back

Walking the loader without the PEB

2026.09

Every shellcode has to answer the same question: where is kernel32? The traditional answers are all signatured. gs:[0x60] to reach the PEB, PEB->Ldr to reach the loader, LIST_ENTRY walk over InLoadOrderModuleList or InMemoryOrderModuleList, hash the base name, done. Every AV, EDR, and static-analysis rule vendor has known this pattern for two decades. LdrpHashTable and the ntdll RVA table are the next-tier variants and are just as well signatured.

stackrb answers the same question without touching any of it. No gs:[...]. No PEB->Ldr. No LIST_ENTRY. No LdrpHashTable. No ntdll RVAs. The primitive it uses instead is LdrpModuleBaseAddressIndex, an unexported RTL_RB_TREE in ntdll's .data, keyed on module base address, indexed by every loaded module. The rest of the chain is figuring out where that tree lives without any of the primitives that would normally get you there.

x64 only. NASM. About one page of assembly, or 240 bytes smaller with -DQUIET.

The chain

The entry point is the return address on the stack, and everything follows from it.

  1. Caller RA at [rbp+64].
  2. try_find_mz_base: page-align that pointer, scan backward for MZ, validate with

e_lfanew < 0x1000 and PE\0\0. Yields the injector's base.

  1. Injector's import directory, kernel32.dll descriptor, FirstThunk[0]. This is a

function pointer into kernel32, so it lives somewhere inside kernel32's image.

  1. Backward MZ scan from it: kernel32 base.
  2. kernel32's import directory, ntdll.dll descriptor, FirstThunk[0]. Chained one hop

through kernel32 because most executables do not import from ntdll directly.

  1. Backward MZ scan: ntdll base.

Nothing here has touched a documented walker structure. The whole discovery so far is "return addresses point into modules, imports point across modules, and PE headers announce themselves." Everything from step 2 onward is arithmetic on the file format Microsoft guarantees not to break.

Finding the tree

LdrpModuleBaseAddressIndex is not exported. It lives at a build-dependent offset in ntdll's .data and has to be located from disassembly of a function that references it.

The chain:

  1. Resolve LdrGetDllFullName by ROR-13 hash off ntdll's export table.
  2. Scan the first 256 bytes for 0xE8 (the first CALL rel32) and follow the displacement.

That lands in LdrpFindLoadedDllByHandleFromBase.

  1. Scan the helper for 48 8B 0D disp32, which is mov rcx, [rip+disp32] loading

tree.Min at tree+8.

  1. Resolve the RIP-relative target, subtract 8 to reach the tree base.
  2. Range-check the helper and the tree against [ntdll_base, ntdll_base + SizeOfImage).

The range checks are the point. Any offset landing outside ntdll's image is a false positive and gets rejected before it can be dereferenced. The find_mov loop retries with a fresh 0x48 on failure, so a spurious byte match in a mov rax, ... earlier in the function does not derail the search.

Decoding the root

The tree is stored with the root pointer XOR-encoded against the tree address itself when the low bit of Min is set:

if (tree->Min & 1)
    root = tree->Root ^ (uintptr_t)tree;

The decoded value has to pass three gates before any pointer walk touches it:

  • 8-byte aligned (test bl, 7).
  • Canonical, bits 47 and above clear (shr rax, 47 must yield zero).
  • Outside ntdll's own image.

Those three gates reject the integers, flags, locks, and code pointers held by neighboring ntdll globals. A bad mov rcx, [rip+d32] match lands on one of those, fails a gate, and the scan retries the next 48 8B 0D candidate.

Deriving the node offset

BaseAddressIndexNode is an RTL_BALANCED_NODE embedded inside LDR_DATA_TABLE_ENTRY at an offset that drifts across Windows releases. Hardcoding it would break the moment Microsoft reorganizes the structure. Deriving it against the live tree is straightforward:

  • For each candidate offset in [0xB0, 0xE0) stepping by 8, run the rbtree query with

ntdll_base as the key.

  • Accept the candidate where (node - off)->DllBase == ntdll_base.

The query is self-verifying because ntdll's base is already known from step 6. If the offset is wrong, the returned node's DllBase will not match, and the loop tries the next candidate. The winning offset is the offset for this Windows build; every subsequent query uses it directly.

The query

rbtree_find_le walks the tree looking for the largest key <= query, in O(log N):

rbtree_find_le:
    xor  eax, eax               ; best
.walk:
    test rcx, rcx
    jz   .out
    mov  r9, rcx
    sub  r9, r8
    mov  r10, [r9 + 0x30]       ; entry->DllBase
    cmp  r10, rdx
    ja   .go_left
    mov  rax, rcx
    mov  rcx, [rcx + 8]         ; Right
    jmp  .walk
.go_left:
    mov  rcx, [rcx]             ; Left
    jmp  .walk

The key handed in is the kernel32 function pointer from step 3. A function pointer sits inside a module's image, so "the largest DllBase less than or equal to this pointer" is the base of the module the pointer belongs to. The winning node's entry contains kernel32's base at +0x30, which the outer routine uses to resolve GetStdHandle and WriteFile by hash and print a proof-of-life banner.

The proof of life

The QUIET build strips the hex formatter and prints one line. The default build patches four hex slots in a template and prints:

[+] stackrb (no PEB, no gs, no LIST_ENTRY)
  k32 backscan : 00007FFDAA250000
  k32 rb-tree  : 00007FFDAA250000
  tree root    : 0000019CA98C5028
  node offset  : 00000000000000C8

The two k32 lines being identical is the whole proof. The backscan number comes from step 4 (an entirely independent path that never touched ntdll). The rb-tree number comes from the tree query. They match, so the tree walk resolved the same kernel32 as the backscan, without hitting any of the walker structures that path would normally use.

Failure behavior

Every chain link is guarded. A hard fail on any step returns NULL up the stack and _start skips to the epilogue rather than dereferencing garbage, so the host process stays alive.

Two residuals matter.

try_find_mz_base requires its input pointer to be inside a mapped image. It fails if the payload is entered from a JIT trampoline whose page is not part of a PE, since the backward MZ scan will not find one.

The default build patches the hex fields of its own template, so the payload page must be writable. RWX injection satisfies this. Under RX, build the banner line in a stack buffer and skip the in-place patch.

What this buys, and what it does not

The signature surface stackrb avoids is well-known: gs:[0x60], PEB->Ldr, the two LIST_ENTRY walks, LdrpHashTable, and the RVA table. Anything watching for those patterns sees a payload that does none of them.

What it does hit is the RB tree, which is a walker structure in its own right, and a disassembly scan for a specific mov rcx, [rip+d32] pattern. A defender who catches the 48 8B 0D scan in LdrpFindLoadedDllByHandleFromBase or watches for a payload dereferencing LdrpModuleBaseAddressIndex catches this. The bet is that neither is in current signature sets, because both require knowing which ntdll global is being resolved and why.

The offset-derivation loop carries the technique across Windows revisions. When Microsoft ships a new LDR_DATA_TABLE_ENTRY layout the offset moves, the loop finds the new one on the first probe, and everything continues. A hardcoded 0xC8 would ship one build and break on the next.

Build

nasm -f bin stackrb.asm -o build/stackrb.bin
nasm -f bin -DQUIET stackrb.asm -o build/stackrb_quiet.bin

-DQUIET removes the hex dump, the hex formatter, and the value stores, leaving the resolution chain plus a single-line banner. About 240 bytes smaller.

Position-independent, x64 only, one page of NASM. The rest of the loader payload is left as an exercise, since stackrb only exists to answer the question the rest of a loader depends on and every serious shellcode has to answer some way.