--- name: re-format-pe description: > PE 格式解析:DOS/NT 头、节表、导入导出、TLS 回调、Rich Header、证书表。 触发词:PE格式、解析exe、section表、Delphi、VB6、vmt、MSVBVM60、RTTI、老程序 capabilities: [pe-parser] --- # PE 格式解析 ## 何时使用 / 何时不用 - 用:目标是 Windows PE 文件(.exe/.dll/.sys),需要理解结构、找壳特征、定位入口/TLS 回调、映射 RVA 与文件偏移 - 不用:ELF(走 [[re-format-elf]])、Mach-O(走 [[re-format-macho]]);只需函数逻辑(直接反编译技能) - 不用:只需快速结论(初勘走 [[re-triage]]) ## 工具准备 参考 [[re-analyze/platform-tips]] 平台分支——Windows 目标可静态分析免沙箱,动态执行一律沙箱。 ### objdump(binutils) - Linux: `apt install binutils` / `dnf install binutils` / `pacman -S binutils` - macOS: `brew install binutils`(`gobjdump`) - Windows/WSL: WSL 内 Linux 版 - 验证: `objdump -V` - 注意: 对 PE 用 `objdump -p` / `objdump -x`(readelf 不解析 PE,属非直接工具——只用于对照 ELF 侧知识) ### pefile(Python,推荐主力) - 全平台: `pip install pefile`(Linux/macOS/Windows/WSL 均可) - 验证: `python3 -c "import pefile; print(pefile.__version__)"` ### CFF Explorer(Windows GUI) - Windows: `choco install cff-explorer`(或从 NTCore 官网下载 Explorer Suite) - 验证: 启动 CFF Explorer 打开样本,能正常显示 Dos Header/Sections 面板 ## 操作步骤 1. **e_lfanew 定位 PE 头**: ```sh objdump -p sample.exe | head -40 # 看 Magic: 0x10b (PE32) / 0x20b (PE32+) 与入口点 ``` ```python 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. **节表与 RVA/文件偏移映射**: ```sh objdump -h sample.exe # 节名/VMA/文件偏移/大小 ``` ```python 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 转文件偏移 ``` 记住: 节内存中的 VirtualAddress 是 RVA,需加 ImageBase 才是 VA;文件偏移由 PointerToRawData 给出。 3. **导入表(IAT 大小异常 = 壳特征)**: ```sh objdump -p sample.exe | grep -A3 -i 'DLL Name' ``` ```python 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]) ``` - 正常程序导入函数较多;仅 1-2 个 DLL、函数极少(如只有 kernel32 的几个)→ 强烈提示加壳/动态解析 4. **TLS 回调检查(比入口点更早执行)**: ```python 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 ``` 简单方法: `objdump -p sample.exe` 输出中查看 TLS 目录表存在与否;存在则用 pefile 打印回调地址列表,逐条看反汇编。回调在入口点(AddressOfEntryPoint)之前执行——恶意样本常在此放解密/反调试代码。 5. **Rich Header 识别编译器**: ```python 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}") ``` Rich Header 位于 DOS stub 之后、e_lfanew 之前,标识编译工具链版本;被移除/伪造本身也是加壳或手工修改特征。 6. **证书表**:SECURITY_DIRECTORY(目录项 4)的 VirtualAddress 是**文件偏移**而非 RVA——直接按 RVA 解析会读错位置: ```python 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)) ``` ## 老运行时识别(Delphi / VB6) 老 PE 样本里两类遗留运行时——Delphi(VCL)与 VB6——结构特征鲜明,不必等反编译,格式解析阶段(节表/资源/导入)即可判定。 ### Delphi(VCL)特征与定位 - **类体系字符串**:VCL 类名以短字符串(1 字节长度前缀)明文存放,`TPersistent`/`TApplication`/`TComponent` 类名成体系出现是强信号;先扫字符串定位类名,再反向找引用者 - **vmt(虚方法表)定位**:类 vmt 是代码节里的指针数组,起点处 vmtSelfPtr 自指;元数据挂在 vmt 负偏移区。**经典布局(Delphi 7 及以前)Win32**:TypeInfo(RTTI 指针)vmt-0x3C、published 方法表 vmt-0x34、类名字符串指针 vmt-0x2C、实例大小 vmt-0x28(旁证:vmtSelfPtr -0x4C、vmtParent -0x24);**Win64 按官方值**、不自行翻倍推演:vmtTypeInfo -0xA8、vmtMethodTable -0x98、vmtClassName -0x88、vmtInstanceSize -0x80。Delphi 2009 起 TObject 新增虚方法,负偏移整体前移,现代布局不再与经典值通用(XE2 只引入 Win64,未改负偏移)。偏移别死记,用「vmtSelfPtr 自指 + 类名指针 → 类名内容」双向校验 - **RTTI 还原类结构**:从 vmt 负偏移的 TypeInfo 指针出发,RTTI 记录含类型名与 published 属性/方法名表,可系统还原类体系与对象布局,作为后续反编译的骨架 - **Borland 资源段特征**:.rsrc 内 RT_RCDATA 出现命名资源 `PACKAGEINFO`(包/单元列表)、`DVCLAL`(版本校验标记);配合「无 Rich Header」(Borland 工具链不生成)交叉确认 ```python 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 特征与入口定位 - **运行时导入**:导入表含 `MSVBVM60.DLL`(VB6 运行时;VB5 为 MSVBVM50.DLL),具名导入多为 `__vba*`/`rtc*` 前缀(`__vbaStrCmp`/`__vbaStrCopy`/`rtcMsgBox` 等)——锁定运行时后,整个 MSVBVM60 API 调用面就是分析锚点 - **特定资源**:.rsrc 内 RT_RCDATA 命名资源 `CUSTOM`(工程名/启动信息);版本信息段常带 VB 运行时标识 - **入口定位思路**:入口先进 `MSVBVM60!ThunRTMain` 做运行时初始化,项目主逻辑在其后调用链中;以 `__vba*` 字符串处理 API 的引用点为锚反查业务代码,比死跟入口省力 ### 老壳注意 - PECompact 类轻壳特征:节表被合并(常剩 1-2 节)、IAT 极小化(API 运行时动态解析)、EP 处为解码循环/跳转 stub - 壳会改写节表与 RVA→文件偏移映射,直接按原偏移解析 vmt/RTTI 会失真——先 [[re-packer-id]] 确认壳型,轻壳走简单脱壳流程([[re-unpack-simple]])再定位运行时 ### 识别先行原则 - 格式解析阶段(节表/资源/导入)就下运行时结论,为后续选路: - 判定 Delphi → 走 RTTI 还原类结构路线(vmt/TypeInfo 定位) - 判定 VB6 → 走 MSVBVM60 API 调用面路线(`__vba*`/`rtc*` 语义还原) - 两者皆无 → 按常规 C/C++ PE 处理 - 识别不出时回头查壳(老样本轻壳率高),别在无壳假设下硬解 ### 常见坑 - **Delphi 字符串按 C 风格读**:现象——字符串区出现「乱码」、反编译字符串截断错位;原因——Delphi 字符串是 AnsiString:数据指针前 4 字节是长度、再前 4 字节是引用计数(ShortString 为 1 字节长度前缀),均非 NUL 结尾的 C 字符串;对策——按长度前缀取串(长度在前、无结尾符),字符串扫描按「长度+内容」模式而非 NUL 截断 - **VB6 的 OLE 自动化调用点被当普通 API**:现象——反编译出现大段 `__vba*` 包裹的 vtable/IDispatch 分发序列,对象参数被误判成普通寄存器传参;原因——VB6 组件调用经 OLE 自动化(IDispatch)分发,调用点在运行时按 DISPID 解析,静态只看到「取接口指针 → 调分发函数」;对策——按「对象 + 属性/方法名 + DISPID」语义还原调用面,别把分发序列当独立函数逐个分析 - **老壳导致 RTTI 偏移失真**:现象——按 vmt 负偏移取类名/RTTI 指针得到垃圾值或指向节外;原因——PECompact 类压缩壳合并节、重写映射,压缩态下原偏移失效;对策——先 [[re-packer-id]] 查壳,轻壳脱壳后重做 vmt/RTTI 定位 - **VB6 编译模式不分(p-code vs native)**:现象——p-code 模式同样有「代码节」,按常规反汇编读出来是变长乱指令;原因——VB6 有两种产物:native 直接编译为 x86(混 `__vba*` 调用),p-code 把逻辑编成字节码流交运行时解释执行,代码节实为解释器+字节码数据;对策——先判编译模式(代码节反汇编可读性、`__vba*` 调用密集度),p-code 走字节码解释,native 走常规反编译,别套同一条流程 ## 跨域联合 - [[re-binary-core]]:工作流第 3 步,PE 目标的格式解析 - [[re-malware]]:PE 恶意样本分析(TLS 回调/证书表是常用藏身处) - [[re-cracking]]:PE 补丁/注册机类任务定位校验代码 - 与 [[re-imports]] 衔接(解析完结构后看导入);发现壳特征时转 [[re-anti-analysis]] ## 常见坑与陷阱 - **TLS 回调是恶意软件常见藏身点**:入口点断点会漏掉——先查 TLS 目录并列出全部回调地址 - **节名异常**(UPX0/.aspack/.nsp0 等):壳的压缩节特征,别当普通代码节反编译 - **导入表极小 → 加壳**:只导入 kernel32 少量函数或导入表为空,说明 IAT 被壳接管,静态拿不到真实导入 - **证书表偏移计算**:SECURITY_DIRECTORY 的指针是文件偏移,当 RVA 解析会读错 - **TLS 目录本身可被覆写/劫持**:现象——样本看似无壳,但在注入的子进程(svchost 等)里出现非预期执行,断在 OEP 的调试器全程"正常";原因——恶意样本(如 Ursnif 变体)改写被注入进程的 TLS 目录,使执行落在伪造回调而非 AddressOfEntryPoint,通用脱壳器与只断入口点的调试器全被绕过;对策——动态断点设在 `ntdll!LdrpCallInitRoutine`(回调指针经参数传递)观察全部回调,静态核验 TLS 目录/回调数组所在节是否可写——可写即劫持面 - **别用 ELF 思维套 PE**:现象——分析 PE 时找 `.interp`/PT_INTERP、期待 `__libc_start_main` 启动路径、按 RTLD_NOW 语义理解加载;原因——两格式动态链接机制根本不同(PE 无 .interp,启动不经过 __libc_start_main);对策——PE 启动链只认:系统加载器 → TLS 回调 → 入口点(AddressOfEntryPoint);导入看 IAT,没有 GOT/PLT 概念 - **PE 代码节内嵌数据 → 线性反汇编误判**:现象——反编译出现大片伪代码或把跳转表当函数;原因——PE 编译器常把跳转表等数据内联进代码节(ELF 则放 `.rodata`),线性反汇编约 1% 误差,函数边界误判率 20%+(尾调用/非标准序言/内联);对策——反编译结果交叉验证,用工具的数据标注(IDA/ghidra 手工标记数据区)修正,别全信自动函数列表 - **数据目录偏移别凭记忆**:PE32+ optional header 的数据目录数组从 `oh + 112` 起(+96 是 SizeOfHeapCommit)——实测 PE32+ 标准布局(24 标准 + 80 Windows 特有(含 8 字节 ImageBase)+ LoaderFlags/NumberOfRvaAndSizes 8 = 112);写 96 会读到 HeapCommit 的垃圾值 - **TLS 目录 4 个地址字段是 VA 不是 RVA**(StartAddressOfRawData/EndAddressOfRawData/AddressOfIndex/AddressOfCallBacks)——与多数数据目录条目不同,换算 RVA 前必须先减 ImageBase - **RVA→文件偏移判定用 raw_size 而非 vsize**:文件内只有 raw_size 字节存在,vsize 可能更大(BSS 类);用 vsize 判定会越界读 - **未引用 import 检测**:IAT 槽存在桩(`jmp [IAT]`)但 CFG/.text 扫描未见引用,只能作为 unused 或间接引用候选——继续查数据引用(`push offset [IAT]` 一类)、间接调用与反汇编覆盖(未识别/未分析代码区)。不能由「无 xref」反推该导入改走动态解析:纯动态解析的 API 通常根本不进导入表。确需下动态解析结论时,须有独立证据:`LoadLibrary*`/`GetModuleHandle*` + `GetProcAddress` 调用点、`Ldr*` 原生 API、PEB/导出表遍历、API hash 表 - **x64 TEB 布局(Wine winnt.h 核实)**:`gs:[0x30]`=Self;`gs:[0x58]`=ThreadLocalStoragePointer(`__declspec(thread)` 的 TLS 数组指针,访问序列 `mov rax,gs:[58]; mov ecx,[_tls_index]; mov rax,[rax+rcx*8]`);`TlsSlots[64]` 在 **0x1480**(动态 TLS);`TlsExpansionSlots` 在 0x1780——0x58 不是 TlsSlots - **内存格式字节序按名书写**:`VK_FORMAT_B8G8R8A8_UNORM`/DXGI 同名格式回读字节序 = [B,G,R,A](px[0] 是 B 不是 R)——像素/纹理断言前先验证字节序,同类陷阱对 R8G8B8A8 反向成立