8.6 KiB
PE Parser: Techniques
The parsers subsystem is the structural foundation that every higher-level SindriKit domain sits on. The reflective loader uses it to validate and map payloads. The syscall resolution pipeline uses it to walk ntdll.dll's export table. The import resolver uses it to iterate descriptor tables and patch the IAT. Understanding how the parser works — and where it makes safety trade-offs — is necessary for using it correctly in both standard and adversarial contexts.
Canonical reference: PE Format Specification — Microsoft Learn
PE Format Overview
A Portable Executable file is laid out as a sequence of structured headers followed by a set of variable-length sections:
[ IMAGE_DOS_HEADER ] "MZ" — compatibility stub and e_lfanew offset
[ MS-DOS Stub ] legacy stub program
[ IMAGE_NT_HEADERS ] "PE\0\0" signature + FILE_HEADER + OPTIONAL_HEADER
[ IMAGE_SECTION_HEADER[] ] one per section: name, VirtualAddress, PointerToRawData, sizes
[ Section Data ] .text, .data, .rdata, .reloc, .tls, ...
The Optional Header (despite its name, mandatory for executable images) contains the DataDirectory array — 16 entries pointing to structures like the Import Table, Export Table, Base Relocation Table, and TLS Table. All directory and section references within a PE are expressed as Relative Virtual Addresses (RVAs): offsets from the image base, not file offsets.
The snd_pe_parser_t Context
snd_pe_parse populates a snd_pe_parser_t structure that caches all critical pointers and flags needed for subsequent directory operations. The key fields:
| Field | Type | Description |
|---|---|---|
source |
snd_buffer_t |
Copy of the buffer descriptor (base pointer + size) |
dos |
PIMAGE_DOS_HEADER |
Direct pointer into the source buffer |
nt.nt32 / nt.nt64 |
union | NT headers pointer — union covers both PE32 and PE32+ |
section_head |
PIMAGE_SECTION_HEADER |
Pointer to the first section header |
is_64bit |
BOOL |
TRUE for PE32+ (64-bit), FALSE for PE32 (32-bit) |
is_dll |
BOOL |
TRUE if IMAGE_FILE_DLL characteristic is set |
is_mapped |
BOOL |
TRUE if the buffer is a loaded image, FALSE if it is a raw file |
sections_count |
DWORD |
Number of section headers |
imports_rva |
DWORD |
Cached RVA of the import directory |
import_size |
DWORD |
Cached size of the import directory |
The SND_PE_GET_NT_FIELD(parser, field) macro abstracts the 32/64-bit union, allowing callers to read header fields without branching on is_64bit at every call site.
The is_mapped Flag: File vs. Memory Layout
This flag is the most operationally significant choice made at parse time, and getting it wrong produces silent, hard-to-diagnose failures.
Raw file layout (disk): Sections live at PointerToRawData within the file. A section whose VirtualAddress is 0x1000 might have a PointerToRawData of 0x400. A tool reading a raw PE from disk must convert RVAs using the section table — finding which section the RVA falls into and computing the file offset as PointerToRawData + (rva - VirtualAddress).
Mapped image layout (memory): After a PE is mapped into virtual memory, sections live at their VirtualAddress from the image base. The OS (or the reflective loader) has already performed the section layout. An RVA translates directly to base + rva.
snd_pe_rva_to_ptr checks parser->is_mapped and branches accordingly. The same parser code path is used for:
- The reflective loader's input payload — a raw file read from disk →
is_mapped = FALSE - A KnownDlls-mapped
ntdll.dllused for SSN resolution →is_mapped = TRUE
Passing the wrong flag produces either out-of-bounds reads (crashing cleanly if bounds checks fire) or silent reads from wrong offsets producing garbage data.
Bounds Checking and Parser Safety
SindriKit's parser is designed to fail safely against malformed or weaponized PE inputs. Every data directory and section access is routed through snd_pe_rva_to_ptr, which validates that the requested (rva, size) region lies entirely within the tracked buffer bounds before returning a pointer. On any violation, it returns NULL, which callers must check.
The 4KB Bootstrap Window (SND_SYS_DLL_SIZE_DEFAULT)
Parsing in-memory system DLLs (e.g., a KnownDlls-mapped ntdll.dll) presents a bootstrapping problem: the total image size is stored inside the OptionalHeader.SizeOfImage field, but to read that field, the headers must first be parsed, which requires a known buffer bound.
SND_SYS_DLL_SIZE_DEFAULT is defined as 0x1000 (4096 bytes — one virtual page). It acts as a conservative initial bound: sufficient to contain the DOS header, NT headers, and section table for every known system DLL, but small enough to limit exposure if the input is malformed.
Important
After
snd_pe_parsesucceeds on an in-memory image, the operator must update the parser'ssource.sizefield toOptionalHeader.SizeOfImagebefore calling any directory resolution function. Leaving it clamped at0x1000will cause all subsequentsnd_pe_rva_to_ptrcalls for data directories (imports, exports, relocations) to be rejected as out-of-bounds.
Export Table Parsing
SindriKit resolves exports from a PE by walking the IMAGE_EXPORT_DIRECTORY. The directory contains three parallel arrays:
AddressOfNames— RVAs to null-terminated function name stringsAddressOfNameOrdinals— 16-bit ordinal indices into the functions arrayAddressOfFunctions— RVAs to the actual function entry points
For name-based resolution, SindriKit iterates the names array (or uses binary search when the names are sorted, as is standard) to find the target, retrieves the corresponding ordinal, then indexes AddressOfFunctions.
For hash-based resolution (snd_pe_get_export_address_by_hash), each export name is hashed at resolution time using the configured algorithm (DJB2 by default) and compared against the target hash. This eliminates all plaintext function name strings from the binary.
Export Forwarders
Some exports are forwarders — their RVA points into the export directory itself (rather than a function body) and contains a string like NTDLL.RtlMoveMemory. SindriKit resolves forwarders recursively by splitting the string, looking up the named module via the injected resolver callback, and repeating the export search in the target module. The recursion depth is capped at SND_FWD_MAX_DEPTH (4) to prevent cycles or intentionally deep forwarder chains from looping indefinitely.
Pass NULL as the resolver argument to forcefully fail forwarder resolution with SND_STATUS_EXPORT_FORWARDER_UNSUPPORTED.
Import Table Parsing
The import table is a null-terminated array of IMAGE_IMPORT_DESCRIPTOR structures, one per dependency DLL. Each descriptor points to two parallel thunk arrays:
- Import Name Table (INT) — original references, untouched after loading
- Import Address Table (IAT) — populated with resolved addresses at load time
For each descriptor, snd_pe_resolve_imports:
- Reads the DLL name from the descriptor and calls
mod_api->load_library. - Iterates the thunk array. Each thunk is either ordinal-based (detected with
SND_PE_SNAP_BY_ORDINAL32/64macros — high bit set) or name-based (IMAGE_IMPORT_BY_NAMEentry). - Calls
mod_api->get_proc_addressto resolve the symbol. - Writes the resolved address directly into the IAT slot.
Because all module and symbol resolution is routed through the injected mod_api, the import resolution step inherits the full OpSec profile of the configured module primitive — Win32-visible or PEB-walking native.
Base Relocations
PE images embed absolute virtual addresses in their code and data sections (pointer fields, vtable entries, etc.) at compile time, assuming the image will be loaded at its preferred ImageBase. When a reflective loader allocates memory at a different address, all embedded pointers are wrong by a fixed constant: delta = actual_base - preferred_base.
The .reloc section contains a sequence of IMAGE_BASE_RELOCATION blocks. Each block covers a 4KB page of the image and contains a list of 12-bit offsets (plus a 4-bit type tag). For each entry of type IMAGE_REL_BASED_DIR64 (x64) or IMAGE_REL_BASED_HIGHLOW (x86), SindriKit adds delta to the QWORD/DWORD at virtual_base + block.VirtualAddress + entry.Offset. Each write is bounds-checked before application.
If the image happens to land at its preferred base, delta is zero and the entire pass is a no-op with no writes.