Skip to content
Obfuscationintermediate

PEB-Walk API Resolution

Malware resolves API addresses by walking the PEB loader module list and parsing each DLL's export table at runtime instead of relying on the import address table.

Rather than letting the Windows loader populate an Import Address Table (IAT) — which leaves DLL and function names visible in the binary — malware locates the modules already mapped into its own process and resolves the functions it needs by hand. This starts at the Process Environment Block (PEB), reachable without any imports through a thread segment register, and proceeds through the loader's linked list of loaded modules.

Once the base address of kernel32.dll is found, the technique parses that DLL's PE export directory to map names (or hashes) to function pointers. PEB-walking is the standard bootstrap for position-independent shellcode and reflective loaders, and it pairs naturally with API hashing so that not even the names being searched for appear in the binary.

How it works

The PEB is at a fixed offset from the thread's TEB: fs:[0x30] on x86, gs:[0x60] on x64. From there the code walks PEB.Ldr and one of its module lists, then resolves exports from the located module's PE header.

asm
; x64 — obtain kernel32.dll base via InMemoryOrderModuleList
mov   rax, gs:[60h]          ; PEB
mov   rax, [rax+18h]         ; PEB.Ldr (PPEB_LDR_DATA)
mov   rax, [rax+20h]         ; InMemoryOrderModuleList.Flink (entry: ntdll image)
mov   rax, [rax]             ; follow Flink -> next module
mov   rax, [rax]             ; follow again -> typically kernel32
mov   rbx, [rax+20h]         ; LDR_DATA_TABLE_ENTRY.DllBase (kernel32 base)

The list entries are LDR_DATA_TABLE_ENTRY structures whose BaseDllName (a UNICODE_STRING) lets the code verify it found the right module rather than relying on a fixed walk depth. With the base address in hand, the export directory is parsed exactly as for API hashing:

c
// Resolve an export from a module base obtained via the PEB walk
FARPROC resolve_export(BYTE *base, const char *target)
{
    IMAGE_DOS_HEADER *dos = (IMAGE_DOS_HEADER *)base;
    IMAGE_NT_HEADERS *nt  = (IMAGE_NT_HEADERS *)(base + dos->e_lfanew);
    IMAGE_EXPORT_DIRECTORY *exp = (IMAGE_EXPORT_DIRECTORY *)
        (base + nt->OptionalHeader.DataDirectory[0].VirtualAddress);

    DWORD *names    = (DWORD *)(base + exp->AddressOfNames);
    WORD  *ordinals = (WORD  *)(base + exp->AddressOfNameOrdinals);
    DWORD *funcs    = (DWORD *)(base + exp->AddressOfFunctions);

    for (DWORD i = 0; i < exp->NumberOfNames; i++) {
        const char *name = (const char *)(base + names[i]);
        if (strcmp(name, target) == 0)           // or compare a hash
            return (FARPROC)(base + funcs[ordinals[i]]);
    }
    return NULL;
}

In practice strcmp is replaced by a hash comparison so the target name never appears as a literal, and the walk often resolves LoadLibraryA/GetProcAddress first to bootstrap everything else.

Detection & analysis

Static analysis:

  • The fixed offsets are a giveaway: a read from fs:[0x30] / gs:[0x60] followed by dereferences at +0x18 (Ldr) and +0x10/+0x20 (module list) with no corresponding import.
  • An IAT that is empty or lists only a handful of functions, combined with a small resolver that parses IMAGE_EXPORT_DIRECTORY, indicates manual resolution.
  • CAPA rules flag "resolve function by walking PEB" and "enumerate PEB module list"; IDA/Ghidra structure-application of PEB/PEB_LDR_DATA/LDR_DATA_TABLE_ENTRY makes the walk readable.

Dynamic analysis:

  • Breakpoint on the fs/gs PEB read, then single-step the list traversal to see which module is selected and what offset depth is used.
  • Hook GetProcAddress/LoadLibrary — implementations frequently resolve these via the PEB walk first and then use them for everything else, so logging them reveals the full API set.
  • A memory dump after the resolver runs exposes a populated function-pointer table that can be matched against known export addresses.

Detection rule hint:

Flag code that reads the PEB via fs:[0x30] or gs:[0x60] and then dereferences the +0x0C/+0x18 Ldr field and a module-list Flink, in a binary whose IAT lacks kernel32!LoadLibrary*/GetProcAddress — manual module-list traversal with a missing loader-resolved IAT is rarely benign outside packers and shellcode.

Votes

Comments(0)