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
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
| Field | Type | Security 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:
- 32-bit:
fs:[0x30]→ PEB address - 64-bit:
gs:[0x60]→ PEB address
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 Field | Malware / 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:
| List | Order | Common 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
| Technique | PEB Field Used | Purpose |
|---|---|---|
| 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.