Skip to content
Packing & Cryptersintermediate

Import Table Obfuscation

Destroying or hiding the original Import Address Table so static tools see an empty or tiny import list, while the packer reconstructs the real API pointers at runtime.

Most static triage starts with the import table: the APIs a binary pulls from kernel32.dll, ws2_32.dll or advapi32.dll reveal its intent before a single instruction is read. Import table obfuscation breaks that shortcut. The packer strips the original IMAGE_DIRECTORY_ENTRY_IMPORT, leaving the IAT empty or holding only a couple of bootstrap functions, then rebuilds every needed pointer at runtime.

Because the Windows loader never resolves the real APIs, a disassembler shows indirect calls through addresses that are zero on disk, and cross-references to named imports simply vanish.

How it works

At load time the stub resolves one or two anchor functions (typically LoadLibraryA and GetProcAddress), then walks its own obfuscated tables to populate the IAT. APIs are usually referenced by a hash of the name rather than the name itself, so no readable strings survive in the file:

c
/* Resolve an API by ROR-13 hash instead of by name — no strings on disk */
FARPROC resolve(HMODULE mod, uint32_t want_hash) {
    BYTE *base = (BYTE *)mod;
    IMAGE_DOS_HEADER *dos = (IMAGE_DOS_HEADER *)base;
    IMAGE_NT_HEADERS *nt  = (IMAGE_NT_HEADERS *)(base + dos->e_lfanew);
    DWORD rva = nt->OptionalHeader
                  .DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress;
    IMAGE_EXPORT_DIRECTORY *exp = (IMAGE_EXPORT_DIRECTORY *)(base + rva);

    DWORD *names = (DWORD *)(base + exp->AddressOfNames);
    WORD  *ords  = (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 (ror13(name) == want_hash)          /* compare hashes, not strings */
            return (FARPROC)(base + funcs[ords[i]]);
    }
    return NULL;
}

The resolved pointers are written into a private table that the unpacked code calls indirectly (call [rbx+8]), so the real IAT may stay empty even after execution begins.

Detection & analysis

Static analysis: The frontmatter is the tell — dumpbin /imports or PE viewers (CFF Explorer, PE-bear) show only LoadLibraryA/GetProcAddress, or an import directory of size zero, against a .text section full of indirect calls through uninitialised pointers. Look for the export-walking loop and a hashing routine (ROR-13, CRC32, or a custom rotate) operating on .dll export names.

Dynamic analysis: Let the stub finish resolving, then dump the process and rebuild imports. Breakpoint on GetProcAddress and log each (module, name) pair to reconstruct the API map; Scylla and pe-sieve can scan mapped memory for valid API pointers and synthesise a fresh import directory at the OEP. A debugger conditional log on GetProcAddress returns the full list the malware hid.

Detection rule hint: Flag PEs whose import directory resolves to fewer than ~5 functions yet whose .text entropy and indirect-call density are high; pair this with a YARA rule matching a ROR-13/CRC32 export-hash loop and references to AddressOfNameOrdinals in code, which together strongly indicate runtime import reconstruction rather than a genuinely tiny program.

Votes

Comments(0)