Kernel Callbacks

Drivers register for system events. Defender (WdFilter.sys) typically uses all five:

# API Symbol Event
1 PsSetCreateProcessNotifyRoutine(Ex) PspCreateProcessNotifyRoutine process create/exit
2 PsSetCreateThreadNotifyRoutine(Ex) PspCreateThreadNotifyRoutine thread create/exit
3 PsSetLoadImageNotifyRoutine(Ex) PspLoadImageNotifyRoutine EXE/DLL/driver map
4 ObRegisterCallbacks _OBJECT_TYPE.CallbackList handle open on Process/Thread
5 CmRegisterCallbackEx CallbackListHead registry

Cases 1–3: same shape (fixed 64-slot array). Cases 4–5: linked lists with different removal mechanics.


Setup

setx _NT_SYMBOL_PATH "srv*C:\Symbols*https://msdl.microsoft.com/download/symbols"

One-time. Caches symbols at C:\Symbols.

livekd64.exe -w

-w = write mode. Without it, ep/eb/eq (removal steps) will fail.

.reload /f

Force-load symbols. Run first if any nt!... name fails to resolve.

lm m nt

Show the ntoskrnl module line — confirms symbols resolved.

? nt

Prints the kernel base address. Note it down; all live addresses sit near it.


Shared concept: EX_FAST_REF (Cases 1–3)

Each array slot is a pointer with a refcount packed into the low 4 bits. Strip it before reading the block.

_EX_CALLBACK_ROUTINE_BLOCK
+0x000  RundownProtect    sync (0x20 = active)
+0x008  Function          callback pointer
+0x010  Context           value from registration

To decode a slot value:

block    = slot & 0xFFFFFFFFFFFFFFF0
function = *(block + 0x08)

Case 1 — Process create

dp nt!PspCreateProcessNotifyRoutine L40

Dump all 64 slots (L40 = hex 0x40). Non-zero = occupied. Copy the left-column base address.

dq (<raw_slot_value> & 0xfffffffffffffff0) L2

Strip EX_FAST_REF nibble → points to _EX_CALLBACK_ROUTINE_BLOCK. L2 shows +0x000 and +0x008 Function. Copy the Function value.

lm a <function>

Which module owns that address — identifies the driver that registered (e.g. WdFilter.sys).

? nt!PspCreateProcessNotifyRoutine - nt

RVA of the array symbol — stable per build, changes per boot due to KASLR.

ep <array_base + slot*8> 0

ep = write 8 bytes (pointer). Zeroing a slot unregisters the callback; the kernel skips NULL slots.

Walk all used slots in one command:

.for (r $t0 = 0; @$t0 < 64; r $t0 = @$t0 + 1) { r $t1 = poi(nt!PspCreateProcessNotifyRoutine + (@$t0 * 8)); .if (@$t1 != 0) { r $t2 = poi((@$t1 & 0xfffffffffffffff0) + 8); .printf "Slot %d: fn=%p  ", @$t0, @$t2; lm a @$t2 } }

$t0 = slot index, $t1 = raw slot value, $t2 = decoded function. Prints only used slots with owner.


Case 2 — Thread create

Identical to Case 1. Only the symbol changes.

dp nt!PspCreateThreadNotifyRoutine L40
dq (<raw_slot_value> & 0xfffffffffffffff0) L2
lm a <function>
? nt!PspCreateThreadNotifyRoutine - nt
ep <array_base + slot*8> 0

Case 3 — Image load

Identical to Case 1. Fires for every image map: EXE launch, DLL load, and kernel driver loads.

dp nt!PspLoadImageNotifyRoutine L40
dq (<raw_slot_value> & 0xfffffffffffffff0) L2
lm a <function>
? nt!PspLoadImageNotifyRoutine - nt
ep <array_base + slot*8> 0

Case 4 — Handle callbacks (ObRegisterCallbacks)

Linked list on the Process/Thread object types. Do not unlink — disable via the Active flag instead; the kernel frees these entries itself and expects the list intact.

_OBJECT_TYPE
+0x0C8  CallbackList      LIST_ENTRY head

CALLBACK_ENTRY_ITEM
+0x000  Flink / Blink
+0x010  Operations        1=create, 2=dup, 3=both
+0x014  Active            1=on, 0=off
+0x028  PreOperation      callback pointer
+0x030  PostOperation     or NULL

Empty list: Flink == type_addr + 0xC8.

dp nt!ObTypeIndexTable L10

Dumps 16 type-object pointers. Index [7] = Process type, [8] = Thread type (stable on x64 Win10 — verify on target).

dt nt!_OBJECT_TYPE <type_addr> CallbackList

Shows the CallbackList head at +0x0C8. If Flink == type_addr+0xC8 → nothing registered. Otherwise Flink is the first node.

dq <node> L7

Seven QWORDs = bytes +0x00..+0x37. Reveals: Flink (+0x00), Blink (+0x08), Operations (+0x10), Active (+0x14), PreOperation (+0x28), PostOperation (+0x30). Copy Flink (next node) and PreOperation.

lm a <PreOperation>

Identifies the driver owning this callback.

eb (<node> + 0x14) 0

eb = write one byte. Sets Active to 0 — kernel checks this before calling PreOperation. Write 1 to re-enable.

db <node>+0x14 L1

Verify the byte read back as 00. Then follow Flink to the next node. Stop when Flink == type_addr+0xC8.


Case 5 — Registry (CmRegisterCallbackEx)

List anchored at CallbackListHead. No Active field — removal requires a real unlink.

_CMREG_CALLBACK
+0x000  Flink
+0x008  Blink
+0x028  Function          callback pointer

+0x028 is hex = 40 decimal = QWORD index 5 (0-based).

dq nt!CallbackListHead L2

+0x000 Flink = first node, +0x008 Blink = last node. If Flink == head address → nothing registered. Copy Flink as the first node.

dq <node> L6

Must use L6 — default range stops before +0x28, missing the Function field. Six QWORDs reach it.

lm a <function>

Identifies the driver owning this callback.

Unlink (two pointer fix-ups):

eq <PREV_Flink_addr>  <NEXT>
eq (<NEXT> + 0x08)    <PREV_Flink_addr>

PREV_Flink_addr = the address you read the previous Flink FROM (not the node itself). For the first node, PREV is nt!CallbackListHead. Stop when Flink == head address.

dq <PREV_Flink_addr> L1

Verify it now skips the removed node. Continue from NEXT.


Commands reference

Cmd Does
dp / dq / db dump pointers / QWORDs / bytes (L<n> = item count, hex)
ep / eq / eb write pointer / QWORD / byte
dt type addr field dump one struct field at address
lm a <addr> module containing address
poi(addr) dereference pointer at addr
? expr evaluate expression (RVA math, kernel base)
.for / .if / .printf / r $t0 pseudo-registers + script loop
.reload /f force symbol load

Offsets are build-specific — always confirm with dt on the target.