Why Dynamic API Resolution?
Shellcode cannot rely on the Import Address Table (IAT) like normal executables because:- No loader: The Windows PE loader doesn’t process shellcode
- No imports section: Shellcode is raw binary, not a full PE file
- Stealth: A normal IAT would expose all APIs you intend to use
- Finding module base addresses at runtime
- Parsing PE export tables manually
- Resolving function addresses by name (or hash)
- Building your own “import table” in memory
Overview: The Resolution Process
API resolution in Stardust happens in two phases:Phase 1: Module Resolution
Find the base address of a DLL (e.g.,kernel32.dll, ntdll.dll):
Phase 2: Function Resolution
Find a specific function within a module:Walking the PEB
The Process Environment Block (PEB) is a Windows kernel structure that contains process information, including a list of loaded modules.What is the PEB?
Every Windows process has a PEB accessible through the Thread Environment Block (TEB):- Process parameters (command line, environment)
- Heap information
- Loader data (Ldr): List of loaded modules
Accessing the PEB
Frominclude/native.h, Stardust uses intrinsic accessors:
- x64: GS register offset
0x60points to PEB - x86: FS register offset
0x30points to PEB
Module Resolution Implementation
Fromsrc/resolve.cc:15-31:
- NtCurrentPeb(): Get the PEB
- Ldr: Loader data structure
- InLoadOrderModuleList: Doubly-linked list of loaded modules
- RangeHeadList: Macro to iterate the list (defined in
include/macros.h:14-24)
LDR_DATA_TABLE_ENTRY Structure
Each module in the list is represented by anLDR_DATA_TABLE_ENTRY:
DllBase: Base address where the module is loadedBaseDllName.Buffer: Wide string containing the module name
Walking Algorithm
TheRangeHeadList macro (include/macros.h:14-24) implements list traversal:
__Headpoints to the list headEntrystarts at the first element (Flink)- Loop until we circle back to
__Head - Execute
SCOPE(the code block) for each entry - Move to next entry via
Flink
Module Resolution Flow
- Get PEB:
NtCurrentPeb() - Get loader data:
PEB->Ldr - Start at list head:
InLoadOrderModuleList - For each module entry:
- Read
BaseDllName.Buffer(e.g.,L"ntdll.dll") - Hash it at runtime:
stardust::hash_string<wchar_t>(L"ntdll.dll") - Compare with target hash:
expr::hash_string<wchar_t>(L"ntdll.dll") - If match, return
Entry->DllBase
- Read
- If no match found, return
0
Special Case: First Module
0 returns the first module in the list, which is always the current executable (or in shellcode’s case, the host process).
Parsing PE Export Tables
Once you have a module base address, you need to find specific functions by parsing the PE export table.PE Structure Overview
A PE (Portable Executable) file has this structure:Function Resolution Implementation
Fromsrc/resolve.cc:47-88:
Step 1: Validate DOS Header
'MZ' (0x5A4D) identify it as a valid executable.
DOS Header fields:
e_magic: Magic number (‘MZ’)e_lfanew: Offset to NT headers
Step 2: Validate NT Headers
'PE\0\0' (0x00004550).
NT Headers contain:
- Signature:
'PE\0\0' - File Header: Architecture, number of sections, etc.
- Optional Header: Entry point, image base, data directories
Step 3: Locate Export Directory
DataDirectory array. Index [0] (IMAGE_DIRECTORY_ENTRY_EXPORT) points to the export directory.
Export Directory structure:
Step 4: Get Export Arrays
- AddressOfNames: RVAs to function name strings
- AddressOfFunctions: RVAs to function code
- AddressOfNameOrdinals: Ordinals that index into AddressOfFunctions
- Find “LoadLibraryA” in
AddressOfNames[0] - Get ordinal from
AddressOfNameOrdinals[0]→42 - Get function RVA from
AddressOfFunctions[42]→0x1A3C0 - Add module base:
module_base + 0x1A3C0= function address
Step 5: Search for Function
- Iterate through all named exports
- Get the name string:
module_base + export_names[i] - Hash the name at runtime:
stardust::hash_string(symbol_name) - Compare with target hash (from compile time)
- If match found:
- Get ordinal:
export_ordns[i] - Get function RVA:
export_addrs[ordinal] - Calculate absolute address:
module_base + RVA
- Get ordinal:
- Return the address
Complete Resolution Example
ResolvingLoadLibraryA from kernel32.dll:
RESOLVE_IMPORT Macro Mechanics
TheRESOLVE_IMPORT macro automates resolving multiple APIs at once.
Macro Definition
Frominclude/macros.h:8-12:
How It Works
Structure Layout
Consider the kernel32 structure:RESOLVE_TYPE (include/resolve.h:8) is:
Iteration Process
i = 1 to skip the handle field (offset 0) and iterate only over the API pointers.
struct_count (include/constexpr.h:8-18) calculates the number of pointer-sized fields:
- Size = 24 bytes (3 * 8 bytes on x64)
- Count = 3 fields
Array-Based Access
uintptr_t values:
Resolution and Replacement
- Read current value (the hash):
&m[i] - Call
resolve::_api(module_base, hash) - Get function address
- Write address back to
&m[i]
Usage Example
Fromsrc/main.cc:38-39:
Complete Resolution Workflow
Putting it all together, here’s the complete flow from shellcode entry to API usage:1. Shellcode Entry
2. Create Instance
3. Instance Constructor
4. Use APIs
Advanced Techniques
Forwarded Exports
Some exports are “forwarded” to other DLLs. For example,kernel32!HeapAlloc forwards to ntdll!RtlAllocateHeap.
Detection:
Ordinal-Only Exports
Some functions are exported by ordinal only (no name). To resolve these:Caching Resolutions
Resolving APIs is expensive. Cache frequently-used functions:instance class implements this pattern.
Security Considerations
Detection Vectors
PEB Walking:- EDR products monitor access to
GS:[0x60]orFS:[0x30] - Mitigation: Use syscalls or alternative methods
- Reading export tables is suspicious
- Mitigation: Obfuscate the parsing logic
- Comparing hashes in a loop is a pattern
- Mitigation: Add junk code, use different algorithms
Evasion Techniques
Indirect PEB Access:Troubleshooting
Module Not Found
Symptom:resolve::module() returns 0
Causes:
- Module not loaded: Use
LoadLibraryAfirst - Wrong hash: Verify hash matches module name
- Case mismatch: Hash function converts to uppercase
- Typo in name: Double-check spelling
Function Not Found
Symptom:resolve::_api() returns 0
Causes:
- Wrong hash: Verify hash matches function name
- Function not exported: Check with
dumpbin /exports - Forwarded export: Not handled by default implementation
- Ordinal-only export: No name to hash
