PEB Walking
Chapter 3 introduced the PEB walk in C. This chapter rebuilds it from scratch at the assembly instruction level — every structure field named, every offset justified, every instruction explained. When you finish this chapter you can write a working PEB walk from memory, trace through someone else's implementation in a debugger without notes, and identify a broken walk mid-execution by reading register values alone.
Why Go Back to Assembly?
The C version works perfectly for loaders where size does not matter. But shellcode is different in three specific ways that make assembly the right tool:
Compiler output for find_module() with -Os: ───────────────────────────────────────────────────────────────── push rbp / mov rbp, rsp / push r15 / push r14 / push r13 / push r12 / push rbx sub rsp, 0x28 mov rax, [gs:0x60] ; PEB mov rax, [rax+0x18] ; Ldr mov rax, [rax+0x20] ; InMemoryOrderModuleList ... comparison loop with CRT helpers for wide char ops ... Total: ~280 bytes at -Os, ~480 bytes at -O0 Hand-crafted assembly for the same operation: ───────────────────────────────────────────────────────────────── PEB walk + string compare loop: ~90 bytes PEB walk + ROR-13 hash compare: ~55 bytes In a 64-byte injection slot (some exploits) only the hand-crafted version fits. Even in a 512-byte slot, the compiler version uses more than half the budget before the actual payload begins.
Beyond size, assembly gives you instruction-level visibility. When the walk crashes, "my C function does the PEB walk" gives you nothing. "RBX holds the InMemoryOrderModuleList Flink, [RBX+0x48] is BaseDllName.Length" lets you immediately read the register dump and know exactly where it failed.
The Thread Environment Block — Your Starting Point
The chain starts at the Thread Environment Block (TEB). On x64 Windows the GS segment register always points to the current thread's TEB. This is a guaranteed, documented property of the Windows x64 ABI — not an implementation detail.
TEB (Thread Environment Block) — base = GS:[0x00] ┌──────────────────────────────────────────────────────────────────┐ │ +0x000 NtTib.ExceptionList (legacy SEH chain — x86 only) │ │ +0x008 NtTib.StackBase (top of this thread's stack) │ │ +0x010 NtTib.StackLimit (bottom of stack — guard page) │ │ +0x018 NtTib.SubSystemTib (unused in modern Windows) │ │ +0x020 NtTib.FiberData │ │ +0x030 NtTib.Self (pointer to TEB itself — verify) │ │ +0x040 ClientId.UniqueProcess (PID — useful for shellcode ID) │ │ +0x048 ClientId.UniqueThread (TID) │ │ +0x058 ThreadLocalStoragePointer │ │ +0x060 ProcessEnvironmentBlock ← PEB* — THE FIELD WE WANT │ │ +0x068 LastErrorValue (what GetLastError() reads) │ │ +0x070 CountOfOwnedCriticalSections │ │ +0x100 CurrentLocale │ │ +0x180 WowTebOffset (used in WoW64 Heaven's Gate) │ └──────────────────────────────────────────────────────────────────┘ Key instruction: mov rax, qword [gs:0x60] → rax = PEB*
; Three equivalent ways to get the PEB pointer in x64 assembly
; Form 1 — Direct segment access (most common in shellcode, 9 bytes)
mov rax, qword [gs:0x60]
; Encoding: 65 48 8B 04 25 60 00 00 00
; NOTE: contains null bytes at the end — fix required for null-free shellcode (Ch09)
; Form 2 — Via NtCurrentTeb() intrinsic in C
; __readgsqword(0x60) compiles to the same 9-byte sequence above
; Form 3 — Via NtCurrentTeb() API call (adds overhead, not used in shellcode)
; PTEB teb = NtCurrentTeb();
; PPEB peb = teb->ProcessEnvironmentBlock;
The encoding
65 48 8B 04 25 60 00 00 00 contains three null bytes. If your shellcode travels through a string-copy injection path, this kills delivery. Chapter 9 covers the fix: build the offset 0x60 from a non-null value using arithmetic, or use an alternative GS access pattern with a register holding the offset.Verify this in any running process: attach x64dbg, pause, look at the GS register base address in the Registers pane, then go to Memory Map and navigate to GS:[0x60]. The 8-byte value there is the PEB address — compare it to Process Hacker's PEB address for the same process. They will always match.
The Process Environment Block — What It Contains
The PEB is a per-process structure (shared across all threads) that the Windows user-mode subsystem uses to track everything about the running process. It is approximately 0x7D0 bytes in size on Windows 10 x64. The fields most useful to shellcode:
PEB (Process Environment Block) ┌──────────────────────────────────────────────────────────────────┐ │ +0x000 InheritedAddressSpace (BYTE — always 0 normally) │ │ +0x001 ReadImageFileExecOptions (BYTE) │ │ +0x002 BeingDebugged (BYTE) ← 1 if debugger attached │ │ Anti-debug: cmp byte [peb+2], 0 / jne .debugger │ │ +0x008 Mutant (HANDLE — usually -1) │ │ +0x010 ImageBaseAddress (PVOID) ← base of the EXE │ │ +0x018 Ldr (PEB_LDR_DATA*) ← WE WANT THIS │ │ +0x020 ProcessParameters (RTL_USER_PROCESS_PARAMETERS*) │ │ → CommandLine, ImagePathName, CurrentDirectory inside │ │ +0x068 NtGlobalFlag (ULONG) ← 0x70 when debugger │ │ Anti-debug: cmp dword [peb+0x68], 0x70 │ │ +0x070 CriticalSectionTimeout (LARGE_INTEGER) │ │ +0x088 HeapFlags (ULONG) ← 0x40000060 in debugger│ │ +0x0F8 OSMajorVersion (ULONG) ← 10 on Windows 10/11 │ │ +0x0FC OSMinorVersion (ULONG) ← 0 on Win10/11 │ │ +0x100 OSBuildNumber (WORD) ← build number (22621 etc)│ │ +0x2E8 ApiSetMap (PVOID) ← API Set Schema host │ └──────────────────────────────────────────────────────────────────┘ Fields used in the PEB walk: PEB + 0x018 → Ldr Fields used in anti-debug (Ch52): PEB + 0x002, +0x068, +0x088 Fields used for environment keys: PEB + 0x0F8/0xFC/0x100 (OS version)
PEB_LDR_DATA — The Module List Container
PEB.Ldr points to a PEB_LDR_DATA structure that lives inside ntdll.dll's data section. It holds three doubly-linked lists. All three traverse the same loaded modules but in different orders:
PEB_LDR_DATA
┌──────────────────────────────────────────────────────────────────┐
│ +0x000 Length (ULONG — 0x58 on x64) │
│ +0x004 Initialized (BOOLEAN — 1 when ready) │
│ +0x008 SsHandle (HANDLE — unused) │
│ +0x010 InLoadOrderModuleList (LIST_ENTRY ← load order) │
│ +0x020 InMemoryOrderModuleList (LIST_ENTRY ← memory order) ★ │
│ +0x030 InInitializationOrderModuleList (LIST_ENTRY) │
│ +0x040 EntryInProgress (PVOID) │
│ +0x048 ShutdownInProgress (BOOLEAN) │
│ +0x050 ShutdownThreadId (HANDLE) │
└──────────────────────────────────────────────────────────────────┘
★ InMemoryOrderModuleList is the conventionally used list for shellcode.
Its Flink at PEB_LDR_DATA + 0x20 is the first module entry.
LIST_ENTRY structure (2 pointers, 16 bytes total):
┌──────────────────────┐
│ +0x00 Flink (→ next)│
│ +0x08 Blink (← prev)│
└──────────────────────┘
Typical InMemoryOrderModuleList traversal order:
[0] — The process EXE itself (e.g., runner.exe)
[1] — ntdll.dll
[2] — kernel32.dll
[3] — Additional DLLs in address order
(Order [0-2] is consistent across Windows XP through Windows 11,
though not formally documented by Microsoft)LDR_DATA_TABLE_ENTRY — Every Field, Every Offset
Each node in the module lists is an LDR_DATA_TABLE_ENTRY structure. The critical subtlety: the LIST_ENTRY fields embedded inside this structure are the actual list links. When the Flink of one entry's InMemoryOrderLinks points to the "next" entry, it points to that entry's InMemoryOrderLinks field — not to the start of the structure. Every field offset you compute must account for this +0x10 adjustment.
LDR_DATA_TABLE_ENTRY (starts at base address B)
┌──────────────────────────────────────────────────────────────────┐
│ B+0x000 InLoadOrderLinks (LIST_ENTRY, 16 bytes) │
│ B+0x010 InMemoryOrderLinks (LIST_ENTRY, 16 bytes) ← ★ │
│ Flink = B+0x010, Blink = B+0x018 │
│ B+0x020 InInitializationOrderLinks (LIST_ENTRY, 16 bytes) │
│ B+0x030 DllBase (PVOID) ← base address of this DLL │
│ B+0x038 EntryPoint (PVOID) ← DLL entry point address │
│ B+0x040 SizeOfImage (ULONG + 4 pad) │
│ B+0x048 FullDllName (UNICODE_STRING, 16 bytes) │
│ Length=B+0x048, MaxLen=B+0x04A, Buffer=B+0x050 │
│ Buffer → "C:\Windows\System32\kernel32.dll" │
│ B+0x058 BaseDllName (UNICODE_STRING, 16 bytes) ← WE USE THIS │
│ Length=B+0x058 (WORD, byte count) │
│ MaxLen=B+0x05A (WORD) │
│ Padding=B+0x05C (DWORD) │
│ Buffer=B+0x060 (PWSTR → "kernel32.dll") │
│ B+0x068 Flags (ULONG — various load flags) │
│ B+0x06C LoadCount (WORD) │
│ B+0x06E TlsIndex (WORD) │
│ B+0x070 HashLinks (LIST_ENTRY — hash table chain) │
│ B+0x080 TimeDateStamp (ULONG — PE build timestamp) │
└──────────────────────────────────────────────────────────────────┘
★ When RBX = address of InMemoryOrderLinks (= B + 0x10):
DllBase = [RBX + 0x20] (B+0x30 - 0x10 = B+0x20)
EntryPoint = [RBX + 0x28]
SizeOfImage = [RBX + 0x30]
FullDllName.Buf = [RBX + 0x40]
BaseDllName.Len = WORD[RBX + 0x48] ← character count × 2
BaseDllName.Buf = [RBX + 0x50] ← PWSTR, not null-terminatedThe offset math is simple once you see the pattern: every field at structure offset F is accessed as [RBX + (F - 0x10)] when RBX points to InMemoryOrderLinks.
UNICODE_STRING — How Module Names Are Stored
BaseDllName is not a C string. Windows uses UNICODE_STRING, which carries its own length. This avoids the O(n) null-terminator scan but requires you to use the Length field for comparison bounds:
typedef struct _UNICODE_STRING {
USHORT Length; // byte count (NOT character count)
// "kernel32.dll" = 13 chars = 26 bytes
USHORT MaxLength; // maximum buffer capacity in bytes
DWORD Padding; // 4 bytes on x64 to align Buffer to 8 bytes
PWSTR Buffer; // pointer to UTF-16LE chars, often NOT null-terminated
} UNICODE_STRING;
// "kernel32.dll" stored in memory as BaseDllName.Buffer:
// Offset: 0x00 0x02 0x04 0x06 0x08 0x0A 0x0C 0x0E 0x10 0x12 0x14 0x16 0x18
// Bytes: 6B00 6500 7200 6E00 6500 6C00 3300 3200 2E00 6400 6C00 6C00 0000
// 'k' 'e' 'r' 'n' 'e' 'l' '3' '2' '.' 'd' 'l' 'l' NUL
// Length = 26 (13 chars × 2 bytes each)
// Note: null terminator IS often present but Length doesn't include it
Two rules for comparing BaseDllName: (1) use Length / 2 as the character count limit, not a null-terminator scan; (2) compare case-insensitively — the case of DLL names in the module list depends on how they were originally requested and can vary between Windows versions.
Complete Walk — Annotated NASM Assembly
Here is the full PEB walk, readable version, every instruction commented. Study this until you can reconstruct it without notes:
; find_module.asm
; Walk InMemoryOrderModuleList looking for a DLL by wide name.
; Input: RCX = null-terminated WCHAR* (search name, e.g. L"kernel32.dll")
; Output: RAX = DllBase of matched module, or 0 if not found
; Clobbers: RAX, RBX, RCX, RDX, RSI, RDI, R12–R15
BITS 64
global find_module
section .text
find_module:
; ── Prologue ───────────────────────────────────────────────────────
push rbp
mov rbp, rsp
push r12
push r13
push r14
push r15
push rbx
sub rsp, 0x28 ; 32-byte shadow space + 8 to maintain alignment
mov r12, rcx ; r12 = search name pointer (save before RCX clobbered)
; ── Step 1: GS:[0x60] → PEB ────────────────────────────────────────
mov rbx, qword [gs:0x60] ; rbx = PEB*
; Optional anti-debug check here:
; movzx eax, byte [rbx+0x02] ; PEB.BeingDebugged
; test eax, eax ; non-zero = debugger attached
; jnz .bail ; react to debugger
; ── Step 2: PEB.Ldr → PEB_LDR_DATA* ───────────────────────────────
mov rbx, qword [rbx+0x18] ; rbx = PEB_LDR_DATA*
; ── Step 3: Get InMemoryOrderModuleList head and first entry ────────
; PEB_LDR_DATA.InMemoryOrderModuleList is at offset 0x20
; The LIST_ENTRY at +0x20 is the sentinel head node.
; Its Flink (+0x20 + 0x00) points to the first real module entry's
; InMemoryOrderLinks field.
lea r13, [rbx+0x20] ; r13 = &head LIST_ENTRY (sentinel, for loop termination)
mov rbx, qword [rbx+0x20] ; rbx = head.Flink = first module entry (InMemoryOrderLinks)
.loop_entry:
; ── Circular list termination: if we're back at the head, stop ──────
cmp rbx, r13
je .not_found
; ── Load BaseDllName fields from current entry ───────────────────────
; BaseDllName.Length is at InMemoryOrderLinks + 0x48 (= LDR_ENTRY+0x58 - 0x10)
movzx r14, word [rbx+0x48] ; r14 = BaseDllName.Length (bytes)
test r14, r14
jz .advance ; skip entries with no name
; BaseDllName.Buffer is at InMemoryOrderLinks + 0x50 (= LDR_ENTRY+0x60 - 0x10)
mov r15, qword [rbx+0x50] ; r15 = BaseDllName.Buffer (PWCHAR)
test r15, r15
jz .advance
; ── Case-insensitive wide string comparison ───────────────────────────
shr r14, 1 ; r14 = character count (Length / 2)
xor rdi, rdi ; rdi = index i (0-based)
.char_cmp:
; End of list entry's name?
cmp rdi, r14
jge .check_end_of_search ; consumed all list chars
; Load character from list entry name
movzx rax, word [r15 + rdi*2] ; list_char = BaseDllName.Buffer[i]
; Load character from search string
movzx rcx, word [r12 + rdi*2] ; search_char = target[i]
; Search string null terminator → it ended before list entry name → no match
test rcx, rcx
jz .mismatch
; Uppercase list char (a-z → A-Z, ASCII only)
cmp rax, 'a'
jl .skip_up_list
cmp rax, 'z'
jg .skip_up_list
sub rax, 32
.skip_up_list:
; Uppercase search char
cmp rcx, 'a'
jl .skip_up_search
cmp rcx, 'z'
jg .skip_up_search
sub rcx, 32
.skip_up_search:
cmp rax, rcx
jne .mismatch
inc rdi
jmp .char_cmp
.check_end_of_search:
; List entry name fully consumed — check search string also ended
movzx rax, word [r12 + rdi*2]
test rax, rax
jnz .mismatch ; search string still has chars → different length → no match
; ── Match found ───────────────────────────────────────────────────────
; DllBase is at InMemoryOrderLinks + 0x20 (= LDR_ENTRY+0x30 - 0x10)
mov rax, qword [rbx+0x20] ; rax = DllBase
jmp .done
.mismatch:
.advance:
; Follow Flink to next entry (InMemoryOrderLinks.Flink is at rbx+0x00)
mov rbx, qword [rbx]
jmp .loop_entry
.not_found:
xor rax, rax ; return NULL
.done:
add rsp, 0x28
pop rbx
pop r15
pop r14
pop r13
pop r12
pop rbp
ret
Export Directory Walk in Assembly
With DllBase in hand, you navigate to the export directory and search by function name. The three parallel arrays (AddressOfFunctions, AddressOfNames, AddressOfNameOrdinals) must be walked together:
DllBase
├── [+0x3C] e_lfanew → IMAGE_NT_HEADERS64 offset from DllBase
│
IMAGE_NT_HEADERS64 (at DllBase + e_lfanew)
├── [+0x00] Signature ("PE\0\0")
├── [+0x04] FileHeader (IMAGE_FILE_HEADER, 20 bytes)
├── [+0x18] OptionalHeader starts here (IMAGE_OPTIONAL_HEADER64)
│ ├── [+0x00] Magic (0x020B for PE32+)
│ ├── [+0x10] AddressOfEntryPoint
│ ├── [+0x18] ImageBase
│ ├── [+0x38] SizeOfImage
│ ├── [+0x70] DataDirectory[0].VirtualAddress ← export dir RVA
│ │ = NT+0x18+0x70 = NT+0x88 from NT headers base
│ └── [+0x74] DataDirectory[0].Size
│
IMAGE_EXPORT_DIRECTORY (at DllBase + DataDirectory[0].VirtualAddress)
├── [+0x00] Characteristics (usually 0)
├── [+0x04] TimeDateStamp
├── [+0x0C] Name (RVA → DLL name string)
├── [+0x10] Base (ordinal base value, usually 1)
├── [+0x14] NumberOfFunctions (count of AddressOfFunctions entries)
├── [+0x18] NumberOfNames (count of named exports)
├── [+0x1C] AddressOfFunctions (RVA → DWORD[] of function RVAs)
├── [+0x20] AddressOfNames (RVA → DWORD[] of name RVAs)
└── [+0x24] AddressOfNameOrdinals (RVA → WORD[] of ordinal indices)
To find "VirtualAlloc":
1. for i = 0 to NumberOfNames-1:
2. if strcmp(DllBase + AddressOfNames[i], "VirtualAlloc") == 0:
3. ord = AddressOfNameOrdinals[i]
4. return DllBase + AddressOfFunctions[ord]; get_export.asm
; Find a function by ASCII name in a PE's export directory.
; Input: RCX = DllBase, RDX = null-terminated ASCII function name
; Output: RAX = function virtual address, or 0 if not found
BITS 64
global get_export
section .text
get_export:
push rbp
mov rbp, rsp
push rbx
push r12
push r13
push r14
push r15
push rsi
push rdi
sub rsp, 0x28
mov r12, rcx ; r12 = DllBase
mov r13, rdx ; r13 = function name
; ── Navigate to export directory ───────────────────────────────────
mov eax, dword [r12+0x3C] ; e_lfanew
lea rbx, [r12+rax] ; rbx = NT headers
; DataDirectory[0].VirtualAddress at NT+0x88
mov eax, dword [rbx+0x88] ; export dir RVA
test eax, eax
jz .not_found
lea rbx, [r12+rax] ; rbx = IMAGE_EXPORT_DIRECTORY*
; ── Extract the three arrays ───────────────────────────────────────
mov r14d, dword [rbx+0x18] ; NumberOfNames
test r14d, r14d
jz .not_found
mov eax, dword [rbx+0x1C] ; AddressOfFunctions RVA
lea r15, [r12+rax] ; r15 = function RVA array
mov eax, dword [rbx+0x20] ; AddressOfNames RVA
lea rsi, [r12+rax] ; rsi = name pointer array
mov eax, dword [rbx+0x24] ; AddressOfNameOrdinals RVA
lea rdi, [r12+rax] ; rdi = ordinal index array
; ── Loop through named exports ─────────────────────────────────────
xor ecx, ecx ; i = 0
.name_loop:
cmp ecx, r14d
jge .not_found
; Get name string for names[i]
mov eax, dword [rsi+rcx*4] ; names[i] = RVA of name string
lea rdx, [r12+rax] ; rdx = actual name string pointer
; Compare to our target name (ASCII, case-sensitive)
push rcx ; save loop index
mov rcx, r13 ; rcx = our target name
.strcmp:
movzx rax, byte [rcx]
movzx r8, byte [rdx]
test rax, rax
jz .str_end_check
cmp rax, r8
jne .no_match_str
inc rcx
inc rdx
jmp .strcmp
.str_end_check:
test r8, r8
jnz .no_match_str ; export name still has chars → longer → no match
; ── Strings match ───────────────────────────────────────────────────
pop rcx ; restore loop index i
movzx eax, word [rdi+rcx*2] ; ordinals[i] = 0-based ordinal index
mov eax, dword [r15+rax*4] ; functions[ordinals[i]] = function RVA
; Check for forwarded export: RVA inside export directory range
; (simplified: skip forwarding check — add if targeting ntdll)
lea rax, [r12+rax] ; function VA = DllBase + function RVA
jmp .done
.no_match_str:
pop rcx
inc ecx
jmp .name_loop
.not_found:
xor rax, rax
.done:
add rsp, 0x28
pop rdi
pop rsi
pop r15
pop r14
pop r13
pop r12
pop rbx
pop rbp
ret
Size-Optimized Version — Hash Instead of String Compare
Replacing the string comparison with a ROR-13 hash check (Chapter 7) cuts the walk to ~55 bytes total — small enough to fit in the tightest shellcode slots. The hash is precomputed at build time; at runtime you hash each candidate name and compare a single integer:
; find_kernel32_tiny.asm — ~55 bytes, uses ROR-13 hash
; hash("KERNEL32.DLL") ROR-13 = 0x6A4ABC5B
K32_HASH equ 0x6A4ABC5B
BITS 64
global find_kernel32_tiny
section .text
find_kernel32_tiny:
mov rbx, qword [gs:0x60] ; PEB*
mov rbx, qword [rbx+0x18] ; PEB_LDR_DATA*
mov rsi, qword [rbx+0x20] ; first InMemoryOrderLinks entry
mov rdi, rsi ; save head for termination
.walk:
movzx rcx, word [rsi+0x48] ; BaseDllName.Length (bytes)
shr ecx, 1 ; character count
mov rdx, qword [rsi+0x50] ; BaseDllName.Buffer
xor eax, eax ; hash = 0
.hash:
test ecx, ecx
jz .check
movzx rbx, word [rdx] ; wide char
cmp ebx, 0x61 ; 'a'
jl .no_up
cmp ebx, 0x7A ; 'z'
jg .no_up
sub ebx, 0x20 ; to uppercase
.no_up:
ror eax, 13
add eax, ebx
add rdx, 2
dec ecx
jmp .hash
.check:
cmp eax, K32_HASH
je .found
mov rsi, qword [rsi] ; next Flink
cmp rsi, rdi ; back to head?
jne .walk
xor rax, rax
ret
.found:
mov rax, qword [rsi+0x20] ; DllBase
ret
Version │ Bytes ────────────────────────────────────────────┼────────── Compiler output at -O0 (no optimization) │ ~480 Compiler output at -Os (size optimization) │ ~160 Hand-crafted NASM with string compare │ ~110 Hand-crafted NASM with ROR-13 hash │ ~55 ────────────────────────────────────────────┴────────── For reference: total typical shell exploit payload space: 64–512 bytes
Verification Harness — Test in Isolation
Always isolate and verify the walk before building anything on top of it. One wrong offset here breaks every downstream resolution silently. This C test harness calls your NASM functions and compares their results against Windows' own API:
// test_peb_walk.c — verify find_module() and get_export() against GetModuleHandle/GetProcAddress
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include <stdio.h>
// Declare external NASM functions
extern void* find_module(const WCHAR* name);
extern void* get_export(void* base, const char* name);
#define CHECK(label, got, expected) \
printf("[%s] %-35s got=%p %s\n", \
(void*)(got)==(void*)(expected) ? "PASS" : "FAIL", \
(label), (void*)(got), \
(void*)(got)==(void*)(expected) ? "" : "(expected: " #expected ")")
int main(void) {
puts("=== PEB Walk Verification Harness ===\n");
// Test module finding
puts("--- find_module() ---");
CHECK("kernel32.dll", find_module(L"kernel32.dll"), GetModuleHandleA("kernel32.dll"));
CHECK("ntdll.dll", find_module(L"ntdll.dll"), GetModuleHandleA("ntdll.dll"));
CHECK("KERNEL32.DLL (caps)",find_module(L"KERNEL32.DLL"), GetModuleHandleA("kernel32.dll"));
CHECK("Kernel32.Dll (mix)", find_module(L"Kernel32.Dll"), GetModuleHandleA("kernel32.dll"));
CHECK("nothere.dll (NULL)", find_module(L"nothere.dll"), NULL);
// Test export finding
puts("\n--- get_export() ---");
void* k32 = find_module(L"kernel32.dll");
if (k32) {
HMODULE hk32 = (HMODULE)k32;
const char* names[] = {
"GetProcAddress", "LoadLibraryA", "VirtualAlloc", "VirtualFree",
"CreateThread", "WriteProcessMemory", "OpenProcess", "CloseHandle",
"ExitProcess", "Sleep", "GetTickCount64", "CreateFileA"
};
for (int i = 0; i < sizeof(names)/sizeof(*names); i++) {
CHECK(names[i], get_export(k32, names[i]), GetProcAddress(hk32, names[i]));
}
CHECK("NonExistentFn (NULL)", get_export(k32, "NonExistentFn"), NULL);
}
puts("\n=== Done ===");
return 0;
}
# Build and run:
nasm -f win64 find_module.asm -o find_module.obj
nasm -f win64 get_export.asm -o get_export.obj
x86_64-w64-mingw32-gcc -o test_walk.exe test_peb_walk.c \
find_module.obj get_export.obj -lkernel32
./test_walk.exe # every line should show [PASS]
Debugging the Walk in x64dbg
When the verification fails, attach x64dbg and step through these checkpoints. Each step has a definitive correct value you can verify instantly:
Checkpoint 1: After "mov rbx, [gs:0x60]" ───────────────────────────────────────────────────────────────── RBX should equal the value shown in Process Hacker → process → Properties → PEB address. If different: GS is wrong (extremely rare, means very early injection). Checkpoint 2: After "mov rbx, [rbx+0x18]" (PEB → Ldr) ───────────────────────────────────────────────────────────────── RBX should be an address inside ntdll.dll's data section. Check: Memory Map (Alt+M), find ntdll.dll. RBX should be inside it. If wrong: PEB address was incorrect (see checkpoint 1). Checkpoint 3: After "mov rsi, [rbx+0x20]" (Ldr → first entry Flink) ───────────────────────────────────────────────────────────────── RSI = address of first module's InMemoryOrderLinks field. Follow in dump: [RSI+0x50] should be a pointer to a WCHAR string. Read [RSI+0x48] as a WORD — should be 2×(name length). If wrong: you may be using +0x10 or +0x30 instead of +0x20. Checkpoint 4: During comparison loop ───────────────────────────────────────────────────────────────── Watch R14 (char count) and R15 (Buffer pointer) as you step. Right-click R15 in registers → "Follow in Dump". The dump should show wide chars (alternating ASCII + 0x00 bytes). If you see garbage: BaseDllName.Buffer offset is wrong. Checkpoint 5: After match and "mov rax, [rsi+0x20]" ───────────────────────────────────────────────────────────────── RAX should equal the address shown for kernel32 in Memory Map. Cross-reference with the DllBase shown in Process Hacker → Modules.
The Six Most Common PEB Walk Bugs
Bug 1: Wrong list offset (using +0x10 for InLoadOrder instead of +0x20 for InMemoryOrder)
Symptom: Walk succeeds but finds the wrong modules, or misses kernel32
Fix: InMemoryOrderModuleList.Flink is at PEB_LDR_DATA + 0x20
Bug 2: Not subtracting 0x10 when accessing fields from InMemoryOrderLinks
Symptom: DllBase reads garbage, names are garbled, access violations
Fix: When RBX = InMemoryOrderLinks address, DllBase = [RBX+0x20] not [RBX+0x30]
All field accesses = [RBX + (struct_offset - 0x10)]
Bug 3: Using Length as character count without dividing by 2
Symptom: Character loop runs twice as long, compares garbage in second half
Fix: shr rcx, 1 after loading BaseDllName.Length
Bug 4: Case-sensitive module name comparison
Symptom: Works on some machines/versions, fails on others
Fix: Always uppercase both chars before comparing
Windows occasionally has "NTDLL.DLL" instead of "ntdll.dll"
Bug 5: Not checking for circular list termination
Symptom: Infinite loop when module not found; process hangs
Fix: Save the head pointer before loop starts; break when Flink == head
Bug 6: Wrong NT headers DataDirectory offset
Symptom: get_export always returns NULL even for functions that exist
Fix: DataDirectory[0].VirtualAddress = NT_headers + 0x88 on x64
(= +0x18 opt header start + +0x70 DataDirectory[0] offset)Questions & Answers
Why is InMemoryOrderModuleList preferred over InLoadOrderModuleList?
In practice, either works once the process is fully initialized. InMemoryOrderModuleList is conventionally used because it's populated during the memory mapping phase — before initialization runs. If your shellcode executes very early in process lifetime (Early Bird APC, process hollowing before the entry point runs), InInitializationOrderModuleList may be incomplete. InMemoryOrderModuleList and InLoadOrderModuleList are both fully populated once the process's DLL loading sequence completes. The choice is convention, not a hard technical requirement for shellcode that runs at normal post-initialization timing.
Can I hardcode the third-entry shortcut to find kernel32 without walking the list?
You can, and some minimalist shellcode does exactly this: follow Flink twice from the list head to reach the third entry (which is typically kernel32 in InMemoryOrderModuleList). In practice this works reliably across all modern Windows versions. But it breaks on: processes started with alternate load orders, processes that have a DLL forcibly loaded before kernel32 via AppInit_DLLs or similar mechanisms, and some system processes with unusual initialization sequences. The walk costs only ~10 additional instructions. Use the walk.
What's a forwarded export and how does it affect get_export()?
A forwarded export is an entry in AddressOfFunctions whose RVA points inside the export directory itself (within the RVA range of the export directory), rather than to actual code. Instead of a function, it contains an ASCII string like "NTDLL.RtlAllocateHeap" indicating the real implementation is in another DLL. The simplified get_export() above returns this string's address instead of a callable function pointer — which will crash. Detection: after computing the function RVA, check if it falls within DataDirectory[0].VirtualAddress to DataDirectory[0].VirtualAddress + DataDirectory[0].Size. If it does, it's a forwarder — parse the forwarding string and recursively resolve. For the APIs commonly used in shellcode (VirtualAlloc, CreateThread, etc. from kernel32), forwarders don't appear in practice.
What changes for 32-bit shellcode or WoW64?
Four things: (1) Use FS instead of GS, and the PEB pointer is at FS:[0x30] (not 0x60). (2) All pointers are 4 bytes — all offsets are smaller. PEB.Ldr at +0x0C, PEB_LDR_DATA.InMemoryOrderModuleList at +0x14, LDR_ENTRY.InMemoryOrderLinks at +0x08, DllBase at +0x18 (from struct base) = +0x10 from InMemoryOrderLinks pointer, BaseDllName.Length at +0x24, BaseDllName.Buffer at +0x28. (3) The export directory DataDirectory[0] is at NT+0x78 (not 0x88) on PE32. (4) In a WoW64 process, there are actually two PEBs — the 32-bit PEB accessible via FS:[0x30] and a 64-bit PEB accessible via a special technique. Shellcode that needs to be agnostic between 32 and 64 bit uses a CPUID check or reads the process's KUSER_SHARED_DATA to determine the architecture.
Can an EDR detect PEB walking behavior?
EDRs can detect it via several signals. Kernel-mode monitors watch for rapid sequential reads across the PEB region (the "sweep" pattern across LDR_DATA_TABLE_ENTRY structures looks distinctive). Userland hooks on GetModuleHandleA are bypassed by PEB walking — that's the point — but the memory access pattern is still visible to kernel sensors. ETW-TI events can report on unusual memory access patterns. The most common defensive signal is: a process reading the PEB module list without a corresponding GetModuleHandleA call at userland is suspicious. The mitigation in offensive tools is to pre-resolve all needed API pointers during a single fast sweep at startup (minimizing the access pattern duration) and then cache everything in local function pointer tables for the remainder of execution.