Module 6: PEB Walking & API Resolution
Resolving DLL modules and exported functions at runtime, the modern C++ way.
Why This Module?
Shellcode can't use the normal import table — there's no loader to fill it in. Instead, Stardust walks the Process Environment Block (PEB) to find loaded DLLs, then parses their export tables to locate function addresses. This is the same fundamental approach as AceLdr, but implemented with modern C++ templates and the DJB2 hash algorithm.
Module Resolution: resolve::module()
The first step is finding a DLL's base address in memory. The PEB contains a linked list of all loaded modules via PEB->Ldr->InLoadOrderModuleList. Stardust's resolve::module() function walks this list, hashing each module name with DJB2 and comparing against a compile-time hash.
C++// resolve::module() - walks PEB to find a DLL base address
// Accesses PEB->Ldr->InLoadOrderModuleList
// Iterates using the RangeHeadList macro
// Compares DJB2 hash of each module name against the target hash
// Returns the DLL base address when a match is found
auto module( u32 hash ) -> void* {
auto peb = NtCurrentPeb();
auto head = &peb->Ldr->InLoadOrderModuleList;
RangeHeadList( head, PLDR_DATA_TABLE_ENTRY, InLoadOrderLinks ) {
if ( entry->BaseDllName.Buffer ) {
if ( djb2( entry->BaseDllName ) == hash )
return entry->DllBase;
}
}
return nullptr;
}
The RangeHeadList Macro
The PEB's module list is a circular doubly-linked list of LIST_ENTRY structures. Manually iterating this requires boilerplate pointer arithmetic. Stardust defines a RangeHeadList macro in macros.h to simplify traversal:
C++// macros.h - RangeHeadList macro
// Iterates a LIST_ENTRY linked list from head->Flink back to head
// Casts each node to the containing structure type using CONTAINING_RECORD
#define RangeHeadList( head, type, field ) \
for ( auto entry = CONTAINING_RECORD( (head)->Flink, type, field ); \
&entry->field != (head); \
entry = CONTAINING_RECORD( entry->field.Flink, type, field ) )
This macro gives you a clean for-loop over every loaded module entry. The loop variable entry is automatically cast to the correct structure type (PLDR_DATA_TABLE_ENTRY), so you can directly access fields like BaseDllName and DllBase.
API Resolution: resolve::_api()
Once you have a DLL's base address, you need to find individual functions inside it. This means parsing the DLL's PE export table — the same structure the Windows loader uses when resolving imports.
C++// resolve::_api() - walks a DLL's export table to find a function
// 1. Get DOS header from base address
// 2. dos_header->e_lfanew gives offset to NT headers
// 3. NT headers contain the export directory RVA
// 4. Export directory points to three parallel arrays:
// - AddressOfFunctions (array of function RVAs)
// - AddressOfNames (array of name RVAs)
// - AddressOfNameOrdinals (array mapping name index to function index)
auto _api( void* base, u32 hash ) -> void* {
auto dos = (PIMAGE_DOS_HEADER)base;
auto nt = RVA2VA<PIMAGE_NT_HEADERS>( base, dos->e_lfanew );
auto dir = &nt->OptionalHeader.DataDirectory[ IMAGE_DIRECTORY_ENTRY_EXPORT ];
auto exp = RVA2VA<PIMAGE_EXPORT_DIRECTORY>( base, dir->VirtualAddress );
auto names = RVA2VA<u32*>( base, exp->AddressOfNames );
auto funcs = RVA2VA<u32*>( base, exp->AddressOfFunctions );
auto ordinals = RVA2VA<u16*>( base, exp->AddressOfNameOrdinals );
for ( u32 i = 0; i < exp->NumberOfNames; i++ ) {
auto name = RVA2VA<char*>( base, names[i] );
if ( djb2( name ) == hash )
return RVA2VA<void*>( base, funcs[ ordinals[i] ] );
}
return nullptr;
}
PE Export Table: Three Parallel Arrays
AddressOfNames
AddressOfNameOrdinals
AddressOfFunctions
Match a name → get its ordinal → use ordinal as index into functions array
Bulk Resolution: RESOLVE_IMPORT
Resolving APIs one at a time would be verbose and repetitive. Stardust provides the RESOLVE_IMPORT macro to resolve an entire batch of functions from a single DLL in one call. This macro works with a struct containing API declarations defined via D_API.
C++// Usage pattern:
// 1. Define API declarations in a struct using D_API macros
// 2. Call RESOLVE_IMPORT with the struct and module hash
//
// RESOLVE_IMPORT expands roughly to:
// base = resolve::module( module_hash );
// struct_count = sizeof(api_struct) / sizeof(void*);
// for ( int i = 1; i < struct_count; i++ ) {
// ptrs[i] = resolve::_api( base, hashes[i] );
// }
RESOLVE_IMPORT( ntdll, H_MODULE_NTDLL );
RESOLVE_IMPORT( kernel32, H_MODULE_KERNEL32 );
Why Does the Loop Start at Index 1?
The API struct's first element (index 0) stores the module base address itself, not a function pointer. The loop starts at index 1 to skip this base address and only resolve actual function pointers. The struct_count variable (calculated as sizeof(struct) / sizeof(void*)) tells the macro how many total slots exist, including that base address slot.
Before & After RESOLVE_IMPORT
Before (compile time)
After (runtime)
How It All Connects
- At compile time: DJB2 hashes are computed for each module name and function name using
constexprfunctions. No plaintext strings appear in the final shellcode. - At runtime (initialization):
RESOLVE_IMPORTcallsresolve::module()to walk the PEB and find the DLL base, then loops through the struct callingresolve::_api()for each function hash. - After resolution: The struct's slots are filled with live function pointers that Stardust can call directly.
Key Takeaway
This resolution subsystem (resolve::module, resolve::_api, RESOLVE_IMPORT) is Stardust's mechanism for calling Windows APIs without an import table. It is a separate system from the symbol<T> template, which handles position-independent string access (covered in the next module).
Knowledge Check
Q1: Why does the RESOLVE_IMPORT loop start at index 1 instead of index 0?
Q2: What does struct_count represent in the RESOLVE_IMPORT macro?