SuperAPI AI Directory

re-format-pe

Security & auditing

View repository

Updated 10-15 21:34

About this skill

PE format parsing

When to use / when not to use

  • Use: the target is a Windows PE file (.exe/.dll/.sys), and you need to understand its structure, find packer characteristics, locate the entry point/TLS callbacks, map RVA to file offset
  • Don't use: ELF (use [[re-format-elf]]), Mach-O (use [[re-format-macho]]); if you only need function logic (use the decompilation skill directly)
  • Don't use: if you only need a quick conclusion (for initial survey use [[re-triage]])

Tool preparation

Refer to [[re-analyze/platform-tips]] for the platform branch — Windows targets can be statically analyzed without a sandbox; dynamic execution is always sandboxed.

objdump(binutils)

  • Linux: apt install binutils / dnf install binutils / pacman -S binutils
  • macOS: brew install binutils(gobjdump)
  • Windows/WSL: the Linux version inside WSL
  • Verify: objdump -V
  • Note: for PE use objdump -p / objdump -x(readelf does not parse PE; it is a non-direct tool — only used for cross-referencing ELF-side knowledge)

pefile (Python, recommended main tool)

  • All platforms: pip install pefile(Linux/macOS/Windows/WSL all work)
  • Verify: python3 -c "import pefile; print(pefile.__version__)"

CFF Explorer(Windows GUI)

  • Windows: choco install cff-explorer(or download Explorer Suite from the NTCore official website)
  • Verify: launch CFF Explorer and open the sample; the Dos Header/Sections panels should display normally

Operating steps

  1. e_lfanew locates the PE header:

    objdump -p sample.exe | head -40   # 看 Magic: 0x10b (PE32) / 0x20b (PE32+) 与入口点
    
    import pefile
    pe = pefile.PE('sample.exe')
    print("e_lfanew offset:", hex(pe.DOS_HEADER.e_lfanew))
    print("PE32" if pe.OPTIONAL_HEADER.Magic == 0x10b else "PE32+")
    print("EntryPoint RVA:", hex(pe.OPTIONAL_HEADER.AddressOfEntryPoint))
    
  2. Section table and RVA/file offset mapping:

    objdump -h sample.exe        # 节名/VMA/文件偏移/大小
    
    import pefile
    pe = pefile.PE('sample.exe')
    for s in pe.sections:
        print(s.Name, "VA:", hex(s.VirtualAddress), "raw:", hex(s.PointerToRawData),
              hex(s.SizeOfRawData), "chars:", hex(s.Characteristics))
    print("RVA->offset:", hex(pe.get_offset_from_rva(0x1000)))   # RVA 转文件偏移
    

    Remember: the VirtualAddress of a section in memory is an RVA; you must add ImageBase to get the VA; the file offset is given by PointerToRawData.

  3. Import table (abnormal IAT size = packer characteristic):

    objdump -p sample.exe | grep -A3 -i 'DLL Name'
    
    import pefile
    pe = pefile.PE('sample.exe')
    for entry in pe.DIRECTORY_ENTRY_IMPORT:
        names = [i.name for i in entry.imports if i.name]
        print(entry.dll, "imports:", len(names), names[:5])
    
    • Normal programs import many functions; only 1-2 DLLs with very few functions (e.g., only a few from kernel32) → strongly suggests packing/dynamic resolution
  4. TLS callback check (executes earlier than the entry point):

    import pefile
    pe = pefile.PE('sample.exe')
    if hasattr(pe, 'DIRECTORY_ENTRY_TLS') and pe.DIRECTORY_ENTRY_TLS:
        # AddressOfCallBacks 按 VA 存放:VA - ImageBase = RVA(只减一次)
        cb_rva = pe.DIRECTORY_ENTRY_TLS.struct.AddressOfCallBacks - pe.OPTIONAL_HEADER.ImageBase
        ptr_size = 8 if pe.OPTIONAL_HEADER.Magic == 0x20b else 4  # PE32+(0x20b)/PE32(0x10b) 才是位宽判据;Machine==0x8664 只说明 x64,ARM64(0xAA64) 同样是 PE32+
        while True:                                                # 回调数组以 0 结尾
            ptr = int.from_bytes(pe.get_data(cb_rva, ptr_size), 'little')
            if ptr == 0:
                break
            print('TLS callback VA:', hex(ptr))
            cb_rva += ptr_size
    

    Simple method: objdump -p sample.exe check the output for the presence of the TLS directory table; if present, use pefile to print the list of callback addresses and disassemble each one. Callbacks execute before the entry point (AddressOfEntryPoint) — malicious samples often place decryption/anti-debugging code here.

  5. Rich Header identifies the compiler:

    import pefile
    pe = pefile.PE('sample.exe')
    rh = pe.parse_rich_header()
    if rh:
        vals = rh['values']          # 扁平整数数组 [compid, count, compid, count, ...](pefile 已按 key 解密)
        for cid, count in zip(vals[0::2], vals[1::2]):
            # compid 高 16 位是工具 Product ID、低 16 位是 build(如 0x10b=VS2015 系)
            print(f"compid={cid:#x} tool={cid>>16:#x} build={cid & 0xffff} count={count}")
    

    The Rich Header is located after the DOS stub and before e_lfanew, identifying the compiler toolchain version; its removal/forgery is itself a characteristic of packing or manual modification.

  6. Certificate table: the VirtualAddress of SECURITY_DIRECTORY (directory entry 4) is afile offsetrather than an RVA — parsing it directly as an RVA will read the wrong location:

    import pefile
    pe = pefile.PE('sample.exe')
    sec = pe.OPTIONAL_HEADER.DATA_DIRECTORY[4]
    print("cert table file offset:", hex(sec.VirtualAddress), "size:", hex(sec.Size))
    

Legacy runtime identification (Delphi / VB6)

There are two types of legacy runtimes in legacy PE samples — Delphi (VCL) and VB6 — with distinctive structural characteristics; you don't have to wait for decompilation, they can be determined at the format parsing stage (section table/resources/imports).

Delphi (VCL) characteristics and localization

  • Class hierarchy strings: VCL class names are stored in plaintext as short strings (1-byte length prefix),TPersistent/TApplication/TComponent the appearance of a systematic set of class names is a strong signal; first scan strings to locate class names, then reverse-search for references
  • vmt (virtual method table) localization: a class vmt is an array of pointers in the code section, with vmtSelfPtr self-referencing at the start; metadata hangs in the negative offset region of the vmt.Classic layout (Delphi 7 and earlier) Win32: TypeInfo (RTTI pointer) at vmt-0x3C, published method table at vmt-0x34, class name string pointer at vmt-0x2C, instance size at vmt-0x28 (corroborating evidence: vmtSelfPtr -0x4C, vmtParent -0x24);Win64 follows the official values, without doubling them yourself: vmtTypeInfo -0xA8, vmtMethodTable -0x98, vmtClassName -0x88, vmtInstanceSize -0x80. Since Delphi 2009, TObject gained new virtual methods, and all negative offsets shifted forward as a whole; modern layouts are no longer interchangeable with the classic values (XE2 only introduced Win64 and did not change the negative offsets). Don't memorize offsets by rote; use bidirectional verification of "vmtSelfPtr self-reference + class name pointer → class name content"
  • Recovering class structure from RTTI: Starting from the TypeInfo pointer at the vmt negative offset, the RTTI records contain the type name and the published property/method name table, allowing systematic recovery of the class hierarchy and object layout, serving as the skeleton for subsequent decompilation
  • Borland resource section characteristics: RT_RCDATA inside .rsrc contains named resources PACKAGEINFO(package/unit list),DVCLAL(version check marker); cross-confirm together with "no Rich Header" (the Borland toolchain does not generate one)
    import pefile
    pe = pefile.PE('sample.exe')
    if hasattr(pe, 'DIRECTORY_ENTRY_RESOURCE'):
        for entry in pe.DIRECTORY_ENTRY_RESOURCE.entries:
            name = entry.name.string if entry.name else None
            print(name, hex(entry.struct.Type))
    

VB6 characteristics and entry point location

  • Runtime imports: The import table contains MSVBVM60.DLL(the VB6 runtime; for VB5 it is MSVBVM50.DLL), and named imports are mostly prefixed with __vba*/rtc* (__vbaStrCmp/__vbaStrCopy/rtcMsgBox , etc.)——once the runtime is locked down, the entire MSVBVM60 API call surface becomes the analysis anchor
  • Specific resources: RT_RCDATA named resource inside .rsrc CUSTOM(project name/startup information); the version information section often carries the VB runtime identifier
  • Entry point location approach: The entry point first goes to MSVBVM60!ThunRTMain to perform runtime initialization, and the project's main logic is in the call chain after it; using references to __vba* string processing APIs as anchors to trace back to the business code is less laborious than blindly following the entry point

Notes on old packers

  • PECompact-like light packer characteristics: the section table is merged (often only 1-2 sections remain), the IAT is minimized (APIs are resolved dynamically at runtime), and the EP is a decoding loop/jump stub
  • The packer rewrites the section table and the RVA→file offset mapping, so parsing vmt/RTTI directly by the original offsets will be distorted——first confirm the packer type with [[re-packer-id]], and for light packers go through the simple unpacking process ([[re-unpack-simple]]) before locating the runtime

Identification-first principle

  • At the format parsing stage (section table/resources/imports), draw runtime conclusions to guide subsequent routing:
    • Determined to be Delphi → follow the RTTI class structure recovery route (vmt/TypeInfo location)
    • Determined to be VB6 → follow the MSVBVM60 API call surface route (__vba*/rtc* semantic recovery)
    • Neither of the two → handle as a regular C/C++ PE
  • When you can't identify it, go back and check for a packer (old samples have a high rate of light packers); don't force a solution under the assumption that there is no packer

Common pitfalls

  • Reading Delphi strings as C-style: Symptom——"garbled text" appears in the string area, and decompiled strings are truncated and misaligned; Cause——Delphi strings are AnsiString: the 4 bytes before the data pointer are the length, and the 4 bytes before that are the reference count (ShortString has a 1-byte length prefix), none of which are NUL-terminated C strings; Countermeasure——retrieve strings by length prefix (length first, no terminator), and scan strings using a "length + content" pattern rather than NUL truncation
  • VB6 OLE automation call sites mistaken for ordinary APIs: Symptom——decompilation shows a large block of __vba* Wrapped vtable/IDispatch dispatch sequence; object arguments are misjudged as ordinary register-passed arguments; cause—VB6 component calls are dispatched via OLE automation (IDispatch), and the call site resolves by DISPID at runtime, so statically all one sees is "get interface pointer → call dispatch function"; countermeasure—reconstruct the call surface by the semantics of "object + property/method name + DISPID", and do not analyze the dispatch sequence as independent functions one by one
  • Old packers cause RTTI offset distortion: symptom—taking the class name/RTTI pointer by negative vmt offset yields garbage values or points outside the section; cause—compression packers like PECompact merge sections and rewrite mappings, and the original offsets become invalid in the packed state; countermeasure—first use [[re-packer-id]] to check the packer, and after unpacking a light packer redo the vmt/RTTI locating
  • VB6 compilation mode is not distinguished (p-code vs native): symptom—p-code mode also has a "code section", and reading it by conventional disassembly yields variable-length garbage instructions; cause—VB6 has two kinds of output: native compiles directly to x86 (mixed __vba* calls), while p-code compiles the logic into a bytecode stream handed to the runtime for interpretation, and the code section is actually the interpreter + bytecode data; countermeasure—first determine the compilation mode (readability of code section disassembly, density of__vba* calls); p-code goes through bytecode interpretation, native goes through conventional decompilation, and do not apply the same workflow to both

Cross-domain combinations

  • [[re-binary-core]]: workflow step 3, format parsing of PE targets
  • [[re-malware]]: PE malware sample analysis (TLS callbacks/certificate table are common hiding places)
  • [[re-cracking]]: locating validation code in PE patching/keygen-type tasks
  • Connects with [[re-imports]] (look at imports after parsing the structure); when packer features are found, switch to [[re-anti-analysis]]

Common pitfalls and traps

  • TLS callbacks are a common hiding spot for malware: breakpoints at the entry point will miss them—first check the TLS directory and list all callback addresses

  • Abnormal section names(UPX0/.aspack/.nsp0, etc.): compression section features of packers; do not decompile them as ordinary code sections

  • Extremely small import table → packed: importing only a few kernel32 functions or an empty import table means the IAT has been taken over by the packer, and the real imports cannot be obtained statically

  • Certificate table offset calculation: the SECURITY_DIRECTORY pointer is a file offset; parsing it as an RVA will read the wrong place

  • The TLS directory itself can be overwritten/hijacked: symptom—the sample appears unpacked, but unexpected execution occurs in an injected child process (svchost, etc.), while a debugger that only breaks at the OEP is "normal" throughout; cause—malicious samples (such as Ursnif variants) rewrite the TLS directory of the injected process so that execution lands in a forged callback rather than AddressOfEntryPoint, bypassing generic unpackers and debuggers that only break at the entry point; countermeasure—set dynamic breakpoints at ntdll!LdrpCallInitRoutine(the callback pointer is passed via a parameter) to observe all callbacks, and statically verify whether the section containing the TLS directory/callback array is writable—writable means it is a hijack surface

  • Do not apply ELF thinking to PE: symptom—when analyzing PE, looking for .interp/PT_INTERP, expecting the __libc_start_main startup path, and understanding loading by RTLD_NOW semantics; cause—the dynamic linking mechanisms of the two formats are fundamentally different (PE has no .interp, and startup does not go through __libc_start_main); countermeasure—the PE startup chain recognizes only: system loader → TLS callbacks → entry point (AddressOfEntryPoint); imports are seen via the IAT, with no GOT/PLT concept

  • Data embedded in the PE code section → linear disassembly misjudgment: Symptom — decompilation produces large amounts of pseudocode or treats jump tables as functions; Cause — PE compilers often inline data such as jump tables into the code section (ELF places them in .rodata), linear disassembly has about 1% error, and function boundary misjudgment rate is 20%+ (tail calls/non-standard prologues/inlining); Countermeasure — cross-validate decompilation results, correct them using the tool's data annotations (manually mark data regions in IDA/ghidra), and do not fully trust the automatic function list

  • Do not rely on memory for data directory offsets: The PE32+ optional header's data directory array starts at oh + 112 (+96 is SizeOfHeapCommit) — measured PE32+ standard layout (24 standard + 80 Windows-specific (including the 8-byte ImageBase) + LoaderFlags/NumberOfRvaAndSizes 8 = 112); writing 96 will read the garbage value of HeapCommit

  • The 4 address fields of the TLS directory are VAs, not RVAs(StartAddressOfRawData/EndAddressOfRawData/AddressOfIndex/AddressOfCallBacks) — unlike most data directory entries, ImageBase must be subtracted first before converting to RVA

  • Use raw_size rather than vsize to determine RVA→file offset: Only raw_size bytes exist within the file; vsize may be larger (BSS-like); using vsize for the determination will cause an out-of-bounds read

  • Unreferenced import detection: The IAT slot has a stub (jmp [IAT]), but a CFG/.text scan finds no reference; it can only be treated as unused or an indirect-reference candidate — continue checking data references (push offset [IAT] and the like), indirect calls, and disassembly coverage (unrecognized/unanalyzed code regions). One cannot infer from "no xref" that the import has switched to dynamic resolution: APIs resolved purely dynamically usually do not enter the import table at all. When a dynamic-resolution conclusion is truly needed, there must be independent evidence:LoadLibrary*/GetModuleHandle* + GetProcAddress call sites,Ldr* native APIs, PEB/export table traversal, API hash tables

  • x64 TEB layout (verified against Wine winnt.h):gs:[0x30]=Self;gs:[0x58]=ThreadLocalStoragePointer(__declspec(thread) 's TLS array pointer, access sequence mov rax,gs:[58]; mov ecx,[_tls_index]; mov rax,[rax+rcx*8]);TlsSlots[64] at 0x1480(dynamic TLS);TlsExpansionSlots at 0x1780 — 0x58 is not TlsSlots

  • Write in-memory format byte order by name:VK_FORMAT_B8G8R8A8_UNORM/DXGI same-name format readback byte order = [B,G,R,A] (px[0] is B, not R) — verify byte order before pixel/texture assertions; the same trap holds in reverse for R8G8B8A8