首页
课程
问答
CTF
社区
招聘
峰会
发现
排行榜
知识库
工具下载
看雪20年
看雪商城
证书查询
登录
注册
首页
社区
课程
招聘
发现
问答
CTF
排行榜
知识库
工具下载
峰会
看雪商城
证书查询
社区
编程技术
发新帖
0
0
[原创]PE 与 PDB 的调试身份匹配
发表于: 2026-7-27 23:38
505
[原创]PE 与 PDB 的调试身份匹配
WangWei_CM.
2026-7-27 23:38
505
# PE 与 PDB 的调试身份匹配 文中所述内容可以使用KswordARK进行项目学习,开源地址<mark class="encrypted">dbcK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6w2f1%4N6G2M7X3c8p5c8g2k6Q4x3V1k6w2f1%4N6G2M7X3c8Q4x3V1j5`.</mark>  ## 目标与四步流程 要想从指定 PE 文件中取得可核验的 PDB 调试身份,分为四步: 1. 以只读方式取得完整且长度受限的 PE 文件字节。 2. 验证 DOS 头、PE 签名、COFF 文件头和可选头,建立文件偏移与 RVA 的对应关系。 3. 通过节表把调试目录 RVA 换成磁盘文件偏移,逐项验证 `IMAGE_DEBUG_DIRECTORY`。 4. 选择 CodeView 记录,验证 RSDS 数据边界,读取 GUID、Age 和 PDB 名称。 PE(Portable Executable,可移植可执行文件)是链接器输出的可执行文件格式。PDB(Program 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`,共享模式允许已有读者、写者和删除者继续访问,读取动作不会主动阻塞更新程序。 ```cpp // 作用:打开一个已经存在的文件并返回访问引用。 // 返回:有效 HANDLE 表示成功。INVALID_HANDLE_VALUE 表示失败,随后立刻读取 GetLastError。 HANDLE CreateFileW( LPCWSTR lpFileName, // 输入:NUL 结尾 UTF-16 文件路径。不能为 nullptr DWORD 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 所有权。 BOOL GetFileSizeEx( HANDLE hFile, // 输入:CreateFileW 成功返回且尚未关闭的文件 HANDLE PLARGE_INTEGER lpFileSize // 输出:有符号 64 位长度,单位字节。不能为 nullptr ); // 作用:从当前文件位置同步读取字节。 // 返回:非零成功。0 失败,读取 GetLastError。实际读取量写入 lpNumberOfBytesRead。 BOOL ReadFile( HANDLE hFile, // 输入:已打开的文件 HANDLE LPVOID lpBuffer, // 输出:调用方分配的可写缓冲区。不能为 nullptr DWORD nNumberOfBytesToRead, // 输入:请求读取字节数。本例先限制文件大小以保证 DWORD 有效 LPDWORD lpNumberOfBytesRead, // 输出:实际读取字节数。同步读取时不能为 nullptr LPOVERLAPPED lpOverlapped // 输入:同步顺序读取为 nullptr ); // 作用:释放普通内核对象访问引用。 // 返回:非零成功。0 失败,读取 GetLastError。调用后 hObject 失效。 BOOL CloseHandle(HANDLE hObject); ``` 正确读取流程先测量长度,再限制允许的最大大小,再分配字节数组,最后确认实际读取量等于测量长度。文件可能在两次操作间被截断或替换,短读结果不能继续交给 PE 解析器。 ```cpp bool ReadWholeFile(const std::wstring& path, std::vector<BYTE>& file) { HANDLE handle = CreateFileW(path.c_str(), GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); if (handle == INVALID_HANDLE_VALUE) return false; LARGE_INTEGER size{}; bool ok = GetFileSizeEx(handle, &size) != FALSE && size.QuadPart > 0 && size.QuadPart <= 512ll * 1024 * 1024; // 同时限制内存分配与 ReadFile 的 DWORD 长度。 if (ok) { file.resize(static_cast<size_t>(size.QuadPart)); DWORD readBytes = 0; ok = ReadFile(handle, file.data(), static_cast<DWORD>(file.size()), &readBytes, nullptr) != FALSE && readBytes == file.size(); } CloseHandle(handle); // 无论后续成功与否,当前函数都拥有并关闭该 HANDLE。 return ok; } ``` 完成第一步后,解析器拥有一段明确长度的磁盘字节。接下来需要确认这些字节确实遵循 PE 布局,并辨别调试目录使用的 RVA 与磁盘读取使用的文件偏移。 ## 2. 验证 PE 头并建立坐标关系 文件偏移是从磁盘文件第一个字节开始计算的位置。例如偏移 `0x200` 表示文件中的第 512 个字节。RVA(Relative Virtual Address,相对虚拟地址)是从模块加载基址开始计算的位置。例如模块基址为 `0x00007FFB10000000`、RVA 为 `0x3000` 时,运行时虚拟地址为 `0x00007FFB10003000`。RVA 与磁盘偏移属于两类坐标。 PE 用节把两种坐标关联起来。节是链接器组织的区域,常见节名有 `.text`、`.rdata`、`.data`。`IMAGE_SECTION_HEADER::VirtualAddress` 是节在映像内的起始 RVA,`PointerToRawData` 是节在磁盘文件内的起始偏移,`VirtualSize` 是映像中的节大小,`SizeOfRawData` 是文件中保存的节字节数。将一个 RVA 映射回磁盘时,先找到包含该 RVA 的节,再计算: ```text 节内偏移 = 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` 是调试目录的 RVA,`Size` 是调试目录的字节数。 `SizeOfImage` 是加载器按节对齐创建的整个映像虚拟大小。`SizeOfHeaders` 是磁盘文件头部区域的大小。RVA 小于 `SizeOfHeaders` 时通常可直接作为文件偏移使用,仍要验证请求长度不越过实际文件。 完成第二步后,已经知道 PE 头声明的节表位置、调试目录 RVA 和其字节长度。RVA 仍是映像坐标,读取磁盘字节前必须借助节表完成换算。 ## 3. 定位调试目录并验证每个条目 解析器需要一个统一的边界规则:对 `offset`、`length` 和 `fileSize`,仅当 `offset <= fileSize` 且 `length <= fileSize - offset` 时访问 `[offset, offset + length)`。这种减法比较避免 `offset + length` 溢出。读取结构前先验证 `sizeof(结构)`,读取数组前先验证元素数乘以元素大小的结果。 ```cpp bool RangeValid(size_t offset, size_t length, size_t total) { return offset <= total && length <= total - offset; } bool RvaToFileOffset(const std::vector<BYTE>& file, const IMAGE_SECTION_HEADER* sections, WORD sectionCount, DWORD sizeOfHeaders, DWORD rva, DWORD needBytes, size_t& result) { if (rva < sizeOfHeaders && RangeValid(rva, needBytes, file.size())) { result = rva; return true; } for (WORD index = 0; index < sectionCount; ++index) { const IMAGE_SECTION_HEADER& section = sections[index]; const DWORD rawSpan = section.SizeOfRawData; if (rva < section.VirtualAddress) continue; const DWORD delta = rva - section.VirtualAddress; if (delta > rawSpan || needBytes > rawSpan - delta) continue; const size_t raw = static_cast<size_t>(section.PointerToRawData) + delta; if (!RangeValid(raw, needBytes, file.size())) return false; result = raw; return true; } return false; } ``` 在 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 的字段顺序固定: ```text 偏移 大小 含义 0 4 字节 RSDS 签名 4 16 字节 GUID(全局唯一标识符) 20 4 字节 Age(PDB 修订计数) 24 剩余字节 PDB 名称,窄字符 NUL 结尾或到条目边界结束 ``` GUID 是 128 位身份值,链接器为 PDB 生成。Age 是 32 位无符号修订计数。PDB 名称是定位提示,可能包含旧机器路径、相对路径或单独文件名。RSDS 数据最少需要 24 字节。PDB 名称扫描只能在 `SizeOfData - 24` 的范围内寻找 NUL,未找到时把整个剩余范围作为有界文本保存。 `StringFromGUID2` 用于把二进制 GUID 输出为固定格式 UTF-16 文本。输出缓冲区至少需要 39 个 `wchar_t`,其中包括花括号、连字符和结尾 NUL。函数返回的字符数包含结尾 NUL。 ```cpp // 作用:把 GUID 转成 {xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx} 形式。 // 返回:成功时返回写入字符数且包含结尾 NUL。0 表示失败。 int StringFromGUID2( REFGUID rguid, // 输入:GUID 常量引用,不转移所有权 LPOLESTR lpsz, // 输出:UTF-16 缓冲区。不能为 nullptr int cchMax // 输入:缓冲区容量,单位 wchar_t。至少为 39 ); ``` ### PDB 身份比较和证据范围 PDB Summary 信息中的 GUID 与 Age 要与 PE RSDS 中的 GUID、Age 完全相等。相等条件是逐字段相等:GUID 的 16 个字节相等且 Age 数值相等。PE 的 `Machine`、COFF `TimeDateStamp`、`SizeOfImage`、文件哈希与 PDB 路径应一并记录,用于排查缓存命中错误、同名文件和符号服务器返回异常。 身份匹配成功说明这份 PDB 可用于解释这份 PE 的符号数据。它不描述 PE 是否已经加载到某个进程,也不描述某个运行时地址是否属于当前模块。运行时地址还需要模块基址、RVA 与进程映像列表共同验证。 ### 常见错误 文件名相同不能建立身份关系: ```cpp bool usable = (pdbName == "ntdll.pdb"); // 错误:名称可在不同版本、不同目录和不同构建中重复。 ``` RVA 也不能直接用于访问磁盘数组: ```cpp const IMAGE_DEBUG_DIRECTORY* item = reinterpret_cast<const IMAGE_DEBUG_DIRECTORY*>(file.data() + debug.VirtualAddress); // 错误:VirtualAddress 属于映像坐标。节对齐会使它与磁盘文件偏移不同。 ``` 正确流程先验证 PE 头与节表,再把调试目录 RVA 映射为原始偏移,随后验证每个 `IMAGE_DEBUG_DIRECTORY` 的数据范围,最后比较 GUID 与 Age。每一步失败都保留为独立状态,避免把损坏文件、没有调试目录、非 RSDS 记录和身份不匹配写成同一种结果。 完整可运行程序在附件  <mark class="encrypted">837K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6%4j5h3&6Y4N6$3g2A6j5$3#2Q4x3X3g2D9j5h3&6*7L8%4g2#2i4K6u0W2j5$3!0E0i4K6u0r3K9i4u0k6c8g2V1K6P5U0u0J5x3e0k6X3</mark>
冰与火的战歌:Windows内核攻防实战高级班!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。
上传的附件:
026-PE与PDB的调试身份匹配.zip
(165.02kb,1次下载)
收藏
・
0
点赞
・
0
打赏
分享
分享到微信
分享到QQ
分享到微博
赞赏记录
参与人
雪币
留言
时间
查看更多
赞赏
×
1 雪花
5 雪花
10 雪花
20 雪花
50 雪花
80 雪花
100 雪花
150 雪花
200 雪花
支付方式:
微信支付
赞赏留言:
快捷留言
感谢分享~
精品文章~
原创内容~
精彩转帖~
助人为乐~
感谢分享~
最新回复
(
0
)
游客
登录
|
注册
方可回帖
回帖
表情
雪币赚取及消费
高级回复
返回
WangWei_CM.
30
发帖
1
回帖
40
RANK
关注
私信
他的文章
[原创] 进程模块枚举、映像身份与线程启动地址关联
215
[原创]Windows 进程 CPU、内存、I/O 与调度指标采样
981
[原创]跨位数进程的映像、命令行与环境块读取
535
[原创]进程实例身份、父子关系与会话归属
293
[原创]Windows 进程枚举NtQuerySystemInformation(SystemProcessInformation
635
关于我们
联系我们
企业服务
看雪公众号
专注于PC、移动、智能设备安全研究及逆向工程的开发者社区
谁下载
×
Fzzz
看原图
赞赏
×
雪币:
+
留言:
快捷留言
为你点赞!
返回
顶部