The Windows API Bootstrap Problem
Before your shellcode can call a single Win32 function, it has to solve a
circular dependency: to find LoadLibrary, you need
kernel32.dll's base address — but to get that, you'd normally
call GetModuleHandle, which lives in kernel32.dll.
This chapter breaks that circle using a structure the Windows loader leaves
in every process's memory before a single instruction of your code runs.
The Circular Dependency
Imagine you're writing shellcode that will be injected into a remote process.
Your first task is to call MessageBoxA to show a proof-of-concept
dialog. MessageBoxA lives in user32.dll. To load
user32.dll you'd call LoadLibraryA. That lives in
kernel32.dll. To find LoadLibraryA, you'd call
GetProcAddress. That also lives in kernel32.dll.
To get kernel32.dll's base address, you'd call
GetModuleHandleA("kernel32.dll"). That also lives in
kernel32.dll.
You can't use any of these functions until you've found them, and you can't find them without already having them. That's the bootstrap problem.
"I want to call MessageBoxA"
│
▼
Need LoadLibraryA → lives in kernel32.dll
│
▼
Need GetProcAddress → lives in kernel32.dll
│
▼
Need kernel32.dll base address
│
▼
Call GetModuleHandleA("kernel32.dll") → lives in kernel32.dll
│
▼
⟲ CIRCULAR — you cannot start here
The CRT startup code has the same problem and solves it the same way you're about to learn — by reading a data structure the Windows loader populates in every process before it transfers control to any user code.
How the Windows Loader Helps You
When Windows creates a new process or injects a DLL into an existing one, it does a lot of work before your code runs. Part of that work is loading every DLL your binary depends on and recording their base addresses in a per-process data structure. This structure is called the Process Environment Block, or PEB.
The critical insight: the PEB is accessible from user-mode at all times, through a chain of pointers that starts from a CPU segment register. No API call required. No DLL needed. The PEB is just memory — and you can read it directly.
GS segment register
│
│ GS:[0x60] (offset to PEB pointer inside TEB)
▼
TEB (Thread Environment Block)
┌─────────────────────────────────────┐
│ offset 0x00 ExceptionList │
│ offset 0x08 StackBase │
│ ... │
│ offset 0x30 ProcessEnvironmentBlock│ ← pointer to PEB
│ ... │
└───────────────┬─────────────────────┘
│
▼
PEB (Process Environment Block)
┌─────────────────────────────────────┐
│ offset 0x00 InheritedAddressSpace │
│ offset 0x02 BeingDebugged │ ← anti-debug flag (later)
│ ... │
│ offset 0x18 Ldr │ ← pointer to PEB_LDR_DATA
│ ... │
└───────────────┬─────────────────────┘
│
▼
PEB_LDR_DATA
┌─────────────────────────────────────┐
│ offset 0x00 Length │
│ offset 0x08 Initialized │
│ offset 0x10 SsHandle │
│ offset 0x18 InLoadOrderModuleList │ ← doubly-linked list (load order)
│ offset 0x28 InMemoryOrderModuleList│ ← doubly-linked list (memory order)
│ offset 0x38 InInitializationOrderModuleList │
└─────────────────────────────────────┘
│
▼ (walking InMemoryOrderModuleList)
LDR_DATA_TABLE_ENTRY (one per loaded module)
┌─────────────────────────────────────┐
│ InLoadOrderLinks (LIST_ENTRY) │
│ InMemoryOrderLinks (LIST_ENTRY) │ ← Flink/Blink here
│ InInitializationOrderLinks │
│ DllBase (PVOID) │ ← the module's base address!
│ EntryPoint (PVOID) │
│ SizeOfImage (ULONG) │
│ FullDllName (UNICODE_STRING)│ ← full path
│ BaseDllName (UNICODE_STRING)│ ← just the filename
└─────────────────────────────────────┘
Every DLL loaded into the process appears in this list. The Windows loader
populates it during process initialization — before your entry point or any
injected shellcode runs. This means kernel32.dll is already
in this list when your code starts.
The Structures in C
The Windows SDK headers define most of these structures, but they're spread across different header files and sometimes incomplete. For offensive code, it's cleaner to define the fields you actually need yourself. Here are the minimal definitions for the PEB walk:
// peb.h — minimal structure definitions for PEB walking
// No Windows SDK headers required for these definitions
#pragma once
#include <windows.h>
// A doubly-linked list entry
typedef struct _MY_LIST_ENTRY {
struct _MY_LIST_ENTRY* Flink; // forward pointer
struct _MY_LIST_ENTRY* Blink; // backward pointer
} MY_LIST_ENTRY;
// A counted Unicode string (how Windows stores wide strings internally)
typedef struct _MY_UNICODE_STRING {
USHORT Length; // length in BYTES (not characters)
USHORT MaximumLength;
WCHAR* Buffer; // the actual string (not null-terminated, use Length)
} MY_UNICODE_STRING;
// One entry in the module list — one per loaded DLL/EXE
typedef struct _MY_LDR_DATA_TABLE_ENTRY {
MY_LIST_ENTRY InLoadOrderLinks;
MY_LIST_ENTRY InMemoryOrderLinks;
MY_LIST_ENTRY InInitializationOrderLinks;
PVOID DllBase; // base address of the loaded module
PVOID EntryPoint;
ULONG SizeOfImage;
MY_UNICODE_STRING FullDllName; // full path: C:\Windows\System32\kernel32.dll
MY_UNICODE_STRING BaseDllName; // just the name: kernel32.dll
} MY_LDR_DATA_TABLE_ENTRY;
// The module list header
typedef struct _MY_PEB_LDR_DATA {
ULONG Length;
BOOLEAN Initialized;
PVOID SsHandle;
MY_LIST_ENTRY InLoadOrderModuleList;
MY_LIST_ENTRY InMemoryOrderModuleList;
MY_LIST_ENTRY InInitializationOrderModuleList;
} MY_PEB_LDR_DATA;
// The PEB itself (only the fields we need)
typedef struct _MY_PEB {
BYTE Reserved1[2];
BYTE BeingDebugged; // non-zero if a debugger is attached
BYTE Reserved2[21];
MY_PEB_LDR_DATA* Ldr; // offset 0x18 on x64
} MY_PEB;
The Windows SDK does define
PEB and LDR_DATA_TABLE_ENTRY,
but they're in <winternl.h> and the definitions are intentionally
incomplete (Microsoft doesn't officially support direct PEB access from user code).
Defining your own also means your code doesn't depend on a specific SDK version
and you understand every field you're accessing. In shellcode, understanding what
you're reading at what offset matters — you can't afford mysterious bugs.
Reading the PEB From C
Getting the PEB pointer on x64 requires reading from the GS segment register at a specific offset. There is no C statement that does this directly — you need either an intrinsic or a tiny inline assembly stub:
// Method 1: MSVC intrinsic (works on MSVC and recent MinGW)
#include <intrin.h>
MY_PEB* get_peb(void) {
return (MY_PEB*)__readgsqword(0x60);
// On x86 (32-bit), use: __readfsdword(0x30)
}
// Method 2: Inline assembly (MinGW / GCC)
MY_PEB* get_peb(void) {
MY_PEB* peb;
__asm__ volatile (
"mov %%gs:0x60, %0" // read 64-bit value at GS:0x60
: "=r"(peb)
);
return peb;
}
// Method 3: NtCurrentPeb() macro (if you have winternl.h)
// #include <winternl.h>
// MY_PEB* peb = (MY_PEB*)NtCurrentPeb();
All three methods produce the same result: a pointer to the current
thread's PEB. On x64, GS:[0x60] always holds this pointer —
this is a documented, stable ABI that Microsoft has not and cannot change
without breaking all existing Windows software.
Walking the Module List
With the PEB in hand, walking to the module list takes three pointer
dereferences. Then you iterate the doubly-linked list, comparing each
entry's BaseDllName against the module you're looking for:
// Case-insensitive wide-string comparison (no CRT)
static int wstr_iequal(WCHAR* a, USHORT a_len, const WCHAR* b) {
USHORT i = 0;
while (i < a_len / sizeof(WCHAR) && b[i]) {
WCHAR ca = a[i];
WCHAR cb = b[i];
// Uppercase both (simple ASCII range only — DLL names are ASCII)
if (ca >= L'a' && ca <= L'z') ca -= 32;
if (cb >= L'a' && cb <= L'z') cb -= 32;
if (ca != cb) return 0;
i++;
}
// Match only if both strings ended at the same point
return (i == a_len / sizeof(WCHAR)) && !b[i];
}
// Find a loaded module by name — returns its base address or NULL
void* find_module(const WCHAR* name) {
MY_PEB* peb = get_peb();
// Ldr->InMemoryOrderModuleList is the head of the circular list
MY_LIST_ENTRY* head = &peb->Ldr->InMemoryOrderModuleList;
MY_LIST_ENTRY* curr = head->Flink;
while (curr != head) {
// Each list entry IS the InMemoryOrderLinks field inside an
// LDR_DATA_TABLE_ENTRY. To get the full entry, subtract the
// offset of InMemoryOrderLinks from the pointer.
MY_LDR_DATA_TABLE_ENTRY* entry = (MY_LDR_DATA_TABLE_ENTRY*)(
(BYTE*)curr - offsetof(MY_LDR_DATA_TABLE_ENTRY, InMemoryOrderLinks)
);
if (entry->BaseDllName.Buffer &&
wstr_iequal(entry->BaseDllName.Buffer,
entry->BaseDllName.Length, name)) {
return entry->DllBase; // found it
}
curr = curr->Flink; // advance to next entry
}
return NULL; // not found
}
The linked list pointer stored in each
LDR_DATA_TABLE_ENTRY
is the InMemoryOrderLinks field — not the start of the
structure. When you dereference curr, you're pointing at
the InMemoryOrderLinks field inside the entry,
not at the entry itself. To get back to the start of the
LDR_DATA_TABLE_ENTRY, you subtract the compile-time offset
of that field using offsetof(). This is the standard
CONTAINING_RECORD / container_of pattern used throughout the Windows kernel
and any code that walks kernel-style doubly-linked lists.
Parsing the Export Directory to Find Functions
You now have kernel32.dll's base address. But you can't call
GetProcAddress yet — you need to find GetProcAddress
itself first, along with any other function you want. The way to do this is
to parse the PE's export directory, which lists every function
the DLL exports by name and ordinal.
DLL base address (e.g. 0x7FFB12340000)
│
│ + e_lfanew → PE signature → Optional Header → DataDirectory[0]
▼
IMAGE_EXPORT_DIRECTORY
┌─────────────────────────────────────────────────────┐
│ Characteristics (unused) │
│ TimeDateStamp │
│ MajorVersion / MinorVersion │
│ Name RVA → "KERNEL32.dll\0" │
│ Base (ordinal base, usually 1) │
│ NumberOfFunctions (total exported functions) │
│ NumberOfNames (functions exported by name) │
│ AddressOfFunctions RVA → DWORD array [N] │ ← function RVAs
│ AddressOfNames RVA → DWORD array [N] │ ← name string RVAs
│ AddressOfNameOrdinals RVA → WORD array [N] │ ← name→ordinal map
└─────────────────────────────────────────────────────┘
To find function "GetProcAddress":
1. Iterate AddressOfNames array
2. At index i, AddressOfNames[i] is an RVA → a string
3. Compare that string to "GetProcAddress"
4. When found at index i, read AddressOfNameOrdinals[i] → ordinal j
5. Read AddressOfFunctions[j] → RVA of the function
6. Add DLL base → actual address of GetProcAddress
// Resolve a function by name from a module's export directory
// base_addr: the DllBase from find_module()
// func_name: ASCII function name to find
void* get_export(void* base_addr, const char* func_name) {
BYTE* base = (BYTE*)base_addr;
// Step 1: get to the PE headers
IMAGE_DOS_HEADER* dos = (IMAGE_DOS_HEADER*)base;
IMAGE_NT_HEADERS* nt = (IMAGE_NT_HEADERS*)(base + dos->e_lfanew);
// Step 2: find the export directory RVA
DWORD exp_rva = nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT]
.VirtualAddress;
if (!exp_rva) return NULL;
IMAGE_EXPORT_DIRECTORY* exp = (IMAGE_EXPORT_DIRECTORY*)(base + exp_rva);
// Step 3: get the three arrays
DWORD* names = (DWORD*)(base + exp->AddressOfNames);
WORD* ordinals = (WORD*) (base + exp->AddressOfNameOrdinals);
DWORD* funcs = (DWORD*)(base + exp->AddressOfFunctions);
// Step 4: walk the name array looking for our function
for (DWORD i = 0; i < exp->NumberOfNames; i++) {
char* name = (char*)(base + names[i]);
// Simple strcmp (no CRT)
const char* a = name;
const char* b = func_name;
while (*a && *a == *b) { a++; b++; }
if (*a != *b) continue; // not a match
// Step 5: found — look up via ordinal
WORD ord = ordinals[i];
DWORD func_rva = funcs[ord];
// Watch for forwarded exports (RVA falls inside export directory)
// For now we skip them — kernel32 functions we care about aren't forwarded
return (void*)(base + func_rva);
}
return NULL; // not found
}
The Complete Bootstrap: From Zero to Any API
Combining the PEB walk and export directory parser gives you a complete
bootstrap sequence. From here, you can reach any function in any loaded DLL,
and from kernel32 you can call LoadLibraryA to bring in any
other DLL you need.
// bootstrap.c — full working example: PEB walk → kernel32 → any API
// No imports required. Zero strings in the binary (we hardcode wide string inline).
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include "peb.h" // our structure definitions from earlier
// ── Inline assembly PEB access ─────────────────────────────────────
static MY_PEB* get_peb(void) {
MY_PEB* p;
__asm__ volatile ("mov %%gs:0x60, %0" : "=r"(p));
return p;
}
// ── Case-insensitive wide string compare ───────────────────────────
static int wstr_iequal(WCHAR* a, USHORT bytes, const WCHAR* b) {
USHORT i = 0, n = bytes / sizeof(WCHAR);
while (i < n && b[i]) {
WCHAR ca = a[i], cb = b[i];
if (ca >= L'a' && ca <= L'z') ca -= 32;
if (cb >= L'a' && cb <= L'z') cb -= 32;
if (ca != cb) return 0;
i++;
}
return (i == n) && !b[i];
}
// ── ASCII strcmp (no CRT) ──────────────────────────────────────────
static int astr_equal(const char* a, const char* b) {
while (*a && *a == *b) { a++; b++; }
return *a == *b;
}
// ── PEB walk: find module by wide name ─────────────────────────────
static void* find_module(const WCHAR* name) {
MY_LIST_ENTRY* head = &get_peb()->Ldr->InMemoryOrderModuleList;
MY_LIST_ENTRY* curr = head->Flink;
while (curr != head) {
MY_LDR_DATA_TABLE_ENTRY* e = (MY_LDR_DATA_TABLE_ENTRY*)(
(BYTE*)curr - offsetof(MY_LDR_DATA_TABLE_ENTRY, InMemoryOrderLinks));
if (e->BaseDllName.Buffer &&
wstr_iequal(e->BaseDllName.Buffer, e->BaseDllName.Length, name))
return e->DllBase;
curr = curr->Flink;
}
return NULL;
}
// ── Export directory walk: find function by ASCII name ─────────────
static void* get_export(void* base, const char* func) {
BYTE* b = (BYTE*)base;
IMAGE_NT_HEADERS* nt = (IMAGE_NT_HEADERS*)(b +
((IMAGE_DOS_HEADER*)b)->e_lfanew);
DWORD erva = nt->OptionalHeader.DataDirectory[0].VirtualAddress;
if (!erva) return NULL;
IMAGE_EXPORT_DIRECTORY* ed = (IMAGE_EXPORT_DIRECTORY*)(b + erva);
DWORD* names = (DWORD*)(b + ed->AddressOfNames);
WORD* ords = (WORD*) (b + ed->AddressOfNameOrdinals);
DWORD* funcs = (DWORD*)(b + ed->AddressOfFunctions);
for (DWORD i = 0; i < ed->NumberOfNames; i++) {
if (astr_equal((char*)(b + names[i]), func))
return (void*)(b + funcs[ords[i]]);
}
return NULL;
}
// ── Function pointer types ─────────────────────────────────────────
typedef HMODULE (WINAPI *fn_LoadLibraryA) (LPCSTR);
typedef FARPROC (WINAPI *fn_GetProcAddress)(HMODULE, LPCSTR);
typedef int (WINAPI *fn_MessageBoxA) (HWND, LPCSTR, LPCSTR, UINT);
typedef void (WINAPI *fn_ExitProcess) (UINT);
// ── Entry point ────────────────────────────────────────────────────
void _start(void) {
// 1. Find kernel32.dll via PEB walk — zero API calls
void* k32 = find_module(L"kernel32.dll");
// 2. Find LoadLibrary and GetProcAddress by parsing kernel32's exports
fn_LoadLibraryA pLoadLibrary = (fn_LoadLibraryA) get_export(k32, "LoadLibraryA");
fn_GetProcAddress pGetProcAddress= (fn_GetProcAddress) get_export(k32, "GetProcAddress");
fn_ExitProcess pExitProcess = (fn_ExitProcess) get_export(k32, "ExitProcess");
// 3. Now use LoadLibrary to bring in any DLL we need
HMODULE user32 = pLoadLibrary("user32.dll");
// 4. Resolve MessageBoxA from user32 via GetProcAddress
fn_MessageBoxA pMessageBox = (fn_MessageBoxA)
pGetProcAddress(user32, "MessageBoxA");
// 5. Call it — this works from shellcode with ZERO imports
pMessageBox(NULL, "PEB walk succeeded.", "Bootstrap", MB_OK);
pExitProcess(0);
}
This program has no imports in its PE import directory. It finds and calls
MessageBoxA — from a completely different DLL than kernel32 —
entirely through runtime resolution. This is the foundation that every
technique in this book builds on.
IMPORTS: (empty — no import directory)
EXPORTS: (empty)
SECTIONS: .text .rdata
SIZE: ~4 KB
At runtime, the process memory looks like:
┌──────────────────────────────────────┐
│ our shellcode/PE │
│ [runs, walks GS:0x60 → PEB → Ldr] │
├──────────────────────────────────────┤
│ ntdll.dll (always loaded by OS) │
├──────────────────────────────────────┤
│ kernel32.dll (always loaded by OS) │ ← we find this via PEB
├──────────────────────────────────────┤
│ user32.dll (loaded by us) │ ← we load this via LoadLibrary
└──────────────────────────────────────┘
Module Load Order and Why Position Is Fragile
You may have read that kernel32.dll is "always the third entry"
in the module list, or that ntdll.dll is "always first." This is
approximately true for a vanilla Windows process — but it is fragile:
- Security products that inject into processes early (like EDR DLLs) may
appear before
kernel32.dllin the list. - Processes created with specific flags or under AppVerifier may have a different load order.
- Windows version differences shift the order of some early-loaded DLLs.
Hardcoding a position index (entry = list[2]) breaks on any
of these cases. Comparing by name (as the implementation above does) is the
correct approach. The name comparison overhead is negligible — the list typically
has fewer than 30 entries.
What About ntdll.dll?
ntdll.dll is also always in the module list and is actually more
useful than kernel32.dll for many purposes — it contains the actual
syscall stubs (Parts 5 and 6 will use these heavily), the Rtl* utility functions,
and Ldr* functions for manual library loading. You find it exactly the same way:
find_module(L"ntdll.dll").
Many production implants resolve their entire API set from ntdll.dll
alone, bypassing kernel32.dll entirely, because ntdll sits closer
to the kernel and its functions are hooked less aggressively by most EDRs.
You'll see this pattern throughout Parts 4 and 5.
Questions & Answers
Is GS:[0x60] documented? Will Microsoft ever change it?
It is not documented as a public API, but it is a de facto ABI that Microsoft
cannot change without breaking every piece of software that uses it — including
their own. The Windows loader itself uses this offset. The CRT startup code
uses this offset. Compilers targeting Windows assume this offset. It has been
stable since Windows XP and will remain stable indefinitely. The same guarantee
applies to PEB.Ldr at offset 0x18 and the module list structure.
These are effectively permanent.
Why not just call GetModuleHandle from a standard import?
In normal CRT-free code (Chapter 2) you can — import three functions from
kernel32 and use GetModuleHandleA and GetProcAddress
to resolve everything else. That's completely valid and is what most Windows
programs do. The PEB walk becomes essential in two specific scenarios:
(1) shellcode, where you have literally zero imports and the PE has no import
directory at all, and (2) when you want to bypass EDR hooks that sit on top
of GetModuleHandleA and GetProcAddress. By going
directly to the PEB, you bypass those hooks entirely. Part 3 (injection) will
use the PEB walk for exactly this reason.
What happens if the DLL I'm looking for isn't loaded yet?
find_module returns NULL. You can't walk the export
directory of a DLL that hasn't been loaded. The solution depends on context:
if you have kernel32.dll's base and can resolve
LoadLibraryA, call it to load the DLL before searching for it.
If you're in a restricted context where even LoadLibraryA
isn't available (deep shellcode before any API resolution), you need to use
LdrLoadDll from ntdll, which is always present. Part 2 (shellcode)
shows the full self-contained version of this problem.
The export walk is O(n) — is that a performance problem?
No, not in practice. kernel32.dll exports around 1,500 functions.
A linear scan comparing ASCII strings costs microseconds — immeasurable at
human scale. The real cost would be if you called this function repeatedly
in a hot loop, which you shouldn't — resolve your function pointers once at
startup and cache them in local variables. That's what every real implant does:
a short initialization phase that resolves all the APIs it needs, stores them
in a struct or global function pointers, then uses those pointers for the
rest of its lifetime.
What are forwarded exports and how do I handle them?
A forwarded export is when a DLL exports a name that actually redirects to
a function in a different DLL. For example, kernel32.dll
forwards HeapAlloc to ntdll.dll!RtlAllocateHeap
on some Windows versions. You can detect a forwarded export because the
function RVA falls within the export directory's own RVA range (not in the
code section). When detected, you'd need to parse the forwarding string
(it's in the format "dllname.FunctionName") and recursively resolve it.
For the APIs you need early in bootstrap (LoadLibraryA,
GetProcAddress, VirtualAlloc), this is not an issue —
they are not forwarded. When building a general-purpose resolver (for a full
reflective DLL loader in Part 3), you'll want to handle forwarding.