PE 与 PDB 的调试身份匹配

PE 与 PDB 的调试身份匹配
PE 与 PDB 的调试身份匹配具体实现可见于KswordARK项目开源地址https://github.com/KSwordDEV/KSword/目标与四步流程要想从指定 PE 文件中取得可核验的 PDB 调试身份分为四步以只读方式取得完整且长度受限的 PE 文件字节。验证 DOS 头、PE 签名、COFF 文件头和可选头建立文件偏移与 RVA 的对应关系。通过节表把调试目录 RVA 换成磁盘文件偏移逐项验证IMAGE_DEBUG_DIRECTORY。选择 CodeView 记录验证 RSDS 数据边界读取 GUID、Age 和 PDB 名称。PEPortable Executable可移植可执行文件是链接器输出的可执行文件格式。PDBProgram Database程序数据库是编译器和链接器生成的符号数据库。PE 的调试目录保存 PDB 的身份提示PDB 自身也保存身份。GUID 与 Age 完全相同时PDB 的符号数据才可用于解释对应的 PE。目标 PE 文件 - 完整文件字节 - 已验证的 PE 头和节表 - 已定位的调试目录 - RSDS GUID、Age 与 PDB 名称这条流程涉及三个对象。PE 文件是磁盘上的字节序列。加载映像是 Windows 映像加载器把 PE 节按虚拟地址布局映射到进程后的内存对象。PDB 保存函数名、源文件位置和类型等调试信息。文件偏移、映像 RVA 和进程虚拟地址属于三种坐标后续每次读取都要区分它们。1. 以只读方式取得完整文件字节第一步要先取得稳定的输入范围。Windows 文件对象由文件系统管理。CreateFileW成功时返回一个HANDLE内核对象访问引用。当前调用方拥有这个引用并在最后调用CloseHandle。文件打开权限与共享方式会参与访问检查。身份读取只请求GENERIC_READ共享模式允许已有读者、写者和删除者继续访问读取动作不会主动阻塞更新程序。// 作用打开一个已经存在的文件并返回访问引用。// 返回有效 HANDLE 表示成功。INVALID_HANDLE_VALUE 表示失败随后立刻读取 GetLastError。HANDLECreateFileW(LPCWSTR lpFileName,// 输入NUL 结尾 UTF-16 文件路径。不能为 nullptrDWORD dwDesiredAccess,// 输入GENERIC_READ 等访问掩码须经文件 ACL 允许DWORD dwShareMode,// 输入允许其它打开者的 READ/WRITE/DELETE 共享位LPSECURITY_ATTRIBUTES lpSecurityAttributes,// 输入通常为 nullptr不自定义继承和安全属性DWORD dwCreationDisposition,// 输入OPEN_EXISTING 表示只打开已有文件DWORD dwFlagsAndAttributes,// 输入FILE_ATTRIBUTE_NORMAL 等标志HANDLE hTemplateFile// 输入普通磁盘文件为 nullptr不使用模板);// 作用获取已打开文件的逻辑长度。// 返回非零成功。0 失败读取 GetLastError。不会转移 hFile 所有权。BOOLGetFileSizeEx(HANDLE hFile,// 输入CreateFileW 成功返回且尚未关闭的文件 HANDLEPLARGE_INTEGER lpFileSize// 输出有符号 64 位长度单位字节。不能为 nullptr);// 作用从当前文件位置同步读取字节。// 返回非零成功。0 失败读取 GetLastError。实际读取量写入 lpNumberOfBytesRead。BOOLReadFile(HANDLE hFile,// 输入已打开的文件 HANDLELPVOID lpBuffer,// 输出调用方分配的可写缓冲区。不能为 nullptrDWORD nNumberOfBytesToRead,// 输入请求读取字节数。本例先限制文件大小以保证 DWORD 有效LPDWORD lpNumberOfBytesRead,// 输出实际读取字节数。同步读取时不能为 nullptrLPOVERLAPPED lpOverlapped// 输入同步顺序读取为 nullptr);// 作用释放普通内核对象访问引用。// 返回非零成功。0 失败读取 GetLastError。调用后 hObject 失效。BOOLCloseHandle(HANDLE hObject);正确读取流程先测量长度再限制允许的最大大小再分配字节数组最后确认实际读取量等于测量长度。文件可能在两次操作间被截断或替换短读结果不能继续交给 PE 解析器。boolReadWholeFile(conststd::wstringpath,std::vectorBYTEfile){HANDLE handleCreateFileW(path.c_str(),GENERIC_READ,FILE_SHARE_READ|FILE_SHARE_WRITE|FILE_SHARE_DELETE,nullptr,OPEN_EXISTING,FILE_ATTRIBUTE_NORMAL,nullptr);if(handleINVALID_HANDLE_VALUE)returnfalse;LARGE_INTEGER size{};boolokGetFileSizeEx(handle,size)!FALSEsize.QuadPart0size.QuadPart512ll*1024*1024;// 同时限制内存分配与 ReadFile 的 DWORD 长度。if(ok){file.resize(static_castsize_t(size.QuadPart));DWORD readBytes0;okReadFile(handle,file.data(),static_castDWORD(file.size()),readBytes,nullptr)!FALSEreadBytesfile.size();}CloseHandle(handle);// 无论后续成功与否当前函数都拥有并关闭该 HANDLE。returnok;}完成第一步后解析器拥有一段明确长度的磁盘字节。接下来需要确认这些字节确实遵循 PE 布局并辨别调试目录使用的 RVA 与磁盘读取使用的文件偏移。2. 验证 PE 头并建立坐标关系文件偏移是从磁盘文件第一个字节开始计算的位置。例如偏移0x200表示文件中的第 512 个字节。RVARelative Virtual Address相对虚拟地址是从模块加载基址开始计算的位置。例如模块基址为0x00007FFB10000000、RVA 为0x3000时运行时虚拟地址为0x00007FFB10003000。RVA 与磁盘偏移属于两类坐标。PE 用节把两种坐标关联起来。节是链接器组织的区域常见节名有.text、.rdata、.data。IMAGE_SECTION_HEADER::VirtualAddress是节在映像内的起始 RVAPointerToRawData是节在磁盘文件内的起始偏移VirtualSize是映像中的节大小SizeOfRawData是文件中保存的节字节数。将一个 RVA 映射回磁盘时先找到包含该 RVA 的节再计算节内偏移 RVA - Section.VirtualAddress 文件偏移 Section.PointerToRawData 节内偏移VirtualSize可能大于SizeOfRawData。这类尾部空间由加载器按零填充磁盘上没有等量字节。读取磁盘文件时节内偏移加上请求长度必须同时落在SizeOfRawData和整个文件范围内。PE 头与调试目录入口PE 文件开头是IMAGE_DOS_HEADER。它由兼容 DOS 的 MZ 签名和多个旧字段组成本篇使用e_magic与e_lfanew。e_magic必须是IMAGE_DOS_SIGNATURE字节文本MZ。e_lfanew是有符号 32 位文件偏移指向后续的 PE 签名。负值、越过文件最后或无法容纳 PE 签名与文件头的e_lfanew都表示当前文件不能继续按 PE 解析。e_lfanew后先是 4 字节IMAGE_NT_SIGNATURE数值为PE\0\0。其后是IMAGE_FILE_HEADER也常称 COFF 文件头。下面字段在身份记录中有明确职责Machine指明目标机器例如0x8664表示 AMD64。NumberOfSections指明紧随可选头之后的节表项数。TimeDateStamp是链接器写入的时间值只能作为辅助信息。SizeOfOptionalHeader是可选头在文件中的字节长度决定节表起点。可选头有 PE32 和 PE32 两种布局第一字段Magic分别为IMAGE_NT_OPTIONAL_HDR32_MAGIC与IMAGE_NT_OPTIONAL_HDR64_MAGIC。PE32 常用于 64 位映像。两种布局都提供SizeOfImage、SizeOfHeaders和DataDirectory数组。本篇读取DataDirectory[IMAGE_DIRECTORY_ENTRY_DEBUG]其中VirtualAddress是调试目录的 RVASize是调试目录的字节数。SizeOfImage是加载器按节对齐创建的整个映像虚拟大小。SizeOfHeaders是磁盘文件头部区域的大小。RVA 小于SizeOfHeaders时通常可直接作为文件偏移使用仍要验证请求长度不越过实际文件。完成第二步后已经知道 PE 头声明的节表位置、调试目录 RVA 和其字节长度。RVA 仍是映像坐标读取磁盘字节前必须借助节表完成换算。3. 定位调试目录并验证每个条目解析器需要一个统一的边界规则对offset、length和fileSize仅当offset fileSize且length fileSize - offset时访问[offset, offset length)。这种减法比较避免offset length溢出。读取结构前先验证sizeof(结构)读取数组前先验证元素数乘以元素大小的结果。boolRangeValid(size_t offset,size_t length,size_t total){returnoffsettotallengthtotal-offset;}boolRvaToFileOffset(conststd::vectorBYTEfile,constIMAGE_SECTION_HEADER*sections,WORD sectionCount,DWORD sizeOfHeaders,DWORD rva,DWORD needBytes,size_tresult){if(rvasizeOfHeadersRangeValid(rva,needBytes,file.size())){resultrva;returntrue;}for(WORD index0;indexsectionCount;index){constIMAGE_SECTION_HEADERsectionsections[index];constDWORD rawSpansection.SizeOfRawData;if(rvasection.VirtualAddress)continue;constDWORD deltarva-section.VirtualAddress;if(deltarawSpan||needBytesrawSpan-delta)continue;constsize_t rawstatic_castsize_t(section.PointerToRawData)delta;if(!RangeValid(raw,needBytes,file.size()))returnfalse;resultraw;returntrue;}returnfalse;}在 PE32 与 PE32 分支中先检查SizeOfOptionalHeader足以容纳对应的IMAGE_OPTIONAL_HEADER32或IMAGE_OPTIONAL_HEADER64再读取DataDirectory。节表起点为optionalHeaderOffset SizeOfOptionalHeader。节表总长度为NumberOfSections * sizeof(IMAGE_SECTION_HEADER)这个范围必须在文件内。调试目录Size必须是sizeof(IMAGE_DEBUG_DIRECTORY)的整数倍映射后的范围必须完整位于文件内。IMAGE_DEBUG_DIRECTORY是调试条目数组。每项的Type说明数据种类本篇寻找IMAGE_DEBUG_TYPE_CODEVIEW。TimeDateStamp是该项辅助时间。SizeOfData是数据字节数。AddressOfRawData是映像 RVA。PointerToRawData是磁盘文件偏移。解析磁盘文件时使用已验证的PointerToRawData与SizeOfData同时记录AddressOfRawData供映像侧比较。完成第三步后调试目录已落在已验证的磁盘范围内每个条目也有独立的类型和长度。接下来只读取Type为 CodeView 的条目从中提取能够标识 PDB 的 RSDS 字段。4. 读取 RSDS 调试身份并说明匹配范围CodeView 是 Microsoft 调试信息的一类记录格式。CodeView 数据开头的 4 字节签名决定后续布局。RSDS 签名的字节文本为R、S、D、S在小端整数中可表示为0x53445352。RSDS 的字段顺序固定偏移 大小 含义 0 4 字节 RSDS 签名 4 16 字节 GUID全局唯一标识符 20 4 字节 AgePDB 修订计数 24 剩余字节 PDB 名称窄字符 NUL 结尾或到条目边界结束GUID 是 128 位身份值链接器为 PDB 生成。Age 是 32 位无符号修订计数。PDB 名称是定位提示可能包含旧机器路径、相对路径或单独文件名。RSDS 数据最少需要 24 字节。PDB 名称扫描只能在SizeOfData - 24的范围内寻找 NUL未找到时把整个剩余范围作为有界文本保存。StringFromGUID2用于把二进制 GUID 输出为固定格式 UTF-16 文本。输出缓冲区至少需要 39 个wchar_t其中包括花括号、连字符和结尾 NUL。函数返回的字符数包含结尾 NUL。// 作用把 GUID 转成 {xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx} 形式。// 返回成功时返回写入字符数且包含结尾 NUL。0 表示失败。intStringFromGUID2(REFGUID rguid,// 输入GUID 常量引用不转移所有权LPOLESTR lpsz,// 输出UTF-16 缓冲区。不能为 nullptrintcchMax// 输入缓冲区容量单位 wchar_t。至少为 39);PDB 身份比较和证据范围PDB Summary 信息中的 GUID 与 Age 要与 PE RSDS 中的 GUID、Age 完全相等。相等条件是逐字段相等GUID 的 16 个字节相等且 Age 数值相等。PE 的Machine、COFFTimeDateStamp、SizeOfImage、文件哈希与 PDB 路径应一并记录用于排查缓存命中错误、同名文件和符号服务器返回异常。身份匹配成功说明这份 PDB 可用于解释这份 PE 的符号数据。它不描述 PE 是否已经加载到某个进程也不描述某个运行时地址是否属于当前模块。运行时地址还需要模块基址、RVA 与进程映像列表共同验证。常见错误文件名相同不能建立身份关系boolusable(pdbNamentdll.pdb);// 错误名称可在不同版本、不同目录和不同构建中重复。RVA 也不能直接用于访问磁盘数组constIMAGE_DEBUG_DIRECTORY*itemreinterpret_castconstIMAGE_DEBUG_DIRECTORY*(file.data()debug.VirtualAddress);// 错误VirtualAddress 属于映像坐标。节对齐会使它与磁盘文件偏移不同。正确流程先验证 PE 头与节表再把调试目录 RVA 映射为原始偏移随后验证每个IMAGE_DEBUG_DIRECTORY的数据范围最后比较 GUID 与 Age。每一步失败都保留为独立状态避免把损坏文件、没有调试目录、非 RSDS 记录和身份不匹配写成同一种结果。完整可运行程序在附件https://wangweicm.lanzouu.com/irYEY3z2r16f