首页
社区
课程
招聘
[原创]PDB 类型、字段与全局符号的定位
发表于: 5天前 244

[原创]PDB 类型、字段与全局符号的定位

5天前
244

PDB 类型、字段与全局符号的定位

定位目标与四步流程

要想从当前 PE(Portable Executable,可移植可执行)映像的已加载符号视图中枚举可见类型,筛选公开、函数和数据符号,并计算它们在模块内的 RVA(Relative Virtual Address,相对虚拟地址),分为四步:

  1. 获得当前 PE 路径,区分 PDB 中类型、字段和符号各自保存的信息。
  2. 以当前进程为会话键初始化 DbgHelp,加载 PE 并检查实际关联的 PDB。
  3. 在同步回调中枚举可见类型,筛选公开、函数和数据符号,立即复制需要保留的字段。
  4. 验证名称长度、回调结构容量和地址下界,计算 RVA,输出可复核的数据并结束符号会话。

PDB(Program Database,程序数据库)由编译器和链接器生成,保存名称、类型和地址等调试信息。PE 文件保存可执行映像本身。DbgHelp(Debug Help,调试帮助库)由 Windows 提供,它把 PE 和 PDB 中格式不同的记录整理为统一的模块和符号接口。输入是当前进程对应的 PE 路径,符号引擎据此加载 PDB 并给出身份状态。输出是类型名称、符号名称、符号类别、大小和模块内 RVA。

当前 PE 路径 -> DbgHelp 符号会话 -> 已加载模块和 PDB -> 类型与符号回调 -> RVA 与输出结果

这条流程包含四类寿命不同的对象。PDB 文件由文件系统保存。DbgHelp 符号会话由当前进程创建并由 SymCleanup 结束。加载模块记录在会话内有效。枚举回调提供的 SYMBOL_INFOW 指针只在回调函数执行期间有效。把回调中的名称指针保存到回调外会形成悬空引用,正确做法是立即复制名称和值字段。

一、第一步:取得当前 PE 路径并认识定位对象

1.1 类型、字段和符号各自描述什么

类型是生成时数据布局的说明。一个结构体类型描述总大小、成员顺序、每个成员的类型和偏移。一个指针类型描述它指向的目标类型。一个数组类型描述元素类型与数组长度。类型索引是 PDB 内部编号,只在同一份 PDB 的类型流中有意义,不能拿到另一份 PDB 中查询。

字段偏移是成员相对所属对象起始地址的字节距离。例如结构大小为 16 字节,字段 Count 的偏移为 8,表示对象起始地址加 8 才是该字段位置。偏移不提供对象实例地址。对象实例地址来自运行时内存、全局符号或其它对象关系。优化、条件生成和系统版本变化都会改变布局,因此字段偏移必须与 PE/PDB 身份记录一起保存。

全局符号是模块范围内可定位的名称,函数符号表示一段指令,数据符号表示全局或静态存储。Publics 索引适合公开名称查询,Globals 索引包含更多全局条目。符号位置常以“段号 + 段内偏移”出现。段号由链接器分配给 PE 节,段内偏移是该节内的字节距离。

1.2 PDB 内的 TPI 与字段列表怎样相连

TPI(Type Information Stream,类型信息流)是 PDB 的类型记录集合。每条记录先给出记录长度和 leaf kind(叶子种类),leaf kind 决定后续字段格式。LF_STRUCTURELF_CLASSLF_UNION 用于聚合类型。LF_POINTER 用于指针类型。LF_ARRAY 用于数组类型。聚合类型记录通常保存类型大小、属性、字段列表类型索引和类型名。

LF_FIELDLIST 是一个连续的变长条目序列。成员条目常见为 LF_MEMBER,它包含成员属性、成员类型索引、数值叶子编码的字段偏移和成员名。基类、静态成员、方法和嵌套类型也有各自的条目种类。解析时先确认当前条目的记录长度没有超过字段列表剩余字节,再根据 leaf kind 读取已知字段。遇到未知 kind 时记录其编号和剩余长度,停止或按已知格式跳过,不能猜测其大小。

数值叶子是 PDB 用于压缩整数的一组编码。较小偏移可直接嵌入字段,较大偏移会跟随 16 位、32 位或 64 位数值。字段偏移最终以字节表达,读取后还要检查它小于或等于已知类型大小。类型为前向声明或大小缺失时,只能保留“偏移已读取、布局尚未确认”的状态。

1.3 符号位置怎样变成 RVA 与运行时地址

PE 节表由 IMAGE_SECTION_HEADER 数组组成。每个节的 VirtualAddress 是节的起始 RVA,Misc.VirtualSize 是映像中节大小,SizeOfRawData 是磁盘中节大小。符号的段号从 1 开始,对应节表的零基数组索引 segment - 1。段号为零、超出节数、段内偏移超过节虚拟范围时,都不能生成 RVA。

sectionIndex = segment - 1
section = sectionHeaders[sectionIndex]
RVA = section.VirtualAddress + sectionOffset
runtimeAddress = loadedModuleBase + RVA

RVA 是模块内部坐标,运行时地址是进程虚拟地址。ASLR(Address Space Layout Randomization,地址空间布局随机化)会让同一 PE 在不同进程或不同启动轮次获得不同加载基址。loadedModuleBase 来自本次符号会话的模块加载结果。把上一次运行的基址缓存到下一次运行会产生错误地址。

1.4 取得当前映像的完整路径

当前映像路径是加载模块时的文件输入。GetModuleFileNameW 使用 UTF-16 字符串,返回值和缓冲区容量都以 wchar_t 字符为单位。返回值接近容量时,调用方无法确认路径是否完整,需要扩大缓冲区后重试。hModulenullptr 时,函数读取当前可执行映像的路径。

// 作用:取得指定模块已加载映像的完整路径。
// 返回:成功时返回写入字符数,不包含结尾 NUL。失败时返回 0,读取 GetLastError。
DWORD GetModuleFileNameW(
    HMODULE hModule,  // 输入:模块句柄。可为 nullptr,表示当前可执行映像。不转移所有权
    LPWSTR lpFilename, // 输出:调用方提供的 UTF-16 缓冲区。不能为 nullptr
    DWORD nSize        // 输入:缓冲区容量,单位为 wchar_t 字符,包含结尾 NUL 的预留位置
);

正确读取时,应保存成功返回的字符数,再构造指定长度的 std::wstring。把缓冲区直接当作永远以 NUL 结尾的字符串会在截断时越过有效范围。路径准备完成后,PE 才有明确的加载目标,下一步可以建立与当前进程关联的 DbgHelp 会话。

完成第一步后,当前 PE 文件已经有了可传入符号引擎的路径,类型、字段和符号的职责也已分清。接下来需要创建 DbgHelp 会话并加载该 PE,符号引擎才会给出与本次会话相对应的模块基址和 PDB 状态。

二、第二步:建立 DbgHelp 会话并加载模块

2.1 先建立 DbgHelp 符号会话

DbgHelp 是 Windows 的调试帮助库。它将 PDB 的内部流转换为统一的模块和符号接口。会话以 HANDLE 作为键,常用 GetCurrentProcess 的伪句柄作为本进程键。伪句柄由系统管理,不调用 CloseHandleSymInitializeW 成功后会话资源由当前调用方以 SymCleanup 释放。DbgHelp 会话状态在进程内共享,多线程调用应由调用方串行化。

// 作用:创建或初始化一个进程关联的 DbgHelp 符号会话。
// 返回:非零成功。0 失败,立即读取 GetLastError。成功后必须调用 SymCleanup。
BOOL SymInitializeW(
    HANDLE hProcess,       // 输入:进程 HANDLE 或 GetCurrentProcess 伪句柄。仅作会话键,不转移所有权
    PCWSTR UserSearchPath, // 输入:UTF-16 符号搜索路径。可为 nullptr,使用系统默认搜索路径
    BOOL fInvadeProcess    // 输入:TRUE 自动加载该进程模块。手工指定模块时使用 FALSE
);

// 作用:设置当前进程 DbgHelp 的符号选项位。
// 返回:调用前的选项位。参数为输入位掩码,没有错误码和资源所有权转移。
DWORD SymSetOptions(DWORD SymOptions);

// 作用:销毁 hProcess 对应的符号会话、会话模块和内部缓存引用。
// 返回:非零成功。0 失败,读取 GetLastError。会话结束后模块基址不能继续传给 Sym* API。
BOOL SymCleanup(HANDLE hProcess);

常用选项 SYMOPT_UNDNAME 使 C++ 修饰名显示为可读名称,SYMOPT_DEFERRED_LOADS 允许按需加载符号,SYMOPT_FAIL_CRITICAL_ERRORS 抑制磁盘错误交互窗口。SymSetOptions 修改的是进程范围状态。调用方应保存返回的旧选项,并在完成后恢复,避免影响同进程内的其它调试功能。

2.2 正确做法:加载模块并检查符号来源

SymLoadModuleExW 将 PE 映像与可找到的 PDB 加入当前会话。ImageName 是 NUL 结尾 UTF-16 映像路径。hFile 可为 nullptr,此时 DbgHelp 自行打开 ImageNameModuleName 可为 nullptrBaseOfDll 为零时符号引擎选取会话基址。DllSize 为零时从 PE 头取得。Datanullptr 时不提供预构造加载数据。Flags 为零时使用常规加载方式。

// 作用:加载一个模块的符号信息,并尝试关联匹配 PDB。
// 返回:非零为本次会话中的模块基址。0 失败,读取 GetLastError。
DWORD64 SymLoadModuleExW(
    HANDLE hProcess,        // 输入:已经 SymInitializeW 的会话键
    HANDLE hFile,           // 输入:可为 nullptr。非空时为调用方已打开映像 HANDLE,所有权不转移
    PCWSTR ImageName,       // 输入:UTF-16 PE 路径。不能为 nullptr
    PCWSTR ModuleName,      // 输入:可为 nullptr。可选显示名称
    DWORD64 BaseOfDll,      // 输入:期望模块基址。0 由 DbgHelp 决定
    DWORD DllSize,          // 输入:模块大小字节数。0 由映像头确定
    PMODLOAD_DATA Data,     // 输入:可为 nullptr。高级加载数据
    DWORD Flags             // 输入:加载标志位。0 为默认行为
);

// 作用:读取已加载模块的符号来源与 PDB 状态。
// 返回:非零成功。0 失败,读取 GetLastError。
BOOL SymGetModuleInfoW64(
    HANDLE hProcess,             // 输入:已初始化会话键
    DWORD64 qwAddr,              // 输入:模块内任意加载地址,通常使用 SymLoadModuleExW 返回值
    PIMAGEHLP_MODULEW64 ModuleInfo // 输入/输出:调用方结构。SizeOfStruct 必须预设为 sizeof 结构
);

下面的流程包含初始化、选项保存、模块加载、模块信息检查与清理。每条失败分支都会先恢复会话状态并释放会话。代码块没有依赖未给出的帮助函数。

HANDLE process = GetCurrentProcess(); // 伪句柄由系统管理。
if (!SymInitializeW(process, nullptr, FALSE)) return;
DWORD oldOptions = SymSetOptions(SYMOPT_UNDNAME | SYMOPT_DEFERRED_LOADS | SYMOPT_FAIL_CRITICAL_ERRORS);

DWORD64 base = SymLoadModuleExW(process, nullptr, imagePath.c_str(), nullptr,
    0, 0, nullptr, 0);
if (base == 0) {
    DWORD error = GetLastError(); // 在后续调用前保存失败原因。
    SymSetOptions(oldOptions);
    SymCleanup(process);
    return;
}

IMAGEHLP_MODULEW64 info{};
info.SizeOfStruct = sizeof(info); // 输入:结构容量与版本。
if (SymGetModuleInfoW64(process, base, &info)) {
    // 记录 info.SymType、info.PdbUnmatched、info.LoadedPdbName 与 base。
}
SymSetOptions(oldOptions);
SymCleanup(process);

PdbUnmatched 表示符号引擎发现的 PDB 身份状态。它与第 26 篇的 GUID/Age 记录共同使用。模块能加载到会话并不代表其中类型记录已经可信可用。

完成第二步后,会话中已经有了模块基址、符号来源类型和 PDB 身份状态。接下来可以在这个会话中枚举条目,类型枚举和符号枚举都必须使用刚取得的模块基址,不能混用其它会话或其它进程的地址。

三、第三步:枚举类型并筛选可定位符号

SymEnumTypesW 枚举模块中符号引擎可见的类型,SymEnumSymbolsW 按名称模式枚举符号。二者使用同一种回调,回调参数 pSymInfo 指向 SYMBOL_INFOWSYMBOL_INFOW::Tag 标识条目种类,Index 是符号引擎的类型索引,TypeIndex 是该符号引用的类型索引,Size 是符号或类型大小,Address 是加载后地址,ModBase 是模块基址,Flags 保存公开、函数等属性,Name 是变长 UTF-16 名称缓冲区。

// 作用:枚举模块中可见的类型条目。
// 返回:非零成功。0 失败,读取 GetLastError。
BOOL SymEnumTypesW(
    HANDLE hProcess,                 // 输入:已初始化的会话键
    ULONG64 BaseOfDll,               // 输入:SymLoadModuleExW 返回的模块基址
    PSYM_ENUMERATESYMBOLS_CALLBACKW Callback, // 输入:非 nullptr 同步回调
    PVOID UserContext                // 输入:可为 nullptr。调用方在枚举期间保持对象有效
);

// 作用:枚举匹配名称模式的模块符号。
// 返回:非零成功。0 失败,读取 GetLastError。
BOOL SymEnumSymbolsW(
    HANDLE hProcess,                 // 输入:已初始化的会话键
    ULONG64 BaseOfDll,               // 输入:模块基址
    PCWSTR Mask,                     // 输入:UTF-16 模式。L"*" 表示全部可见符号,可为 nullptr
    PSYM_ENUMERATESYMBOLS_CALLBACKW Callback, // 输入:非 nullptr 同步回调
    PVOID UserContext                // 输入:可为 nullptr。不转移所有权
);

回调返回 TRUE 时枚举继续,返回 FALSE 时 DbgHelp 停止枚举。回调外需要长期保存字段时,复制 Namestd::wstring,复制 TagIndexSizeAddressModBase 到自己的记录。地址换算前验证 Address >= ModBase,随后计算 RVA = Address - ModBase。还要确认 RVA 小于 PE SizeOfImage

完成第三步后,回调已经提供了类型或符号名称、符号类别、大小和加载后地址。名称指针仍属于 DbgHelp 的临时缓冲区,地址也仍是进程虚拟地址。接下来要在数据离开回调前检查长度和所有权,再用模块基址计算可跨本次输出核对的 RVA。

四、第四步:验证长度和地址并输出可复核结果

4.1 错误边界与最小错误示范

把加载地址直接当作 RVA 会把 ASLR 基址混进模块内部偏移:

DWORD64 rva = symbol->Address;
// 错误:Address 是加载后的进程虚拟地址,缺少减去 ModBase 的步骤。

回调外保存 symbol->Name 指针也会出错:

savedName = symbol->Name;
// 错误:Name 属于 DbgHelp 回调临时结构,回调返回后地址不再可靠。

正确方式是 savedName.assign(symbol->Name, symbol->NameLen),并在同一回调内保存数值字段。类型枚举为空、符号枚举为空、PDB 被裁剪、PDB 身份不匹配、符号路径不可访问和模块只有导出名称属于不同状态。每种状态都应保留来源、错误码、模块基址和 PE/PDB 身份记录,避免把“没有可见类型”解释成“模块没有代码”。

4.2 类型记录的长度、字段偏移与版本边界

直接读取 PDB 的 TPI 流时,解析器面对的是二进制记录序列。每条记录的长度字段描述该条记录在流中占用的字节数,长度本身的单位是字节。当前记录结束位置必须小于或等于流的总字节数。记录中引用的类型索引只是一把钥匙,解析器还要在同一份 TPI 中确认该索引落在类型索引最小值和最大值之间。类型流截断、索引超界、未知 leaf kind、字段列表循环引用和名称字符串缺少终止边界都应产生明确的解析状态。

聚合类型的字段列表常通过一个类型索引间接指向 LF_FIELDLIST。访问该字段列表时,先保存“所属类型索引、所属类型名、所属类型大小和字段列表索引”,再逐项读取成员记录。成员偏移是相对所属对象起点的字节距离。它并不等于 PE 节内偏移,也不等于运行时虚拟地址。只有把一个真实对象地址加上已验证的成员偏移,才得到成员的运行时位置。

字段记录还需要处理对齐填充。PDB 会使用填充 leaf 把下一条记录对齐到所需边界,填充字节属于记录间格式,不构成结构成员。把填充解释成成员名称或偏移会让后续解析位置错位。未知记录不能按“猜测的结构大小”跳过。可靠做法是使用该记录声明的长度检查剩余范围,记录 leaf kind 后停止当前字段列表,或在已经掌握该 kind 规范时按规范跳过。

SYMBOL_INFOW 也有自己的长度边界。SizeOfStruct 是调用方设置给部分输入 API 的结构容量。枚举回调的 Size 是符号或类型本身的大小。NameLen 是名称字符数。三者服务于不同对象,不能混用。名称以 UTF-16 表示,复制时使用 NameLen 限制范围,再移除范围内的最后 NUL。名称指针由 DbgHelp 管理,离开回调后只能使用自己的副本。

模块信息同样需要分层解释。IMAGEHLP_MODULEW64::SymType 表示当前会话可见的符号来源类型,例如 PDB、导出符号或无符号。LoadedPdbName 是符号引擎实际加载的 PDB 路径。PdbUnmatched 表示身份检查状态。模块存在、PDB 已加载、类型可枚举、某个字段已定位是四个不同结论。优化后的 PDB 可能缺少局部变量,裁剪 PDB 可能缺少私有类型,导出符号视图也可能只有函数名和地址。

定位结果应保存可复核证据:PE 路径与身份、PDB 路径与 GUID/Age、模块会话基址、符号 Tag、类型索引、字段列表索引、字段偏移、段号、段内偏移、RVA、运行时地址和每一层的错误码。这样才能区分“符号引擎没有加载 PDB”“PDB 没有该类型”“字段记录格式未知”“段表换算失败”和“运行时模块基址已变化”。

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


冰与火的战歌:Windows内核攻防实战高级班!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。

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