构建后可执行文件回溯






4.84/5 (9投票s)
使用 DbgHelp 库执行高级的构建后可执行文件处理
引言
在构建过程之后修补可执行文件是定制编译和链接过程的有效方法。当环境工具不提供便捷的扩展性时,这一点尤其有用。例如,在《动态文本节映像验证》和《防篡改和自愈代码》中,我们都需要将派生结果(.text节的计算哈希值)复制并粘贴回源文件。这导致了又一次构建,以便将正确的签名嵌入到可执行文件中。每次源文件更改时,我们都需要执行额外的步骤。
本文将介绍一个命令行工具来自动化回填过程。选择命令行工具而不是 Visual Studio 插件,是因为命令行工具还提供了脚本化的能力。该程序将使用 DbgHelp.dll 的符号管理器来定位可执行文件中需要修补的变量的内存地址。然后,程序会将 DbgHelp.dll 输出的虚拟地址转换为磁盘偏移量,并将值写入目标可执行文件。
DbgHelp 库
DbgHelp 库为调试器和其他系统实用工具提供服务,以协助处理调试信息。该库现在被认为是操作系统的一部分。像 ImageHlp.dll 这样的其他库会将调用路由到该库。与其他操作系统文件不同,DbgHelp 在升级和补丁过程中不会像其他系统文件那样接收更新(尽管该库在 Windows 文件保护的管辖范围内)。
符号文件包含可执行文件将包含的相同调试信息,不同之处在于信息存储在调试 (.dbg) 文件或程序数据库 (.pdb) 文件中,而不是可执行文件中。Matt Pietrek 和 John Robbins 都对 PDB 格式的说明不多。但是,我们可以使用《Undocumented Windows 2000》来编写我们自己的符号解析器,因为 Schreiber 对该格式进行了逆向工程。
该库由 Windows 操作系统团队维护。要下载该库的最新版本,我们需要下载最新的 Debugging Tools for Windows(包括 WinDbg、kd 和 cdb)。Debugging Tools for Windows 包含所需的最新头文件和 lib 文件。关于该库使用的 Usenet 问题应定向到 microsoft.public.windbg。每次修改 PDB 格式(例如,在 Visual Studio 版本之间)时,最新版本的库将正确地操作符号文件。截至本文撰写之时,最新版本是 6.8,日期为 2008 年 10 月。
DLL 冲突(Dll hell)在这个库中依然存在。这是因为该库不会通过更新服务进行更新。例如,在一台完全打过补丁的 Windows XP Pro 工作站上,Windows 文件保护会保护文件的 5.1.2600 版本。如果我们尝试覆盖该文件,WPF 会立即从缓存中恢复 5.1 版本(日期为 2004 年 8 月)。因此,我们发现 IDA Pro、Debugging Tools for Windows 和 Visual Studio 等工具都会自带它们自己的库版本。

使用低版本 DbgHelp(例如,\System32 中的过时版本)通常会导致 Win32 错误 87:“参数不正确”,偶尔也会出现错误 126:“找不到指定的模块”。另一个与版本有关的问题是“在动态链接库 dbghelp.dll 中找不到过程入口点 SymInitializeW”。在这种情况下,我们链接了库的更新版本(来自 Debugging Tools for Windows),但使用了 DLL 的低版本。
在编译和链接时,我们在项目属性页的“其他路径”下指定更新的头文件和 LIB 的位置。该位置通常是 C:\Program Files\Debugging Tools for Windows\SDK,\inc 和 \lib。另外请注意,我们需要安装 Windows SDK for Windows Server 2008,以便能够访问 DbgHelp.h 中引用的最新枚举(如 BasicType)。Visual Studio 2005 用户应阅读 SDK 发行说明的第 4.3 节。最后,如果我们只想要枚举(不需要 SDK),可以从 Debug Interface Access SDK 中的 Enumerations and Structures 中获取,这似乎不是 Microsoft 的下载。
这个版本的 DbgHelp 就是我们最终会携带的版本(就像 IDA Pro 和 Visual Studio 一样),而不是操作系统在项目(可能在 \System32 中)缺少时会尝试加载的版本。由于版本问题,所有示例都在存档中包含了库,分别放在 \Debug 和 \Release 目录中,如图 2 所示。
![]() |
图 2:带库的示例目录结构 |
2005 年 2 月的《Under the Hood》期刊刊登了 Matt Pietrek 的文章,题为《Visual Studio 2005 (Whidbey) 和使用 DBGHELP 的程序》。根据 Pietrek 的说法,如果同时使用 DbgHelp 和 ImageHlp 库,我们应该先包含 DbgHelp 库,以确保加载的是所需的库版本。
示例程序
本文附带三个示例程序。第一个程序演示了 DbgHelp 库。第二个示例包含两个项目——一个用于修补的目标(命名为 Target.exe)和一个执行修补的程序(BackPatch.exe)。最后一个示例只有一个项目——Target.exe,它在后期构建步骤中使用第二个示例中的 BackPatch.exe 程序。
示例 1
第一个示例使用硬编码的参数来演示符号库。它的唯一目的是让我们熟悉库中需要用到的函数,这些函数列在下面。
- SymInitialize
- SymSetOptions
- SymSetSearchPath
- SymLoadModule
- SymGetModuleInfo
- SymFromName
- SymUnloadModule
- SymCleanup
下面的代码展示了我们确定文件名 Target.exe 中名为 variable 的符号的虚拟地址的步骤。通常我们会遇到 Target!variable 的形式。下面,variable 被声明为全局变量,HANDLEID 被定义为 1。句柄为库提供了一个上下文,以防调试器管理多个客户端。我们可以使用 GetCurrentProcess,但常量更方便。
int variable = 0; string symbol = "variable"; string filename = "Target.exe"; string path = "C:\\...\\BackPatch\\debug"; SymInitialize( HANDLEID, NULL, FALSE );
我们将 NULL 传递给 SymInitialize,因为稍后我们将设置搜索路径。将 FALSE 传递给 fInvadeProcess:我们不需要库枚举所有加载的模块,从而有效地为每个模块调用 SymLoadModule 函数。
初始化库后,我们选择取消修饰名称并加载所需的搜索路径。如果我们对源代码行号感兴趣,我们会将 SYMOPT_LOAD_LINES OR 进去。SYMOPT_DEBUG 会通过 OutputDebugString 将额外信息发送到输出窗口。有关可用选项的完整列表,请参阅 MSDN 中的 设置符号选项。
SymSetOptions( SymGetOptions() | SYMOPT_UNDNAME );
接下来,我们指定一个目录搜索路径。
SymSetSearchPath( HANDLEID, path );
如果我们指定的目录不够精确,可能会收到错误的结果,因为 DbgHelp 会搜索并可能找到调试目录中 Target.exe 的发布版本(反之亦然)的错误符号。在下面的图 3 中,路径 "...\ExeFunctionMap Files\ExeFunctionMap\" 被传递给 SymSetSearchPath。我们运行发布版本,但 DbgHelp 加载它找到的第一个可执行文件的符号,即调试符号。
![]() |
图 3:DbgHelp 搜索路径结果 |
接下来,我们调用库加载模块的符号表。之后是对 SymGetModuleInfo 的调用。显然,这将为我们提供有关加载的模块和符号表的信息。SymLoadModule 的调用不需要映像在内存中。最后,SymLoadModule 已被 SymLoadModule64 取代,而 SymLoadModule64 又被 SymLoadModuleEx 取代。
DWORD64 dwBaseAddress = SymLoadModule64( HANDLEID, 0, filename, NULL, 0, 0 ); IMAGEHLP_MODULE64 im = { sizeof(im) }; SymGetModuleInfo64( HANDLEID, dwBaseAddress, &im );
然后,我们构建一个结构,以便我们可以查询库中关于符号 variable 的信息。结构 SYMBOL_INFO 的 name 字段是可变长度的,因此我们必须提供一个足够大的缓冲区来容纳存储在 SYMBOL_INFO 结构末尾的名称。缓冲区格式的源代码包含从 MSDN 获取的详细信息(为清晰起见已省略)。最后,查询在对 SymFromName 的调用中完成。
TCHAR szSymbolName[MAX_SYM_NAME]; ULONG64 buffer[...]; PSYMBOL_INFO pSymbolInfo = (PSYMBOL_INFO)buffer; StringCchCopy( szSymbolName, MAX_SYM_NAME, symbol ); pSymbol->SizeOfStruct = sizeof(SYMBOL_INFO); pSymbol->MaxNameLen = MAX_SYM_NAME; SymFromName( HANDLEID, szSymbolName, pSymbolInfo ); cout << " Symbol Name: " << pSymbolInfo->Name << endl; cout << "Virtual Address: 0x" << pSymbolInfo->Address << endl;
最后,我们卸载模块并使用 SymUnloadModule 和 SymCleanup 关闭库。
SymUnloadModule64( HANDLEID, dwBaseAddress ); SymCleanup( HANDLEID );
第一个示例的输出如图 4 所示。请注意,即使我们将可执行文件(和 DbgHelp.dll)移动到 C: 的根目录下,符号引擎也能正确找到 PDB 文件,该文件显示在加载的模块下为 C:\...\BackPatch\BackPatch.pdb。
![]() |
图 4:示例 1 输出 |
示例 2
我们的第二个示例是最有趣的。它包含两个项目——一个驱动程序和一个修补程序。使用两个项目是因为集体分发它们更容易。
驱动程序名为 Target.exe,它显示其源文件中声明的整数的值。Target.cpp 的列表如下,其输出如图 5 所示。
INT variable = 0xFFBB8844; int main(int argc, _TCHAR* argv[]) { cout << "variable: 0x"; cout << HEX(8) << variable << endl; cout << " Address: 0x"; cout << HEX(8) << (INT*) &variable << endl; return 0; }
![]() |
图 5:Target.exe 输出 |
BackPatch.cpp 包含示例 1 中介绍的符号引擎代码。由于我们希望 BackPatch.exe 独立运行,因此我们添加了 Chris Losinger 的 CCmdLine 来进行命令行解析,并添加了一些升级以支持 Unicode。BackPatch 支持的开关如下:
Switch | 含义 |
-s | 符号名称 |
-f | 要回填的文件 |
-p | 将传递给 DbgHelp 的路径 |
-n | 新值 |
-v | 详细输出 |
-t | 仅测试 |
一个根目录为 C:\BackPatch 的项目,希望将名为 'variable' 的整数符号以新值 0xF0F00F0F 修补到 Target.exe 中,将如下构成:BackPatch.exe -s variable -f Target.exe -p "C:\BackPatch" - n 0xF0F00F0F。
我们将使用项目属性页来使用提供的宏为程序传递参数。Visual Studio 会将输出放在 \Debug 或 \Release 目录中,因此我们将使用 $(ProjDir) 宏。参见图 6。
![]() |
图 6:命令行参数 |
$(ProjDir) 是根目录(解决方案所在的位置),后面会附加 \Debug 或 \Release。我们将宏括在引号中,因为我们可能会遇到长文件名。图 7 显示了在根目录为 C:\Local Shared\BackPatch\BackPatch Sample Files\BackPatch 2\ 的解决方案中运行第二个示例的结果。
![]() |
图 7:示例 2 输出 |
一旦 DbgHelp 确定了感兴趣的虚拟地址(上面是 0x419004),我们就移到磁盘文件。DbgHelp 产生了 DbgHlpTargetAddress(虚拟地址)和 DbgHlpBaseAddress。然后我们规范化虚拟地址以获得相对虚拟地址。
ULONG DbgHlpVirtAddr = DbgHlpTargetAddress - DbgHlpBaseAddress;
下一步是将磁盘文件的视图映射到内存中,以便进行读写访问。我们之前已经多次见过这段代码。
///////////////////////////////////////////////////////////// hFile = CreateFile( filename, GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, 0); if ( hFile == INVALID_HANDLE_VALUE ) { ... } ///////////////////////////////////////////////////////////// hFileMapping = CreateFileMapping( hFile, NULL, PAGE_READWRITE , 0, 0, NULL ); if ( NULL == hFileMapping ) { ... } ///////////////////////////////////////////////////////////// pDiskBaseAddress = MapViewOfFile( hFileMapping, FILE_MAP_WRITE | FILE_MAP_READ, 0, 0, 0 ); if ( NULL == pDiskBaseAddress ) { ... }
接下来,我们解析 DOS 和 NT 头文件以查找节头。一旦我们获得第一个头,我们就会遍历头文件,搜索包含我们派生的 DbgHlpVirtAddr 的头文件。我们已经知道它会位于 .data 或 .rdata 节中。
///////////////////////////////////////////////////////////// PIMAGE_SECTION_HEADER pSectionHeader = IMAGE_FIRST_SECTION( pNTHeader ); UINT nSectionCount = pNTHeader->FileHeader.NumberOfSections; for( UINT i = 0; i < nSectionCount; i++ ) { // Find the Section... if( pSectionHeader->VirtualAddress <= DbgHlpVirtAddr && DbgHlpVirtAddr < pSectionHeader->VirtualAddress + pSectionHeader->Misc.VirtualSize ) { // We found the Section Header ... } }
使用上面的 [相对] VirtualAddress 告诉我们在可执行文件映射到内存后变量应该驻留的位置。我们关心它在磁盘文件上的分配位置。为此,我们只使用 RawPointer 成员。但是,仅凭 PointerToRawData 并不足够——它只告诉我们节在磁盘上的起始位置。我们仍然需要节内的偏移量。对于偏移量,我们回到预期的内存布局。
ULONG dwOffset = DbgHlpVirtAddr - pSectionHeader->VirtualAddress; ULONG dwDiskLocation = pSectionHeader->PointerToRawData + dwOffset;
最后,给定 dwDiskLocation,我们将其转换为 INT 指针并写入新值。
*((INT*)dwDiskLocation) = 0x00000000;
图 8 显示了在第二个示例中运行 BackPatch.exe 的结果,提供了新值 0xFFAAFF。
![]() |
图 8:示例二的结果 |
在图 9 中,我们在运行 BackPatch.exe 后检查 Target.exe 的磁盘文件。我们这样做是为了验证磁盘文件中的值。请注意,从图 8 中,我们计算出磁盘位置为 0x1C18。在图 9 中,我们看到值 0xFFAAFF 已被写入。
![]() |
图 9:程序十六进制转储 |
在图 10 中,我们交替运行 Target/BackPatch/Target。Target.exe 的 variable 开始时值为 0xF0F00F0F。修补后,值为 0xAA0000AA。
![]() |
图 10:打印/修补/打印 |
示例 3
示例 3 检查了构建过程后回填可执行文件的效果。我们首先将 BackPatch.exe 和 DbgHelp.dll 放入项目输出目录(\Debug 或 \Release)中。接下来,我们添加一个后期构建事件,该事件调用我们的命令行工具(参见图 11)。我们输入的命令行是 "$(OutDir)\BackPatch.exe" -s variable -f $(TargetFileName) -p "$(OutDir)" -n 0x1111111 -v。
![]() |
图 11:后期构建事件 |
编译和链接后,将调用我们的工具,如图 12 所示。对于 Visual Studio,我们应该删除 -v 开关并提供 -q 开关以抑制输出。
![]() |
图 12:工具调用 |
运行示例 3 会产生预期的结果:显示 0x11111111。接下来,我们使用 PEChecksum 等工具检查文件的校验和,该工具在 Windows PE 校验和算法分析 中进行了介绍。在图 13 中,我们看到校验和不正确。
![]() |
图 13:PE 校验和 |
此外,文件时间戳也将被修改。这在 Visual Studio 环境中不会造成问题(例如,文件时间戳触发额外的编译)。如果我们正在修补使用强名称的 CLR 程序集,我们希望在链接器调用 sn.exe 之前运行。请参阅相关的《强名称程序集(程序集签名)》和 AssemblyDelaySignAttribute 类。留给读者作为练习,该工具可以通过以下方式增强:
- 添加安静 (-q) 开关
- 保存并恢复原始文件时间戳
- 重新计算 PE 校验和
回想一下,ImageHlp 将调用路由到 DbgHelp,因此如果使用 ImageHlp 的 CheckSumMappedFile 来重新计算校验和,我们必须在 ImageHlp 库之前包含 DbgHelp 库。另一种方法是使用等效函数自行计算校验和。在《代码移植:无限代码重用》中提供了一个这样的函数。在后一篇文章中,我们将 CheckSumMappedFile 的编译代码移植到了我们自己的可执行文件中。
下载次数
- 下载源代码 - 示例 1 - 900 kB
- 下载源代码 - 示例 2 - 909 kB
- 下载源代码 - 示例 3 - 927 kB
- 下载程序 - PEChecksum - 108 kB
校验和
- BackPatch1.zip
- MD5: 2AF82B1C938ECEE614950491B33393DC
- SHA-1: 2BDEAC1AD7EF3A63576F9CA1A7F12C732CA0D33B
- BackPatch2.zip
- MD5: 059B9B3578CCA165FCE4ACB2099F0364
- SHA-1: 18ACF5B093817267FED962EB03DFA5D2DDA14B57
- BackPatch3.zip
- MD5: 114CFBC047B4EC6B5B0131B5B44C6E90
- SHA-1: D4E12F5001895075C30F17FBF45D6B789AAE9FEF
修订
- 2008.03.23 添加了 DIA SDK 信息
- 2008.03.16 初始发布