Chapter 06

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:

C compiler output vs hand-crafted assembly
  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 structure — x64 offsets relevant to shellcode
  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;
Null bytes in GS:[0x60]
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 structure — x64 offsets for shellcode authors
  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 structure — x64 offsets
  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 — complete x64 field layout
  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-terminated

The 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:

Export directory navigation — field offsets from NT headers
  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
Assembled size comparison
  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:

x64dbg debugging checkpoints for PEB walk
  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

PEB walk bugs — cause, symptom, and fix
  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.