利用 Windows 热补丁机制






4.93/5 (19投票s)
在这里,我将演示如何模拟 Windows 热补丁,以及如何利用此机制临时或永久地重定向自定义过程的执行。
引言
也许并非所有程序员都知道,微软已经开发了一种机制,允许通过 Windows 操作系统家族对其库和可执行文件进行热补丁。热补丁允许在不重新启动整个系统的情况下更新正在运行的组件;当然,关键组件(通常是系统组件)总是需要重启整个操作系统才能应用必要的更改。
本文的范围是解释汇编是如何设计成允许轻松进行热补丁的,以及如何利用这种设计允许将执行重定向到自定义过程,该过程可以被视为补丁。我不会描述 Windows 如何执行热补丁,因为有关这种机制实现的信息似乎是操作系统内部的、未公开的部分。
背景
我假设读者对汇编编程语言、C/C++ 编码语法、Windows API 使用和调用约定、Intel x86/64 操作码、可移植可执行文件 (PE) 的结构以及进程调试有基本和中级知识。我还假设您对 Visual C++ IDE、IDA Pro 免费调试工具和您喜欢的十六进制编辑器(我使用 HxD)有信心。
然而,我将尝试以一种即使不是该领域专家的人也能阅读和理解该机制工作方式的方式来撰写本文。
它是如何制作的:Windows 热补丁
最重要的一点是要说,Windows 程序员构建了系统的组件以允许其进行热补丁。没有这种最初的努力,热补丁一个组件将更加困难,通常会导致重写过程的初始部分。
正如你们都知道的(我发誓),Windows API 过程使用 `__stdcall` 调用约定(在 MSDN 中搜索 `__stdcall`),它描述了过程将如何被调用。此选项允许被调用者在执行内部代码之前清理堆栈(请记住,`__stdcall` 也会像 `__cdecl` 调用约定一样,将过程值返回到 eax 处理器寄存器)。简而言之,编译器将为您安排函数(我假设您使用 C/C++ 编码)的方式如下:
// __stdcall format.
// Prologue: clean the stack to prepare for execution.
//
push ebp
mov ebp, esp
//
// Here goes the procedure codes; at the end return the value 0.
//
mov eax, 0
//
// Epilogue: restore the previously saved base pointer.
//
mov esp, ebp
push ebp
ret
除了这种调用约定之外,Windows 程序员明智地添加了一些指令来“为短跳转(处理器跳转到距离操作数-128 到 127 字节的内存位置,有关 x86/64 的信息请参阅 Intel 手册)腾出空间”,这允许将执行重定向到另一个已修补的过程。用于保留此类空间的指令通常被翻译为汇编语言语句“mov edi, edi”,该语句可以翻译成操作码,即 16 位字“8b ff”。
如果您使用 IDA Pro(5.0 版本可免费下载),您可以查看导出过程(导出选项卡),并且可以意识到其中大多数过程在 `__stdcall` 调用约定序言之前,在过程的开头都有这种看似无用的语句。
除了这种语句之外,在每个“可修补”过程的前面,Windows 程序员保留了 5 个字节的空闲空间(这可能因库而异,在 ntdll.dll 中有 5 个 `0xcc` 字节,而在 user32.dll 中有 5 个 `0x90` 字节)。这些字节足以存储一个远跳转到内存中的某个位置(远跳转允许跳转到距离操作数-2147483648 到 2147483647 的任意位置)。
// Patchable procedure as you can find in ntdll.dll.
// The opcode 0xcc(int 3) is usually a value filled by the compiler to align the code.
//
int 3
int 3
int 3
int 3
int 3
mov edi, edi
push ebp
mov ebp, esp
//
// ... and the procedure continues here.
为了**总结**我所说的一切,每个可修补的过程前面都有一个 7 字节的模式,允许将执行重定向到另一个现有的或注入的过程。
核心:覆盖并跳转。
正如我在标题中所说,热补丁模式背后的想法是重写过程前面用于跳转到自定义内存位置的 7 个字节的代码。不幸的是,任何内存更改都会导致操作系统在内存中创建一个**副本**,因此在特定模块内进行的任何修补仅对该进程有效。这种机制称为**写时复制**,它是操作系统内存保护的一部分(在 MSDN 中搜索 `Copy-on-Write Protection` 以获取更多信息);这可以防止进程重写其部分代码,而这些代码也被系统中的其他模块使用。
让每个模块都能使用注入的补丁的唯一方法是直接在文件系统中的物理文件中执行这种修补,这样,从更改发生之日起,所有使用该模块的应用程序都将包含这种补丁。通过我的项目,可以选择在内存中还是在文件中进行修补。
对系统中当前运行的组件执行完整的热补丁(更改物理内存页中的代码),这会导致系统上所有进程的更新,超出了本文的范围(并且通常需要内核模式组件,这些组件可以访问物理页)。
我天真的机制遵循以下步骤:
- 找到您要修补的**目标过程**(这可以通过各种启发式方法完成;在我的项目中,我修补第一个兼容的过程),使用已知模式来查找它。
- 在同一模块中找到**足够的空闲空间**来包含补丁。此外,此步骤可以与您的自定义启发式绑定;在我的项目中,我选择第一个兼容的空闲空间部分。
- **计算**注入的过程与目标过程之间的差异:这将是远跳转的值。
- 将跳转**注入**到目标过程的前面,并将补丁注入到找到的空闲空间中。
扫描
步骤 1 和 2 通过扫描内存以查找兼容模式来执行。
为了不给 Windows 子系统造成任何问题,我在解决方案中包含了一个 DLL 项目,我将其设计成类似于 ntdll.dll 的修补模式。这个小项目只包含一个代码表,其中包含一些“裸露”的过程,我通过汇编语句进行了安排。
安排是 `__stdcall` 调用约定的一种副本,增加了用于热补丁机制的 7 个字节和一个允许正常执行的短跳转。没有这样的跳转,当有人调用过程时,处理器将执行中断 3,这将导致调试中断(Intel 就是这样管理这种操作码的)。
// Procedure exported by dummy.dll
DUMMY_API __declspec(naked) int
dummyA()
{
// Entry to emulate windows patching mechanism.
__asm
{
jmp short start
int 3
int 3
int 3
int 3
int 3
start: mov edi, edi
push ebp
mov ebp, esp
}
mem.c 代码表中存在的扫描过程将简单地扫描给定的字节缓冲区以查找要识别的模式。如果是文件修补,缓冲区可以通过 `fread()` 过程填充;如果所需的补丁仅注入内存,则可以通过 `memcpy()` 过程填充。两种扫描过程都将继续扫描整个文件,并将填充一个描述符列表,告诉我们可修补的候选对象在哪里(mem.h 包含描述每个候选对象的结构)。
在这种情况下,可修补过程的模式将是字节序列(7 字节):
cc cc cc cc cc 8b ff 55 8b ec
**仅扫描此模式可能很危险,因为您可能会发现兼容的字节,例如,位于数据区域。这种简单的扫描仅在此处用于简化目的;最好将这种扫描限制在仅用于执行的区域,或者使用 PE 文件中的导出过程部分作为指南(在此部分列出了模块导出的可用过程,因此您将确信它们确实是过程)。**
与注入兼容的空闲内存区域的模式将是字节序列(14 字节的对齐保留空间;通常会靠近可修补的候选对象):
cc cc cc cc cc cc cc cc cc cc cc cc cc cc
选择空闲内存模式是因为我将插入的补丁是一个 14 字节的过程,该过程返回值 0x64(ASCII 表中 'D')。这种过程是 dummyD() 过程的副本,该过程存在于 dummy DLL 项目中。dummyD() 是一个未使用的过程,仅插入到项目中,以便我能够使用 IDA 打开二进制文件并复制所需的代码部分。
计算跳转
步骤 3 是一个相当简单和数学的部分。执行此修补所需的有趣跳转有两个:一个是近跳转,将执行指向指令前 5 个字节;第二个是远跳转。
第一个跳转总是固定的,因为跳转回不变。我们假设(并且已经看到)第二个跳转所需的 5 个字节始终位于可修补过程的开头。也可以更改此跳转以选择另一个空闲部分,但请记住,您受限于-128 到 127 字节的地址范围。
第二个跳转可以计算如下:
远跳转 = <注入的补丁的地址> - <目标过程的地址> - 5
所需的地址是通过前面的扫描(步骤 1 和 2)获得的,而常数 5 是跳转指令的大小(CPU 在执行期间会消耗它)。
注入补丁
最后一步终于将修改写入内存/文件。现在所有必要的基础步骤都已计算好,要完成的工作相当简单:**重写内存**以更改执行流程。在我的解决方案中,我实现了两者(内存和文件重写),您可以在 dummy 库上进行测试。
对于这两种过程,操作是按顺序编写修改。它首先以远跳转指令 `0xe9` 开头,紧接着是 4 个字节,即注入过程的偏移量。然后用字节 `0xeb 0xf9` 覆盖 `__stdcall` 序言之前的“`mov edi,edi`”指令。第一个是短跳转的操作码,第二个字节是必要的负偏移量,用于将流重定向到远跳转。
然后它“搜索”到先前步骤中扫描到的空闲空间区域,并用准备好的补丁覆盖后面的字节。在这种情况下,正如我所说,注入的补丁是 dummyD() 过程,它被编译器翻译成以下字节:
8b ff 55 8b ec b8 64 00 00 00 8b e5 5d c3
以下是将补丁注入文件的过程:
int
injectFilePatch(
char * path,
dword procBase,
dword patchBase,
byte * patch,
dword pLength)
{
int bWr = 0;
FILE * fd = fopen(path, "r+b");
byte farJmp = JMP_FAR;
// 5 is the jmp instruction size(e9 xx xx xx xx).
dword jmpOff = patchBase - procBase - 5;
if (!fd)
{
return ERR_IO;
}
// Positionate at the begin of the area to rewrite.
fseek(fd, procBase, SEEK_SET);
// Writes the far jum instruction ...
bWr += fwrite(&farJmp, 1, 1, fd);
// ... to this address ...
bWr += fwrite(&jmpOff, 1, 4, fd);
// ... and replace mov edi,edi with a short jump back.
bWr += fwrite(g_short_jmp_back, 1, 2, fd);
if (bWr != 7)
{
return ERR_IO;
}
// Positionate at the begin of the area to rewrite.
fseek(fd, patchBase, SEEK_SET);
// Inject the patch in the binary file.
bWr = fwrite(patch, 1, pLength, fd);
if (bWr != pLength)
{
return ERR_IO;
}
fclose(fd);
return SUCCESS;
}
这是将补丁注入内存的过程:
int
injectMemPatch(
dword procBase,
dword patchBase,
byte * patch,
dword pLength)
{
int bWr = 0;
byte farJmp = JMP_FAR;
// 5 is the jmp instruction size(e9 xx xx xx xx).
dword jmpOff = patchBase - procBase - 5;
// Writes the far jum instruction ...
//bWr += fwrite(&farJmp, 1, 1, fd);
memcpy(procBase, &farJmp, 1);
// ... to this address ...
//bWr += fwrite(&jmpOff, 1, 4, fd);
memcpy(procBase + 1, &jmpOff, 4);
// ... and replace mov edi,edi with a short jump back.
//bWr += fwrite(g_short_jmp_back, 1, 2, fd);
memcpy(procBase + 5, g_short_jmp_back, 2);
// Inject the patch in the binary file.
//bWr = fwrite(patch, 1, pLength, fd);
memcpy(patchBase, patch, pLength);
return SUCCESS;
}
**注意:** 在尝试写入内存之前,我必须更改进程内存的保护级别,以获得修改该区域所需的权限。这可以通过 Windows API 过程 `VirtualProtect()` 来完成,该过程存在于 mem.c 代码中的扫描过程中。如果没有此类授权,任何尝试写入内存的行为都将导致异常,从而中断您进程的执行。
测试
我附在这篇文章中的是一个压缩的存档,其中包含一个 Visual Studio 2013 for Windows Desktop 解决方案,其中包含三个项目:
- **Patch Patcher:** 这个程序负责应用补丁。
- **Dummy:** 一个 dummy dll,格式化成与 ntdll.dll 相似的修补签名。
- **Test:** 这个程序加载 dll 并执行导出过程。
您需要修改的唯一项目是 Patcher 项目。作为默认操作,此程序在执行其入口点时 just exit。您可以取消注释您想执行的所需修补操作,当然也要更改 dummy.dll 模块的路径。
int main(int argc, char ** argv)
{
// Uncomment this to patch the physical file; the patch is permanent.
//patchFile("C:\\Dev\\WinPatching\\Debug\\dummy.dll");
// Uncomment this to patch in memory; the patch is valid only for patcher.exe.
//patchMemory("C:\\Dev\\WinPatching\\Debug\\dummy.dll");
return 0;
}
使用命令提示符,您可以测试**永久修补**,首先运行 test.exe,然后运行 patcher.exe(在删除 patchFile() 过程的注释后),然后再次运行 test.exe。
这里有一些测试输出:
Dummy library tester [version 0.1]
Initial state is: a=0x0, b=0x1, c=0x2
Calling dummyA...
a should be 0x61, a=0x61
Calling dummyB...
a should be 0x62, a=0x62
Calling dummyC...
a should be 0x63, a=0x63
Final state is: a=0x63, b=0x1, c=0x3
Scan pattern:
cc cc cc cc cc 8b ff 55 8b ec
Candidates found:
Base: 0x432
Base: 0x452
Base: 0x472
Patching file procedure at 0x432 to 0x49e
Dummy library tester [version 0.1]
Initial state is: a=0x0, b=0x1, c=0x2
Calling dummyA...
a should be 0x61, a=0x64
Calling dummyB...
a should be 0x62, a=0x62
Calling dummyC...
a should be 0x63, a=0x63
Final state is: a=0x63, b=0x1, c=0x3
如您所见,第一个文本块是首次运行 test.exe 的结果。初始状态显示在第二行,其中包含使用的各种变量的值。“a”变量将存储每次调用 dummy.dll 过程时的结果:如您所见,总是获得预期值。
在 initial test.exe 执行后,我执行了 patcher.exe(在取消注释 patchFile() 过程后)。扫描模式提醒我们正在搜索的签名,而候选者提供了 dummyA()、dummyB() 和 dummyC() 过程在物理文件中的基地址。正如您所注意到的,dummyD() 过程没有候选者;这是因为 dummyD() 是在没有修补签名的情况下编写的。
最后一行告诉我们选择了哪个候选者(出于测试方便,总是第一个)以及执行将重定向到何处。如果您在修补前后用十六进制编辑器打开文件,您将轻松看到重写的字节。
最后一个块是 test.exe 的日志;这次 dummyA() 过程将返回 0x64,正如我们所期望的。这是由于补丁已注入到 dll 本身。从现在开始,任何加载并使用 dummy.dll 的进程在调用导出的过程时都将返回 0x64。
**内存修补**的测试有点复杂。首先取消注释 patcher 项目中的正确过程(patchMemory),然后从 Debug 文件夹中删除修改后的 dll(使其恢复为原始状态)。
使用 Visual Studio 在 patcher.c 中注入代码断点(按 F9 键),在调用注入的地方。
status = injectMemPatch(
matchBase,
patchBase,
g_patch,
PATCH_LENGTH);
这将立即在补丁注入之前中断执行。
现在,如果您调试 patcher 应用程序(按 F5 键),执行将在那里停止,您将在创建的控制台中看到与文件修补测试中看到的相同的输出。这次基地址不同(更大),因为它们指的是内存位置。使用 Visual Studio 的内存窗口(Debug > Windows > Memory > Memory 1),您可以打开另一个选项卡,该选项卡将显示当前 RAM 中发生的情况。移动到指针位置(在我看来是 0x6ace1032),您将找到加载器加载了您的可执行文件。
0x6ACE1000 cc cc cc cc cc e9 26 00 00 00 e9 41 00 00 00 e9 ÌÌÌÌÌé&...éA...é
0x6ACE1010 5c 00 00 00 e9 77 00 00 00 cc cc cc cc cc cc cc \...éw...ÌÌÌÌÌÌÌ
0x6ACE1020 cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc ÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌ
0x6ACE1030 eb 05 cc cc cc cc cc 8b ff 55 8b ec b8 61 00 00 ë.ÌÌÌÌÌ.ÿU.ì¸a..
0x6ACE1040 00 8b e5 5d c3 cc cc cc cc cc cc cc cc cc cc cc ..å]ÃÌÌÌÌÌÌÌÌÌÌÌ
0x6ACE1050 eb 05 cc cc cc cc cc 8b ff 55 8b ec b8 62 00 00 ë.ÌÌÌÌÌ.ÿU.ì¸b..
0x6ACE1060 00 8b e5 5d c3 cc cc cc cc cc cc cc cc cc cc cc ..å]ÃÌÌÌÌÌÌÌÌÌÌÌ
0x6ACE1070 eb 05 cc cc cc cc cc 8b ff 55 8b ec 8b 45 0c 8b ë.ÌÌÌÌÌ.ÿU.ì.E..
0x6ACE1080 4d 08 80 c1 02 88 08 b8 63 00 00 00 8b e5 5d c3 M.€Á.ˆ.¸c....å]Ã
0x6ACE1090 8b ff 55 8b ec b8 64 00 00 00 8b e5 5d c3 cc cc .ÿU.ì¸d....å]ÃÌÌ
0x6ACE10A0 cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc ÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌ
0x6ACE10B0 cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc ÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌ
0x6ACE10C0 cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc ÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌ
0x6ACE10D0 cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc ÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌ
0x6ACE10E0 cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc ÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌ
0x6ACE10F0 cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc ÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌ
如果您仔细查看 0x6ace1032,您会识别出我们正在寻找的模式:**cc cc cc cc cc 8b ff 55 8b ec**。如果您继续进行汇编分析,您还会检测到其他 dummy 过程;它们都以相同的序言(eb 05 cc cc cc cc cc 8b ff 55 8b ec)开始,并用“0xcc”对齐以从行开始。唯一不同的过程是 dummyD,块中的最后一个,它有一个不可修补的序言,并从 0x6ace1090 开始。
如果您保持内存 1 选项卡打开并逐步执行(按 F10),您将实时看到内存的修改。通常 Visual Studio 会将红色字符显示为被覆盖的字符(我用粗体字符增强了这些区域)。
0x6ACE1000 cc cc cc cc cc e9 26 00 00 00 e9 41 00 00 00 e9 ÌÌÌÌÌé&...éA...é
0x6ACE1010 5c 00 00 00 e9 77 00 00 00 cc cc cc cc cc cc cc \...éw...ÌÌÌÌÌÌÌ
0x6ACE1020 cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc ÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌ
0x6ACE1030 eb 05 e9 67 00 00 00 eb f9 55 8b ec b8 61 00 00 ë.ég...ëùU.ì¸a..
0x6ACE1040 00 8b e5 5d c3 cc cc cc cc cc cc cc cc cc cc cc ..å]ÃÌÌÌÌÌÌÌÌÌÌÌ
0x6ACE1050 eb 05 cc cc cc cc cc 8b ff 55 8b ec b8 62 00 00 ë.ÌÌÌÌÌ.ÿU.ì¸b..
0x6ACE1060 00 8b e5 5d c3 cc cc cc cc cc cc cc cc cc cc cc ..å]ÃÌÌÌÌÌÌÌÌÌÌÌ
0x6ACE1070 eb 05 cc cc cc cc cc 8b ff 55 8b ec 8b 45 0c 8b ë.ÌÌÌÌÌ.ÿU.ì.E..
0x6ACE1080 4d 08 80 c1 02 88 08 b8 63 00 00 00 8b e5 5d c3 M.€Á.ˆ.¸c....å]Ã
0x6ACE1090 8b ff 55 8b ec b8 64 00 00 00 8b e5 5d c3 8b ff .ÿU.ì¸d....å]Ã.ÿ
0x6ACE10A0 55 8b ec b8 64 00 00 00 8b e5 5d c3 cc cc cc cc U.ì¸d....å]ÃÌÌÌÌ
0x6ACE10B0 cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc ÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌ
0x6ACE10C0 cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc ÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌ
0x6ACE10D0 cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc ÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌ
0x6ACE10E0 cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc ÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌ
0x6ACE10F0 cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc ÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌÌ
现在加载到内存中的 dummy.dll 模块已为该进程打补丁,并且对这些过程的任何调用都将以 0x64 的返回值结束。
结论
热补丁或永久补丁在汇编设计允许的情况下(例如在 Windows 库中)可以是一种有用且无痛的解决方案。这种机制允许将执行流程重定向到您的自定义过程,并且可以以多种方式用于实时个性化或改进某些服务。例如,邮件服务器或 Web 服务器可以在不重启系统的情况下更新自身。杀毒软件可以将执行从病毒过程重定向到系统的敏感区域,甚至可以通过跳转到其代码区域之外来中断恶意软件。
对于不包含任何辅助重定向设计的过程进行修补仍然是可能的,但这通常会导致重写过程的前几个字节。
项目的代码清晰且注释完整,因此跟踪流程并理解每个过程和采取的行动并不困难。如果您有任何疑问,只需提问,我将尽快回答。
这是我在这里的第一个文章,我希望它能对一些人有用,至少能让他们稍微了解一下这个机制是如何工作的以及如何利用它。如果您发现任何语法或技术错误,请原谅我,并随时与我联系:我很乐意修改文章以纠正报告的句子。
历史
2014/03/07 - 文章的初始发布。 :3
2014/04/01 - 在文章的第一个汇编代码片段中将“asp”更正为“esp”(语法错误,我的错)。