Every Windows executable contains an Import Address Table (IAT) that lists the DLLs and functions the program requires. This table serves as a roadmap for the Windows loader, telling it which libraries to load and which function addresses to resolve. However, this same roadmap provides security tools with immediate insight into a program's capabilities. When an analyst or automated system sees imports like VirtualAlloc, WriteProcessMemory, and CreateRemoteThread, alarm bells ring before the program even executes.
The IAT problem fundamentally comes down to this: static imports create a permanent record of API usage that survives in the binary forever, visible to anyone who examines the file. The solution lies in resolving API addresses at runtime rather than compile time, eliminating the incriminating IAT entries while preserving the ability to use those functions.
THE IAT PROBLEM VISUALIZED
==========================
STATIC IMPORTS (Visible)
========================
PE File on Disk:
┌──────────────────────────────────────────────────────────────┐
│ DOS Header │
│ PE Header │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Import Address Table (IAT) │ │
│ │ ─────────────────────────────────────────────────────│ │
│ │ kernel32.dll: │ │
│ │ ├── VirtualAlloc ◄── RED FLAG! │ │
│ │ ├── VirtualProtect ◄── RED FLAG! │ │
│ │ ├── WriteProcessMemory ◄── CRITICAL! │ │
│ │ └── CreateRemoteThread ◄── CRITICAL! │ │
│ │ ntdll.dll: │ │
│ │ └── NtAllocateVirtualMemory ◄── EXTREMELY SUS! │ │
│ └────────────────────────────────────────────────────────┘ │
│ .text (code) │
│ .data (data) │
└──────────────────────────────────────────────────────────────┘
DYNAMIC RESOLUTION (Hidden)
===========================
PE File on Disk:
┌──────────────────────────────────────────────────────────────┐
│ DOS Header │
│ PE Header │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Import Address Table (IAT) │ │
│ │ ─────────────────────────────────────────────────────│ │
│ │ kernel32.dll: │ │
│ │ └── GetSystemTime ◄── Innocent! │ │
│ │ user32.dll: │ │
│ │ └── MessageBoxA ◄── Normal! │ │
│ └────────────────────────────────────────────────────────┘ │
│ .text (code contains runtime resolution logic) │
│ .data (contains hash constants: 0x9CE0D4A, 0x10066F2F...) │
└──────────────────────────────────────────────────────────────┘
At runtime, the code walks the PEB, parses export tables,
and resolves the "suspicious" APIs dynamically using hashes.
Before we can hide imports, we must understand how they work. The Windows Portable Executable (PE) format defines a specific structure for import information that the loader processes when starting a program.
The PE header's optional header contains a data directory array, where entry index 1 points to the import directory. This directory contains an array of IMAGE_IMPORT_DESCRIPTOR structures, one for each imported DLL, terminated by a null entry.
PE IMPORT STRUCTURE ANATOMY
===========================
IMAGE_IMPORT_DESCRIPTOR (one per DLL):
┌─────────────────────────────────────────────────────────────────────┐
│ │
│ OriginalFirstThunk ──────► Import Lookup Table (ILT) │
│ TimeDateStamp ──────► Binding timestamp │
│ ForwarderChain ──────► Forwarded function index │
│ Name ──────► RVA to DLL name string ("kernel32") │
│ FirstThunk ──────► Import Address Table (IAT) │
│ │
└─────────────────────────────────────────────────────────────────────┘
Import Lookup Table (ILT) - Before Loading:
┌─────────────────────────────────────────────────────────────────────┐
│ │
│ [0] ──► IMAGE_IMPORT_BY_NAME { Hint=0x0, "VirtualAlloc" } │
│ [1] ──► IMAGE_IMPORT_BY_NAME { Hint=0x1, "VirtualProtect" } │
│ [2] ──► IMAGE_IMPORT_BY_NAME { Hint=0x2, "CreateThread" } │
│ [3] ──► NULL (terminator) │
│ │
└─────────────────────────────────────────────────────────────────────┘
Import Address Table (IAT) - After Loading:
┌─────────────────────────────────────────────────────────────────────┐
│ │
│ [0] ──► 0x00007FFE45A20000 (VirtualAlloc actual address) │
│ [1] ──► 0x00007FFE45A20180 (VirtualProtect actual address) │
│ [2] ──► 0x00007FFE45A21A00 (CreateThread actual address) │
│ [3] ──► NULL │
│ │
└─────────────────────────────────────────────────────────────────────┘
When code calls VirtualAlloc, it actually reads the address
from IAT[0] and jumps there. The loader filled in these addresses
at process startup by walking the export tables of each DLL.
The key insight is that the loader performs two operations we can replicate: finding loaded modules and looking up function addresses in their export tables. If we do this ourselves at runtime, we bypass the IAT entirely.
Windows tracks all loaded modules in a data structure accessible through the Process Environment Block (PEB). Each thread has a Thread Environment Block (TEB) containing a pointer to the PEB, and the PEB contains a pointer to the loader data structures.
The PEB's Ldr field points to a PEB_LDR_DATA structure containing three linked lists of loaded modules:
InLoadOrderModuleList: Order in which modules were loadedInMemoryOrderModuleList: Order by memory addressInInitializationOrderModuleList: Order of DllMain callsEach list connects LDR_DATA_TABLE_ENTRY structures that contain the module's base address and name.
#include <windows.h>
#include <winternl.h>
// Custom structures (the official ones are incomplete)
typedef struct _MY_PEB_LDR_DATA {
ULONG Length;
BOOLEAN Initialized;
HANDLE SsHandle;
LIST_ENTRY InLoadOrderModuleList;
LIST_ENTRY InMemoryOrderModuleList;
LIST_ENTRY InInitializationOrderModuleList;
} MY_PEB_LDR_DATA, *PMY_PEB_LDR_DATA;
typedef struct _MY_LDR_DATA_TABLE_ENTRY {
LIST_ENTRY InLoadOrderLinks;
LIST_ENTRY InMemoryOrderLinks;
LIST_ENTRY InInitializationOrderLinks;
PVOID DllBase;
PVOID EntryPoint;
ULONG SizeOfImage;
UNICODE_STRING FullDllName;
UNICODE_STRING BaseDllName;
// Additional fields exist but aren't needed here
} MY_LDR_DATA_TABLE_ENTRY, *PMY_LDR_DATA_TABLE_ENTRY;
With these structures defined, we can walk the module list to find a specific DLL by name:
HMODULE GetModuleHandleCustom(LPCWSTR wszModuleName) {
// Access PEB via TEB
// The TEB is stored in a segment register (GS on x64, FS on x86)
// The PEB pointer is at offset 0x60 (x64) or 0x30 (x86) from TEB
#ifdef _WIN64
PPEB pPeb = (PPEB)__readgsqword(0x60);
#else
PPEB pPeb = (PPEB)__readfsdword(0x30);
#endif
// Navigate to loader data
PMY_PEB_LDR_DATA pLdr = (PMY_PEB_LDR_DATA)pPeb->Ldr;
// Walk the InMemoryOrderModuleList
// This is a circular doubly-linked list
PLIST_ENTRY pHead = &pLdr->InMemoryOrderModuleList;
PLIST_ENTRY pEntry = pHead->Flink;
while (pEntry != pHead) {
// The entry is embedded in LDR_DATA_TABLE_ENTRY
// We use CONTAINING_RECORD to get the full structure
PMY_LDR_DATA_TABLE_ENTRY pModule = CONTAINING_RECORD(
pEntry,
MY_LDR_DATA_TABLE_ENTRY,
InMemoryOrderLinks
);
if (pModule->BaseDllName.Buffer) {
// Case-insensitive comparison
if (_wcsicmp(pModule->BaseDllName.Buffer, wszModuleName) == 0) {
return (HMODULE)pModule->DllBase;
}
}
pEntry = pEntry->Flink;
}
return NULL; // Module not found
}
PEB MODULE LIST TRAVERSAL
=========================
TEB (Thread Environment Block)
┌──────────────────────────────┐
│ ... │
│ ProcessEnvironmentBlock ────┼────┐
│ (offset 0x60 on x64) │ │
│ ... │ │
└──────────────────────────────┘ │
│
▼
PEB (Process Environment Block)
┌──────────────────────────────┐
│ ... │
│ Ldr ────────────────────────┼────┐
│ ... │ │
└──────────────────────────────┘ │
│
▼
PEB_LDR_DATA
┌───────────────────────────────────────────────────────────┐
│ InMemoryOrderModuleList (head) ◄──────┐ │
│ │ │ │
│ ▼ │ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ ntdll.dll │───►│ kernel32.dll │───► ... ────┘│
│ │ DllBase: 0x7FF..│ │ DllBase: 0x7FE..│ │
│ │ BaseDllName │ │ BaseDllName │ │
│ └─────────────────┘ └─────────────────┘ │
└───────────────────────────────────────────────────────────┘
By walking this list, we find module base addresses
without calling GetModuleHandle (no IAT entry needed).
Once we have a module's base address, we can find function addresses by parsing its export table. This is exactly what GetProcAddress does internally.
DLLs export functions through an IMAGE_EXPORT_DIRECTORY structure containing three parallel arrays:
AddressOfFunctions: RVAs of exported function codeAddressOfNames: RVAs of function name stringsAddressOfNameOrdinals: Indices linking names to addressesEXPORT TABLE STRUCTURE
======================
IMAGE_EXPORT_DIRECTORY:
┌─────────────────────────────────────────────────────────────────┐
│ Name ──► "kernel32.dll" │
│ Base ──► Ordinal base (usually 1) │
│ NumberOfFunctions ──► Total exported functions │
│ NumberOfNames ──► Functions exported by name │
│ AddressOfFunctions ──► RVA array of function addresses │
│ AddressOfNames ──► RVA array of name string RVAs │
│ AddressOfNameOrdinals──► Word array of ordinal indices │
└─────────────────────────────────────────────────────────────────┘
Lookup Process for "VirtualAlloc":
AddressOfNames: AddressOfNameOrdinals: AddressOfFunctions:
┌────────────────┐ ┌───────────────────┐ ┌──────────────────┐
│ [0] "CloseH.."│ │ [0] → 42 │ │ [0] → 0x1000 │
│ [1] "Create.."│ │ [1] → 103 │ │ [1] → 0x1180 │
│ [2] "Virtual."│ ◄── │ [2] → 7 ────────┼───►│ [7] → 0x5A00 │
│ ... │ Match│ ... │ │ ... │
└────────────────┘ └───────────────────┘ └──────────────────┘
1. Find "VirtualAlloc" in AddressOfNames array (index 2)
2. Read AddressOfNameOrdinals[2] = 7
3. Read AddressOfFunctions[7] = 0x5A00 (RVA)
4. Return DllBase + 0x5A00 = actual function address
FARPROC GetProcAddressCustom(HMODULE hModule, LPCSTR szFunctionName) {
PBYTE pBase = (PBYTE)hModule;
// Parse PE headers
PIMAGE_DOS_HEADER pDosHeader = (PIMAGE_DOS_HEADER)pBase;
if (pDosHeader->e_magic != IMAGE_DOS_SIGNATURE) {
return NULL; // Invalid PE
}
PIMAGE_NT_HEADERS pNtHeaders = (PIMAGE_NT_HEADERS)(pBase + pDosHeader->e_lfanew);
if (pNtHeaders->Signature != IMAGE_NT_SIGNATURE) {
return NULL; // Invalid PE
}
// Get export directory
DWORD dwExportRVA = pNtHeaders->OptionalHeader.DataDirectory[
IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress;
if (dwExportRVA == 0) {
return NULL; // No exports
}
PIMAGE_EXPORT_DIRECTORY pExportDir =
(PIMAGE_EXPORT_DIRECTORY)(pBase + dwExportRVA);
// Get the three arrays
PDWORD pdwAddressOfFunctions = (PDWORD)(pBase + pExportDir->AddressOfFunctions);
PDWORD pdwAddressOfNames = (PDWORD)(pBase + pExportDir->AddressOfNames);
PWORD pwAddressOfNameOrdinals = (PWORD)(pBase + pExportDir->AddressOfNameOrdinals);
// Linear search through names
for (DWORD i = 0; i < pExportDir->NumberOfNames; i++) {
LPCSTR szCurrentName = (LPCSTR)(pBase + pdwAddressOfNames[i]);
if (strcmp(szCurrentName, szFunctionName) == 0) {
// Found it - get the ordinal, then the function address
WORD wOrdinal = pwAddressOfNameOrdinals[i];
DWORD dwFunctionRVA = pdwAddressOfFunctions[wOrdinal];
// Check for forwarded export (address within export section)
DWORD dwExportSize = pNtHeaders->OptionalHeader.DataDirectory[
IMAGE_DIRECTORY_ENTRY_EXPORT].Size;
if (dwFunctionRVA >= dwExportRVA &&
dwFunctionRVA < dwExportRVA + dwExportSize) {
// This is a forwarded export
// The "address" is actually a string like "NTDLL.RtlAllocateHeap"
// Full implementation would recursively resolve this
return NULL;
}
return (FARPROC)(pBase + dwFunctionRVA);
}
}
return NULL; // Function not found
}
While custom GetModuleHandle and GetProcAddress eliminate IAT entries, they still require function name strings in the binary. These strings are easily detected: an analyst seeing "VirtualAlloc" or "CreateRemoteThread" in the strings output knows exactly what the program does.
API hashing solves this by replacing strings with numeric hash values. At runtime, the code hashes each export name and compares against the target hash. No function name strings appear in the binary.
DJB2 is the most commonly used API hash algorithm due to its simplicity and good distribution. Created by Daniel J. Bernstein, it processes one character at a time with a multiply-and-add operation.
// DJB2 hash implementation
DWORD HashStringDjb2(LPCSTR szString) {
DWORD dwHash = 5381; // Magic starting value
while (*szString) {
// hash = hash * 33 + char
// The multiplication by 33 is done as (hash << 5) + hash
// because bit shifting is faster than multiplication
dwHash = ((dwHash << 5) + dwHash) + *szString++;
}
return dwHash;
}
// Case-insensitive version for module names
DWORD HashStringDjb2W(LPCWSTR wszString) {
DWORD dwHash = 5381;
while (*wszString) {
WCHAR c = *wszString++;
// Convert to lowercase for case-insensitive comparison
if (c >= L'A' && c <= L'Z') {
c += 32;
}
dwHash = ((dwHash << 5) + dwHash) + c;
}
return dwHash;
}
DJB2 HASH COMPUTATION EXAMPLE
=============================
Input: "VirtualAlloc"
Initial: hash = 5381 (0x1505)
Step 1: 'V' (86)
hash = (5381 << 5) + 5381 + 86
= 172192 + 5381 + 86
= 177659 (0x2B5DB)
Step 2: 'i' (105)
hash = (177659 << 5) + 177659 + 105
= 5685088 + 177659 + 105
= 5862852 (0x596AC4)
Step 3: 'r' (114)
hash = (5862852 << 5) + 5862852 + 114
= 187611264 + 5862852 + 114
= 193474230 (0xB8765B6)
... (continues for each character) ...
Final: 0x9CE0D4A (for "VirtualAlloc")
This hash value replaces the string in the binary.
At runtime, we hash each export name and compare.
Different hash algorithms provide different trade-offs between speed, collision resistance, and detectability:
// Jenkins One-at-a-Time - Better distribution
DWORD JenkinsOneAtATime(LPCSTR szString) {
DWORD dwHash = 0;
while (*szString) {
dwHash += *szString++;
dwHash += (dwHash << 10);
dwHash ^= (dwHash >> 6);
}
// Final mixing
dwHash += (dwHash << 3);
dwHash ^= (dwHash >> 11);
dwHash += (dwHash << 15);
return dwHash;
}
// Rotate-based hash (common in shellcode)
DWORD Ror13Hash(LPCSTR szString) {
DWORD dwHash = 0;
while (*szString) {
// Rotate right by 13 bits
dwHash = (dwHash >> 13) | (dwHash << 19);
dwHash += *szString++;
}
return dwHash;
}
Combining PEB walking with API hashing creates a complete dynamic resolution system that requires no strings and no IAT entries.
// Precomputed module hashes (lowercase)
#define KERNEL32_HASH 0x6DDB9555 // "kernel32.dll"
#define NTDLL_HASH 0x1EDAB0ED // "ntdll.dll"
#define USER32_HASH 0x63C84283 // "user32.dll"
HMODULE GetModuleByHash(DWORD dwModuleHash) {
#ifdef _WIN64
PPEB pPeb = (PPEB)__readgsqword(0x60);
#else
PPEB pPeb = (PPEB)__readfsdword(0x30);
#endif
PMY_PEB_LDR_DATA pLdr = (PMY_PEB_LDR_DATA)pPeb->Ldr;
PLIST_ENTRY pHead = &pLdr->InMemoryOrderModuleList;
PLIST_ENTRY pEntry = pHead->Flink;
while (pEntry != pHead) {
PMY_LDR_DATA_TABLE_ENTRY pModule = CONTAINING_RECORD(
pEntry, MY_LDR_DATA_TABLE_ENTRY, InMemoryOrderLinks);
if (pModule->BaseDllName.Buffer) {
// Hash the current module name
DWORD dwCurrentHash = HashStringDjb2W(pModule->BaseDllName.Buffer);
if (dwCurrentHash == dwModuleHash) {
return (HMODULE)pModule->DllBase;
}
}
pEntry = pEntry->Flink;
}
return NULL;
}
// Precomputed function hashes
#define VIRTUALALLOC_HASH 0x9CE0D4A
#define VIRTUALPROTECT_HASH 0x10066F2F
#define CREATETHREAD_HASH 0x44F575C3
#define WAITFORSINGLEOBJ_HASH 0xE8AFE98
FARPROC GetFunctionByHash(HMODULE hModule, DWORD dwFunctionHash) {
PBYTE pBase = (PBYTE)hModule;
// Navigate PE structure
PIMAGE_DOS_HEADER pDos = (PIMAGE_DOS_HEADER)pBase;
PIMAGE_NT_HEADERS pNt = (PIMAGE_NT_HEADERS)(pBase + pDos->e_lfanew);
DWORD dwExportRVA = pNt->OptionalHeader.DataDirectory[
IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress;
if (!dwExportRVA) return NULL;
PIMAGE_EXPORT_DIRECTORY pExport = (PIMAGE_EXPORT_DIRECTORY)(pBase + dwExportRVA);
// Get export arrays
PDWORD pdwFunctions = (PDWORD)(pBase + pExport->AddressOfFunctions);
PDWORD pdwNames = (PDWORD)(pBase + pExport->AddressOfNames);
PWORD pwOrdinals = (PWORD)(pBase + pExport->AddressOfNameOrdinals);
// Hash each export name and compare
for (DWORD i = 0; i < pExport->NumberOfNames; i++) {
LPCSTR szName = (LPCSTR)(pBase + pdwNames[i]);
DWORD dwCurrentHash = HashStringDjb2(szName);
if (dwCurrentHash == dwFunctionHash) {
WORD wOrdinal = pwOrdinals[i];
return (FARPROC)(pBase + pdwFunctions[wOrdinal]);
}
}
return NULL;
}
The techniques above still have one weakness: the hash values appear as constants in the binary. Signature-based detection can target known hash values like 0x9CE0D4A for VirtualAlloc. Compile-time hashing with randomization addresses this.
C++11 introduced constexpr functions that the compiler evaluates at compile time when possible. This allows computing hashes during compilation, ensuring no string ever exists in the source code flow.
// Compile-time DJB2 hash using recursion
constexpr DWORD HashCompileTime(const char* szString, DWORD dwHash = 5381) {
// Base case: null terminator
return (*szString == '\0')
? dwHash
: HashCompileTime(
szString + 1,
((dwHash << 5) + dwHash) + *szString
);
}
// Convenience macro
#define HASH(str) HashCompileTime(str)
// Usage - these are computed at compile time
constexpr DWORD HASH_VirtualAlloc = HASH("VirtualAlloc");
constexpr DWORD HASH_CreateThread = HASH("CreateThread");
// The string "VirtualAlloc" never appears in the binary
// Only the constant 0x9CE0D4A is embedded
To defeat hash-based signatures, we can add a per-build seed that modifies all hash values. Each compilation produces different hashes.
// Generate seed from compile time
constexpr DWORD CompileTimeSeed() {
// Use __TIME__ macro: "HH:MM:SS"
return (__TIME__[0] - '0') * 36000 +
(__TIME__[1] - '0') * 3600 +
(__TIME__[3] - '0') * 600 +
(__TIME__[4] - '0') * 60 +
(__TIME__[6] - '0') * 10 +
(__TIME__[7] - '0');
}
// Seeded hash - XOR result with seed
constexpr DWORD HashSeeded(const char* szString, DWORD dwSeed, DWORD dwHash = 5381) {
return (*szString == '\0')
? (dwHash ^ dwSeed) // Apply seed at the end
: HashSeeded(szString + 1, dwSeed, ((dwHash << 5) + dwHash) + *szString);
}
#define HASH_RANDOM(str) HashSeeded(str, CompileTimeSeed())
// Runtime hash must use same seed
DWORD HashStringWithSeed(LPCSTR szString) {
DWORD dwHash = 5381;
DWORD dwSeed = CompileTimeSeed(); // Same seed as compile time
while (*szString) {
dwHash = ((dwHash << 5) + dwHash) + *szString++;
}
return dwHash ^ dwSeed;
}
COMPILE-TIME vs RUNTIME HASHING
===============================
Traditional Approach:
┌────────────────────────────────────────────────────────────┐
│ Source Code: GetProcAddress(h, "VirtualAlloc") │
│ ↓ │
│ Binary contains: "VirtualAlloc" string │
│ │
│ DETECTION: String search finds "VirtualAlloc" → FLAGGED │
└────────────────────────────────────────────────────────────┘
Runtime Hashing:
┌────────────────────────────────────────────────────────────┐
│ Source Code: GetFunctionByHash(h, 0x9CE0D4A) │
│ ↓ │
│ Binary contains: 0x9CE0D4A constant │
│ │
│ DETECTION: Known hash signature → FLAGGED │
└────────────────────────────────────────────────────────────┘
Compile-Time Randomized Hashing:
┌────────────────────────────────────────────────────────────┐
│ Source Code: GetFunctionByHash(h, HASH_RANDOM("VirtualA"))│
│ ↓ │
│ Binary contains: 0xA7B3F21 (varies per build) │
│ │
│ DETECTION: No known signature, no string → HARDER! │
└────────────────────────────────────────────────────────────┘
Some Windows API functions always return constant values that can be replaced with macros, avoiding even the need for dynamic resolution.
These functions always return pseudo-handles: GetCurrentProcess() returns (HANDLE)-1 and GetCurrentThread() returns (HANDLE)-2. These aren't real kernel handles but special values the kernel recognizes.
// Avoid IAT entries entirely by using constants
#define NtCurrentProcess() ((HANDLE)(LONG_PTR)-1)
#define NtCurrentThread() ((HANDLE)(LONG_PTR)-2)
void Example() {
// Instead of: HANDLE hProc = GetCurrentProcess();
HANDLE hProc = NtCurrentProcess(); // No API call!
// Instead of: HANDLE hThread = GetCurrentThread();
HANDLE hThread = NtCurrentThread(); // No API call!
// These pseudo-handles work everywhere real handles would
MEMORY_BASIC_INFORMATION mbi;
VirtualQueryEx(hProc, someAddress, &mbi, sizeof(mbi));
}
Bringing together all concepts into a practical API resolution system:
#include <windows.h>
#include <winternl.h>
// =====================================================================
// CONFIGURATION
// =====================================================================
#define DJB2_SEED 5381
// Module hashes (computed offline, case-insensitive)
#define H_KERNEL32 0x6DDB9555 // kernel32.dll
#define H_NTDLL 0x1EDAB0ED // ntdll.dll
// Function hashes (case-sensitive)
#define H_VIRTUALALLOC 0x9CE0D4A
#define H_VIRTUALPROTECT 0x10066F2F
#define H_VIRTUALFREE 0x30633AC
#define H_CREATETHREAD 0x44F575C3
#define H_WAITFORSINGLEOBJECT 0xE8AFE98
// =====================================================================
// HASH FUNCTIONS
// =====================================================================
__forceinline DWORD HashStringW(LPCWSTR wszString) {
DWORD dwHash = DJB2_SEED;
while (*wszString) {
WCHAR c = *wszString++;
if (c >= L'A' && c <= L'Z') c += 32; // Lowercase
dwHash = ((dwHash << 5) + dwHash) + c;
}
return dwHash;
}
__forceinline DWORD HashStringA(LPCSTR szString) {
DWORD dwHash = DJB2_SEED;
while (*szString) {
dwHash = ((dwHash << 5) + dwHash) + *szString++;
}
return dwHash;
}
// =====================================================================
// STRUCTURES
// =====================================================================
typedef struct _MY_PEB_LDR_DATA {
ULONG Length;
BOOLEAN Initialized;
HANDLE SsHandle;
LIST_ENTRY InLoadOrderModuleList;
LIST_ENTRY InMemoryOrderModuleList;
LIST_ENTRY InInitializationOrderModuleList;
} MY_PEB_LDR_DATA, *PMY_PEB_LDR_DATA;
typedef struct _MY_LDR_DATA_TABLE_ENTRY {
LIST_ENTRY InLoadOrderLinks;
LIST_ENTRY InMemoryOrderLinks;
LIST_ENTRY InInitializationOrderLinks;
PVOID DllBase;
PVOID EntryPoint;
ULONG SizeOfImage;
UNICODE_STRING FullDllName;
UNICODE_STRING BaseDllName;
} MY_LDR_DATA_TABLE_ENTRY, *PMY_LDR_DATA_TABLE_ENTRY;
// =====================================================================
// API RESOLUTION
// =====================================================================
HMODULE GetModuleByHash(DWORD dwHash) {
#ifdef _WIN64
PPEB pPeb = (PPEB)__readgsqword(0x60);
#else
PPEB pPeb = (PPEB)__readfsdword(0x30);
#endif
PMY_PEB_LDR_DATA pLdr = (PMY_PEB_LDR_DATA)pPeb->Ldr;
PLIST_ENTRY pHead = &pLdr->InMemoryOrderModuleList;
PLIST_ENTRY pEntry = pHead->Flink;
while (pEntry != pHead) {
PMY_LDR_DATA_TABLE_ENTRY pModule = CONTAINING_RECORD(
pEntry, MY_LDR_DATA_TABLE_ENTRY, InMemoryOrderLinks);
if (pModule->BaseDllName.Buffer &&
HashStringW(pModule->BaseDllName.Buffer) == dwHash) {
return (HMODULE)pModule->DllBase;
}
pEntry = pEntry->Flink;
}
return NULL;
}
FARPROC GetFunctionByHash(HMODULE hModule, DWORD dwHash) {
PBYTE pBase = (PBYTE)hModule;
PIMAGE_DOS_HEADER pDos = (PIMAGE_DOS_HEADER)pBase;
PIMAGE_NT_HEADERS pNt = (PIMAGE_NT_HEADERS)(pBase + pDos->e_lfanew);
DWORD dwExportRVA = pNt->OptionalHeader.DataDirectory[
IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress;
if (!dwExportRVA) return NULL;
PIMAGE_EXPORT_DIRECTORY pExport = (PIMAGE_EXPORT_DIRECTORY)(pBase + dwExportRVA);
PDWORD pdwFunctions = (PDWORD)(pBase + pExport->AddressOfFunctions);
PDWORD pdwNames = (PDWORD)(pBase + pExport->AddressOfNames);
PWORD pwOrdinals = (PWORD)(pBase + pExport->AddressOfNameOrdinals);
for (DWORD i = 0; i < pExport->NumberOfNames; i++) {
if (HashStringA((LPCSTR)(pBase + pdwNames[i])) == dwHash) {
return (FARPROC)(pBase + pdwFunctions[pwOrdinals[i]]);
}
}
return NULL;
}
// =====================================================================
// FUNCTION POINTER TYPES
// =====================================================================
typedef LPVOID (WINAPI* fnVirtualAlloc)(LPVOID, SIZE_T, DWORD, DWORD);
typedef BOOL (WINAPI* fnVirtualProtect)(LPVOID, SIZE_T, DWORD, PDWORD);
typedef BOOL (WINAPI* fnVirtualFree)(LPVOID, SIZE_T, DWORD);
typedef HANDLE (WINAPI* fnCreateThread)(
LPSECURITY_ATTRIBUTES, SIZE_T, LPTHREAD_START_ROUTINE, LPVOID, DWORD, LPDWORD);
typedef DWORD (WINAPI* fnWaitForSingleObject)(HANDLE, DWORD);
// API table for organized access
typedef struct _API_TABLE {
fnVirtualAlloc pVirtualAlloc;
fnVirtualProtect pVirtualProtect;
fnVirtualFree pVirtualFree;
fnCreateThread pCreateThread;
fnWaitForSingleObject pWaitForSingleObject;
} API_TABLE, *PAPI_TABLE;
// =====================================================================
// INITIALIZATION
// =====================================================================
BOOL InitializeAPIs(PAPI_TABLE pApis) {
HMODULE hKernel32 = GetModuleByHash(H_KERNEL32);
if (!hKernel32) return FALSE;
pApis->pVirtualAlloc = (fnVirtualAlloc)GetFunctionByHash(hKernel32, H_VIRTUALALLOC);
pApis->pVirtualProtect = (fnVirtualProtect)GetFunctionByHash(hKernel32, H_VIRTUALPROTECT);
pApis->pVirtualFree = (fnVirtualFree)GetFunctionByHash(hKernel32, H_VIRTUALFREE);
pApis->pCreateThread = (fnCreateThread)GetFunctionByHash(hKernel32, H_CREATETHREAD);
pApis->pWaitForSingleObject = (fnWaitForSingleObject)GetFunctionByHash(
hKernel32, H_WAITFORSINGLEOBJECT);
// Verify all APIs resolved
return (pApis->pVirtualAlloc && pApis->pVirtualProtect &&
pApis->pVirtualFree && pApis->pCreateThread &&
pApis->pWaitForSingleObject);
}
// =====================================================================
// USAGE EXAMPLE
// =====================================================================
int main() {
API_TABLE apis = { 0 };
if (!InitializeAPIs(&apis)) {
return 1; // Resolution failed
}
// Use dynamically resolved APIs - no IAT entries exist for these
LPVOID pMem = apis.pVirtualAlloc(
NULL,
4096,
MEM_COMMIT | MEM_RESERVE,
PAGE_READWRITE
);
if (!pMem) return 1;
// Change protection
DWORD dwOldProtect;
apis.pVirtualProtect(pMem, 4096, PAGE_EXECUTE_READ, &dwOldProtect);
// Clean up
apis.pVirtualFree(pMem, 0, MEM_RELEASE);
return 0;
}
Understanding IAT hiding helps defenders recognize when it's being used and develop appropriate countermeasures.
IAT HIDING DETECTION MATRIX
===========================
┌──────────────────────────┬─────────────────────────────┬────────────────┐
│ Indicator │ Detection Method │ Difficulty │
├──────────────────────────┼─────────────────────────────┼────────────────┤
│ Minimal IAT │ PE static analysis │ Easy │
│ (few legitimate imports) │ │ │
├──────────────────────────┼─────────────────────────────┼────────────────┤
│ PEB/TEB access patterns │ Behavioral monitoring │ Hard │
│ (segment register reads) │ (runtime hooking) │ │
├──────────────────────────┼─────────────────────────────┼────────────────┤
│ PE header parsing │ Hook NtReadVirtualMemory │ Medium │
│ │ for self-inspection │ │
├──────────────────────────┼─────────────────────────────┼────────────────┤
│ Known hash constants │ Signature-based scanning │ Easy │
│ (e.g., 0x9CE0D4A) │ │ (if not random)│
├──────────────────────────┼─────────────────────────────┼────────────────┤
│ API use without import │ Behavioral analysis: │ Medium │
│ │ API called but not in IAT │ │
└──────────────────────────┴─────────────────────────────┴────────────────┘
rule IAT_Hiding_PEB_Walk {
meta:
description = "Detects PEB walking for module enumeration"
strings:
// x64: mov rax, gs:[0x60] (PEB access)
$peb64 = { 65 48 8B 04 25 60 00 00 00 }
// x86: mov eax, fs:[0x30] (PEB access)
$peb32 = { 64 A1 30 00 00 00 }
// Ldr access offset 0x18 from PEB
$ldr_access = { 48 8B ?? 18 }
condition:
any of ($peb*) and $ldr_access
}
rule API_Hashing_DJB2 {
meta:
description = "Detects DJB2 hash algorithm"
strings:
// DJB2 initial value 5381 (0x1505)
$init1 = { C7 ?? 05 15 00 00 } // mov [mem], 0x1505
$init2 = { B? 05 15 00 00 } // mov reg, 0x1505
// Shift left 5 (the *33 operation)
$shl5 = { C1 E? 05 } // shl reg, 5
condition:
any of ($init*) and $shl5
}
rule Known_API_Hashes {
meta:
description = "Detects known API hash constants"
strings:
// Common DJB2 hashes
$h_virtualalloc = { 4A 0D CE 09 } // VirtualAlloc
$h_virtualprotect = { 2F 6F 06 10 } // VirtualProtect
$h_createthread = { C3 75 F5 44 } // CreateThread
condition:
any of them
}
IAT hiding represents a fundamental technique for evading static analysis. By understanding how Windows resolves API addresses, we can replicate that process at runtime, eliminating the permanent record that static imports create.
TECHNIQUE COMPARISON
====================
┌────────────────────────┬──────────────┬────────────┬──────────────────┐
│ Technique │ Effectiveness│ Complexity │ Detection Diff. │
├────────────────────────┼──────────────┼────────────┼──────────────────┤
│ Custom GetModuleHandle │ High │ Medium │ Hard │
│ (PEB walking) │ │ │ │
├────────────────────────┼──────────────┼────────────┼──────────────────┤
│ Custom GetProcAddress │ High │ Medium │ Hard │
│ (Export parsing) │ │ │ │
├────────────────────────┼──────────────┼────────────┼──────────────────┤
│ Runtime API hashing │ Medium │ Low │ Medium │
│ (Known signatures) │ │ │ (hash sigs) │
├────────────────────────┼──────────────┼────────────┼──────────────────┤
│ Compile-time hashing │ High │ Medium │ Hard │
│ (No strings ever) │ │ │ │
├────────────────────────┼──────────────┼────────────┼──────────────────┤
│ Randomized hashing │ Very High │ Medium │ Very Hard │
│ (Per-build variation) │ │ │ │
├────────────────────────┼──────────────┼────────────┼──────────────────┤
│ Pseudo-handle macros │ Medium │ Low │ Easy │
│ (Constant substitution)│ │ │ │
└────────────────────────┴──────────────┴────────────┴──────────────────┘
Best Practices: