Chapter 13

Process Internals

The kernel's EPROCESS structure and the user-mode Process Environment Block — what they contain, how the loader uses them, and how malware reads and manipulates them to resolve APIs, detect sandboxes, and hide itself

Scenario

Shellcode you're reversing doesn't import anything. Instead it reads from gs:[0x60], follows two pointer chains, and suddenly has the address of kernel32.dll. Five lines of assembly later it calls LoadLibraryA without ever using the Windows API. The shellcode is walking the PEB to find loaded modules and resolve exports manually — a technique every malware analyst encounters. Understanding the PEB explains exactly what those pointer dereferences are doing.

EPROCESS — The Kernel Process Structure

Every process in Windows has a corresponding kernel object called EPROCESS (Executive Process). This structure is allocated in kernel memory — user-mode code cannot directly read or modify it. The Windows kernel maintains a doubly-linked list of all EPROCESS objects, forming the process list that tools like Task Manager and Process Explorer walk to enumerate running processes.

EPROCESS is a large, undocumented structure (~2,600 bytes on Windows 11 x64). Its layout changes between Windows versions, which is why rootkits that manipulate EPROCESS must account for version differences. The key sub-components are:


  EPROCESS Key Layout (simplified, Windows 10/11 x64)
  ─────────────────────────────────────────────────────────────────────
  +0x000  Pcb               (KPROCESS — scheduling-level process object)
  +0x2E0  ProcessLock       (push lock)
  +0x2E8  UniqueProcessId   (PID — what you see in Task Manager)
  +0x2F0  ActiveProcessLinks(LIST_ENTRY — doubly-linked list of all processes)
  +0x358  Peb               (pointer to PEB — the user-mode half)
  +0x364  Session           (pointer to session object)
  +0x448  ImageFileName     (15-char null-terminated process name, e.g. "notepad.exe")
  +0x4B8  Token             (EX_FAST_REF — security token reference)
  +0x540  ObjectTable       (pointer to process handle table)
  +0x7D8  WoW64Process      (non-NULL if 32-bit process on 64-bit OS)

  Note: These offsets shift between Windows versions.
  Use WinDbg dt nt!_EPROCESS to see live offsets on your system.

  

Key EPROCESS Fields

FieldTypeSecurity Relevance
UniqueProcessId HANDLE (PID) The process ID. Rootkits that perform DKOM (Direct Kernel Object Manipulation) unlink the EPROCESS from the ActiveProcessLinks list to hide a process from enumeration — the PID still exists but the process doesn't appear in lists.
ActiveProcessLinks LIST_ENTRY Doubly-linked list connecting all EPROCESS structures. Removing a process from this list hides it from the OS's process enumeration but not from memory-based detection tools that walk physical memory.
ImageFileName CHAR[15] Short process name (no path). Limited to 15 characters — this is why process names get truncated. Can be spoofed or left blank by some process creation techniques.
Token EX_FAST_REF Pointer to the process's security token (see Chapter 30). Token stealing attacks copy a privileged process's token reference into this field, giving the attacker's process the privileges of the victim.
Peb PEB* Pointer to the user-mode Process Environment Block (see below). This is the bridge between the kernel structure and user-mode process information.
WoW64Process pointer Non-NULL if this is a 32-bit process running under WOW64. Points to the WOW64 compatibility information structure.

Process Environment Block (PEB)

The Process Environment Block (PEB) is the user-mode counterpart to EPROCESS. It lives in user-mode address space, which means your code can read it directly without a system call. The Windows loader, the C runtime, and the heap manager all use the PEB. It's accessible via a fixed segment register offset:

The NtCurrentPeb() macro in the Windows SDK wraps this:

// Access PEB via intrinsic (preferred in user-mode code)
PPEB pPeb = NtCurrentPeb();

// Manual access (used in shellcode where SDK is unavailable)
// 64-bit:
PPEB pPeb = (PPEB)__readgsqword(0x60);
// 32-bit:
PPEB pPeb = (PPEB)__readfsdword(0x30);

PEB Fields


  PEB Key Fields (x64, Windows 10/11)
  ─────────────────────────────────────────────────────────────────────
  +0x000  InheritedAddressSpace     (BOOL)
  +0x002  BeingDebugged             (BOOL) ← anti-debug check #1
  +0x010  ImageBaseAddress          (PVOID) ← base of the main EXE
  +0x018  Ldr                       (PPEB_LDR_DATA) ← module list
  +0x020  ProcessParameters         (PRTL_USER_PROCESS_PARAMETERS)
           └─ CommandLine, ImagePathName, Environment
  +0x070  NtGlobalFlag              (ULONG) ← anti-debug: set 0x70 by debugger
  +0x078  CriticalSectionTimeout    (LARGE_INTEGER)
  +0x088  HeapSegmentReserve        (SIZE_T)
  +0x110  ProcessHeap               (PVOID) ← default heap handle
  +0x118  FastPebLock               (PRTL_CRITICAL_SECTION)
  +0x2EC  OSMajorVersion            (ULONG)
  +0x2F0  OSMinorVersion            (ULONG)
  +0x2F4  OSBuildNumber             (USHORT)
  +0x368  OSPlatformId              (ULONG)

  
PEB FieldMalware / Security Relevance
BeingDebugged (+0x002) Set to 1 when a debugger is attached. The IsDebuggerPresent() API simply reads this byte. Anti-debug checks read it directly: mov al, gs:[peb+2]. Patching it to 0 defeats IsDebuggerPresent but not other checks.
ImageBaseAddress (+0x010) The load address of the main EXE. Used by shellcode and injected code to find the host process's PE and parse it.
Ldr (+0x018) Pointer to PEB_LDR_DATA — the linked list of all loaded modules. Walking this list is how shellcode finds kernel32.dll and ntdll.dll without calling any API.
NtGlobalFlag (+0x070) Set to 0x70 when the process is being debugged (heap debugging flags). Anti-debug checks test for this value. Patching it to 0 defeats this check.
ProcessParameters (+0x020) Pointer to RTL_USER_PROCESS_PARAMETERS containing CommandLine, ImagePathName, environment block. Malware reads command-line arguments and the image path from here without calling GetCommandLine.
ProcessHeap (+0x110) Handle to the default process heap. Used for heap-based anti-debug checks: the heap header has a ForceFlags field that is non-zero under a debugger.

PEB_LDR_DATA — The Module List

PEB.Ldr points to a PEB_LDR_DATA structure that maintains three doubly-linked lists of all loaded modules in the process, each organizing the same modules in a different traversal order:

ListOrderCommon Use
InLoadOrderModuleList Load order (EXE first, then ntdll, then kernel32, then others) Shellcode PEB walkers typically use this — kernel32.dll is reliably in the 3rd position on most Windows versions
InMemoryOrderModuleList Virtual address order (lowest base address first) Less commonly used by malware
InInitializationOrderModuleList DllMain call order (ntdll first since it initializes first) Sometimes used by shellcode to find ntdll specifically

Each list entry is an LDR_DATA_TABLE_ENTRY:

typedef struct _LDR_DATA_TABLE_ENTRY {
    LIST_ENTRY InLoadOrderLinks;        // +0x00: next/prev in load order list
    LIST_ENTRY InMemoryOrderLinks;      // +0x10
    LIST_ENTRY InInitializationOrderLinks; // +0x20
    PVOID      DllBase;                 // +0x30: base address of the DLL
    PVOID      EntryPoint;              // +0x38: DllMain / entry point
    ULONG      SizeOfImage;             // +0x40
    UNICODE_STRING FullDllName;         // +0x48: full path
    UNICODE_STRING BaseDllName;         // +0x58: just the filename
    ULONG      Flags;                   // +0x68
    USHORT     LoadCount;               // +0x6C (varies by version)
    // ... more fields ...
} LDR_DATA_TABLE_ENTRY;

Complete PEB Module Walk

// Walk all loaded modules in a process (from user-mode C)
#include <winternl.h>

void ListLoadedModules() {
    PPEB pPeb = NtCurrentPeb();
    PPEB_LDR_DATA pLdr = pPeb->Ldr;

    // InLoadOrderModuleList.Flink points to first LDR_DATA_TABLE_ENTRY
    PLIST_ENTRY pListHead = &pLdr->InLoadOrderModuleList;
    PLIST_ENTRY pEntry = pListHead->Flink;

    while (pEntry != pListHead) {
        // Recover the LDR_DATA_TABLE_ENTRY from its InLoadOrderLinks member
        PLDR_DATA_TABLE_ENTRY pModule = CONTAINING_RECORD(
            pEntry, LDR_DATA_TABLE_ENTRY, InLoadOrderLinks);

        if (pModule->DllBase) {
            wprintf(L"%p  %wZ  %wZ\n",
                pModule->DllBase,
                &pModule->BaseDllName,
                &pModule->FullDllName);
        }
        pEntry = pEntry->Flink;
    }
}
// Output: ntdll.dll, KERNEL32.dll, KERNELBASE.dll, ...

Malware PEB Abuse

TechniquePEB Field UsedPurpose
Anti-debug: IsDebuggerPresent bypass BeingDebugged Read directly without calling IsDebuggerPresent, avoiding API hooks on that function
Anti-debug: NtGlobalFlag check NtGlobalFlag Check for 0x70 flag set by heap debug mode when debugger is attached
Heap flag check ProcessHeap Read heap header flags (ForceFlags at heap+0x74 on 64-bit) — non-zero under debugger
API resolution (shellcode) Ldr → InLoadOrderModuleList Find kernel32.dll base, parse EAT, resolve LoadLibrary/GetProcAddress without importing anything
Process hollowing detection evasion ImageBaseAddress After hollowing, patch PEB.ImageBaseAddress to point to the injected PE's base so memory scanners see the "right" base
Environment enumeration ProcessParameters → Environment Read environment variables (username, COMPUTERNAME, USERDOMAIN) for sandbox/AV detection without calling GetEnvironmentVariable

Q & A

Can user-mode code access EPROCESS directly?

No — EPROCESS lives in kernel address space, which is protected from user-mode reads and writes by the CPU's memory protection (Ring 0 vs Ring 3 separation). Attempting to read kernel memory from user mode causes an access violation. User-mode code can obtain information about EPROCESS indirectly through: (1) System calls like NtQuerySystemInformation(SystemProcessInformation), which return process information from EPROCESS fields. (2) The OpenProcess API, which returns a handle to a process object, allowing limited operations through system calls. (3) The PEB, which is the user-mode projection of some EPROCESS data — it's at a user-mode address and directly accessible. Debugger-level tools (WinDbg, Process Hacker with kernel driver) can access EPROCESS through kernel debugging or a kernel driver with PsLookupProcessByProcessId. This is why rootkits that manipulate EPROCESS must run as kernel drivers — the manipulation happens in kernel mode.

How do memory forensics tools detect DKOM (hidden processes)?

Direct Kernel Object Manipulation (DKOM) hides a process by unlinking its EPROCESS from the ActiveProcessLinks list. Tools like Task Manager walk this list — the hidden process simply doesn't appear. Memory forensics detects DKOM through cross-view comparison: (1) Pool tag scanning: Windows allocates EPROCESS objects from the Pool allocator with a specific tag (Proc). A memory scanner can scan all kernel memory for pool allocations with this tag, finding all EPROCESS objects regardless of whether they're in the linked list. (2) Thread cross-reference: every ETHREAD (thread kernel object) has a pointer back to its parent EPROCESS. If an EPROCESS isn't in the process list but is referenced by active threads, it's hidden. (3) Handle table scanning: the kernel's handle table references process objects; a process with open handles will still appear in handle table analysis even if unlinked from the process list. Tools like Volatility implement all three approaches and report discrepancies as likely DKOM.

Why is kernel32.dll reliably the 3rd entry in InLoadOrderModuleList?

By convention and Windows design, every process loads a fixed set of initial DLLs in a consistent order: (1) The process's own EXE (InLoadOrderModuleList Flink #1). (2) ntdll.dll — the first DLL mapped by the OS before anything else, at Flink #2. (3) kernel32.dll — loaded as part of the process initialization by ntdll, at Flink #3. This ordering has been consistent since Windows XP. Shellcode relies on this to find kernel32.dll: walk the load order list, skip entry 1 (the EXE) and entry 2 (ntdll.dll), and entry 3 is kernel32.dll. Once kernel32.dll's base is found, parse its EAT to get LoadLibraryA and GetProcAddress, then load any other DLL needed. The caveat: if the process was created with additional DLLs pre-loaded (some protected process configurations), the order might differ. Robust shellcode also verifies by comparing the BaseDllName UNICODE_STRING against the expected name.