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.
- Caller RA at
[rbp+64]. try_find_mz_base: page-align that pointer, scan backward forMZ, validate with
e_lfanew < 0x1000 and PE\0\0. Yields the injector's base.
- Injector's import directory,
kernel32.dlldescriptor,FirstThunk[0]. This is a
function pointer into kernel32, so it lives somewhere inside kernel32's image.
- Backward
MZscan from it:kernel32base. kernel32's import directory,ntdll.dlldescriptor,FirstThunk[0]. Chained one hop
through kernel32 because most executables do not import from ntdll directly.
- Backward
MZscan:ntdllbase.
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:
- Resolve
LdrGetDllFullNameby ROR-13 hash off ntdll's export table. - Scan the first 256 bytes for
0xE8(the firstCALL rel32) and follow the displacement.
That lands in LdrpFindLoadedDllByHandleFromBase.
- Scan the helper for
48 8B 0D disp32, which ismov rcx, [rip+disp32]loading
tree.Min at tree+8.
- Resolve the RIP-relative target, subtract 8 to reach the tree base.
- 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, 47must 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.