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
-
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)) -
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.
-
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
-
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_sizeSimple method:
objdump -p sample.execheck 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. -
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.
-
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/TComponentthe 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!ThunRTMainto 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_mainstartup 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*+GetProcAddresscall 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 sequencemov rax,gs:[58]; mov ecx,[_tls_index]; mov rax,[rax+rcx*8]);TlsSlots[64]at 0x1480(dynamic TLS);TlsExpansionSlotsat 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