首页
社区
课程
招聘
[原创]PE 与 PDB 的调试身份匹配
发表于: 6天前 364

[原创]PE 与 PDB 的调试身份匹配

6天前
364

PE 与 PDB 的调试身份匹配

文中所述内容可以使用KswordARK进行项目学习,开源地址041K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6w2f1%4N6G2M7X3c8p5c8g2k6Q4x3V1k6w2f1%4N6G2M7X3c8Q4x3V1j5`.
图片描述

目标与四步流程

要想从指定 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,共享模式允许已有读者、写者和删除者继续访问,读取动作不会主动阻塞更新程序。

// 作用:打开一个已经存在的文件并返回访问引用。
// 返回:有效 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 解析器。

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.dataIMAGE_SECTION_HEADER::VirtualAddress 是节在映像内的起始 RVA,PointerToRawData 是节在磁盘文件内的起始偏移,VirtualSize 是映像中的节大小,SizeOfRawData 是文件中保存的节字节数。将一个 RVA 映射回磁盘时,先找到包含该 RVA 的节,再计算:

节内偏移 = RVA - Section.VirtualAddress
文件偏移 = Section.PointerToRawData + 节内偏移

VirtualSize 可能大于 SizeOfRawData。这类尾部空间由加载器按零填充,磁盘上没有等量字节。读取磁盘文件时,节内偏移加上请求长度必须同时落在 SizeOfRawData 和整个文件范围内。

PE 头与调试目录入口

PE 文件开头是 IMAGE_DOS_HEADER。它由兼容 DOS 的 MZ 签名和多个旧字段组成,本篇使用 e_magice_lfanewe_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_MAGICIMAGE_NT_OPTIONAL_HDR64_MAGIC。PE32+ 常用于 64 位映像。两种布局都提供 SizeOfImageSizeOfHeadersDataDirectory 数组。本篇读取 DataDirectory[IMAGE_DIRECTORY_ENTRY_DEBUG],其中 VirtualAddress 是调试目录的 RVA,Size 是调试目录的字节数。

SizeOfImage 是加载器按节对齐创建的整个映像虚拟大小。SizeOfHeaders 是磁盘文件头部区域的大小。RVA 小于 SizeOfHeaders 时通常可直接作为文件偏移使用,仍要验证请求长度不越过实际文件。

完成第二步后,已经知道 PE 头声明的节表位置、调试目录 RVA 和其字节长度。RVA 仍是映像坐标,读取磁盘字节前必须借助节表完成换算。

3. 定位调试目录并验证每个条目

解析器需要一个统一的边界规则:对 offsetlengthfileSize,仅当 offset <= fileSizelength <= fileSize - offset 时访问 [offset, offset + length)。这种减法比较避免 offset + length 溢出。读取结构前先验证 sizeof(结构),读取数组前先验证元素数乘以元素大小的结果。

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_HEADER32IMAGE_OPTIONAL_HEADER64,再读取 DataDirectory。节表起点为 optionalHeaderOffset + SizeOfOptionalHeader。节表总长度为 NumberOfSections * sizeof(IMAGE_SECTION_HEADER),这个范围必须在文件内。调试目录 Size 必须是 sizeof(IMAGE_DEBUG_DIRECTORY) 的整数倍,映射后的范围必须完整位于文件内。

IMAGE_DEBUG_DIRECTORY 是调试条目数组。每项的 Type 说明数据种类,本篇寻找 IMAGE_DEBUG_TYPE_CODEVIEWTimeDateStamp 是该项辅助时间。SizeOfData 是数据字节数。AddressOfRawData 是映像 RVA。PointerToRawData 是磁盘文件偏移。解析磁盘文件时使用已验证的 PointerToRawDataSizeOfData,同时记录 AddressOfRawData 供映像侧比较。

完成第三步后,调试目录已落在已验证的磁盘范围内,每个条目也有独立的类型和长度。接下来只读取 Type 为 CodeView 的条目,从中提取能够标识 PDB 的 RSDS 字段。

4. 读取 RSDS 调试身份并说明匹配范围

CodeView 是 Microsoft 调试信息的一类记录格式。CodeView 数据开头的 4 字节签名决定后续布局。RSDS 签名的字节文本为 RSDS,在小端整数中可表示为 0x53445352。RSDS 的字段顺序固定:

偏移  大小             含义
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。

// 作用:把 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 TimeDateStampSizeOfImage、文件哈希与 PDB 路径应一并记录,用于排查缓存命中错误、同名文件和符号服务器返回异常。

身份匹配成功说明这份 PDB 可用于解释这份 PE 的符号数据。它不描述 PE 是否已经加载到某个进程,也不描述某个运行时地址是否属于当前模块。运行时地址还需要模块基址、RVA 与进程映像列表共同验证。

常见错误

文件名相同不能建立身份关系:

bool usable = (pdbName == "ntdll.pdb");
// 错误:名称可在不同版本、不同目录和不同构建中重复。

RVA 也不能直接用于访问磁盘数组:

const IMAGE_DEBUG_DIRECTORY* item =
    reinterpret_cast<const IMAGE_DEBUG_DIRECTORY*>(file.data() + debug.VirtualAddress);
// 错误:VirtualAddress 属于映像坐标。节对齐会使它与磁盘文件偏移不同。

正确流程先验证 PE 头与节表,再把调试目录 RVA 映射为原始偏移,随后验证每个 IMAGE_DEBUG_DIRECTORY 的数据范围,最后比较 GUID 与 Age。每一步失败都保留为独立状态,避免把损坏文件、没有调试目录、非 RSDS 记录和身份不匹配写成同一种结果。

完整可运行程序在附件
图片描述
e74K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6%4j5h3&6Y4N6$3g2A6j5$3#2Q4x3X3g2D9j5h3&6*7L8%4g2#2i4K6u0W2j5$3!0E0i4K6u0r3K9i4u0k6c8g2V1K6P5U0u0J5x3e0k6X3


传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!!

上传的附件:
收藏
免费 0
打赏
分享
最新回复 (0)
游客
登录 | 注册 方可回帖
返回