热补丁:(非常)深入






4.92/5 (68投票s)
一个可用的热补丁库,
- GitHub 项目: https://github.com/WindowsNT/hotpatch
引言
我开发了一款游戏,其吸引力如此之大,以至于我无法允许用户在更新时退出游戏。
我已经因此面临几起诉讼,而且,除非您现在投票给我,否则我将无法支付我的律师费。
当然,我并不太在乎。他并不在乎钱,因为他不会停止玩我的游戏。但为了满足所有人的要求,我必须在 Windows 中实现热更新技术。
而且我还有一个客户要求热更新,但又不喜欢 DLL。嘿,自己进行热更新有可能吗?嗯,我想是的。
现在,使用清理过的库 (一个 *.h 文件!) 并且不需要管理员权限!
什么是热更新?
这是一种在不先退出程序的情况下更新进程的技术。要实现这一点,您需要在运行时修补函数调用。在最常见的情况下,您有一个可执行文件和一些包含函数的 DLL。
修补发生在当您的可执行文件从您的服务器下载另一个 DLL 并加载它时。然后,该 DLL 将在运行时用修补过的函数替换旧函数。
如果您还在看,我稍后还会介绍更疯狂的场景。请继续阅读。
五种方法
热更新有五种方法
- 加载一个 DLL 到您的地址空间,该 DLL 会修补您的 EXE 函数。这是最安全的方法。
- 优点: 最安全,最简单。工作在单个地址空间中完成。
- 缺点: 需要一个额外的 DLL。
- 通过 COM 互操作和一些代理函数,从更新后的 EXE 修补您的 EXE。
- 优点: 不需要额外的 DLL
- 缺点: 工作通过代理调用完成,不同的地址空间。
- 通过共享内存和一些代理函数,从更新后的 EXE 修补您的 EXE。
- 优点: 不需要额外的 DLL。
- 缺点: 工作通过代理调用完成,不同的地址空间。
- 通过将更新后的 EXE 加载到与您的 EXE 相同的地址空间 (!!!) 来修补您的 EXE。
- 优点: 所有工作都在单个 EXE 和单个地址空间内完成。
- 缺点: 粗糙且通常不稳定的方法。
- 从一开始就将您的应用程序作为 DLL
- 优点: 单文件应用程序 + 修补
- 缺点: 需要一个引导程序 (或 .bat 文件或链接器等) 来启动您的应用程序。
五个项目
包含的 Visual Studio 2017 解决方案包含以下项目
- 以 *.hotpatch.h 形式的
HotPatching
库, 以及我的更新 USM 和 XML 库 - 五个演示热更新的项目
- 一个可以修改模块以包含热更新信息的应用程序 (pig.exe + 源代码)。解决方案中的项目不需要它,因为指定了自定义构建步骤来自我准备。在您的项目中,您可能需要它。
这些项目同时支持 x86 和 x64 上的所有热更新方法。
准备编译器和链接器
为了使函数有资格进行热更新,必须满足三个条件
- 函数不能是内联的。
- 函数的大小必须至少为两个字节。
- 编译器不能优化函数参数列表 (后面有示例),并且,
- 函数入口点之前必须有足够的填充空间来存储跳转到修补程序。
您可以使用 __declspec(noinline)
强制 VC++ 编译器从不内联函数。
为了满足其余的填充要求,您需要使用 /HOTPATCH
进行编译 (仅在 x86 上需要) 并使用 /FUNCTIONPADMIN:5
(x86) 和 /FUNCTIONPADMIN:12
(x64) 进行链接。稍后我将解释这些字节的用途。在旧版本的 Visual Studio 中,链接器会忽略 /FUNCTIONPADMIN
参数,因此它不会在 x64 构建中为函数之前的必需填充留出空间。
使用 /FUNCTIONPADMIN
进行编译会在函数开头放置一个虚拟的 mod edi,edi
或 mod rdi,rdi
命令。这允许您用 JMP 指令替换这些字节。
修补什么?
要进行修补,必须知道要修补的函数在哪里。有些人可能会说从可执行文件中导出一组可修补的函数,这样修补程序就可以在导出表中查找它们并找到它们的地址。这会起作用,但 a) 您必须手动导出所有可修补的函数,b) 如果您忘记导出需要修补的函数怎么办?
我的解决方案是利用 PDB 调试信息和 DIA SDK 来导出应用程序的所有函数,保存它们的地址,然后将这些信息作为资源包含在原始可执行文件中。为了使此方法生效,您需要在 Release 模式下也使用 /Zi
进行编译并使用 /DEBUG
进行链接,以便生成 PDB。
在当前的 VS2017 中,这只在 Release 构建中有效。Debug 构建具有非标准的 PDB,无法使用 DIA SDK 进行解析。
构建后配置
测试可执行文件构建完成后,将调用一个构建后事件。HOTPATCH::AutoPrepareExecutable()
将自身移动到另一个文件,将自身复制回旧文件,然后调用 HOTPATCH::PrepareExecutable()
,该函数将
- 加载 DIA SDK 和 PDB 文件
- 加载可执行文件以获取函数的虚拟地址。然后将函数的地址与可执行文件的加载地址一起保存到一个 XML
string
中。
HOTPATCH::PrepareExecutable
还接受一个编译单元列表。如果您传递一个空的初始化列表,将包含所有编译单元。例如,如果要仅包含 main.obj,请传递 {L"main.obj"}
。
然后,PostBuildPatch
调用 HOTPATCH::PatchExecutable()
,该函数仅使用 BeginUpdateResource()
、UpdateResource()
和 EndUpdateResource()
将 XML string
保存到 Test Executable 中。
如果您有多个可修补的模块,您将对所有模块重复此过程。
x86 函数修补
修补是通过以下方式实现的:
- 在函数入口点之前的 5 个字节 (链接器使用
/FUNCTIONMINPAD
创建的空间) 写入相对 32 位JMP
指令,该指令跳转到新函数的入口点。 - 将函数入口点的前两个字节替换为
0xF9 0xEB
,这是 "JMP $ - 5
" 的汇编代码 (这就是为什么函数需要至少 2 个字节可用),此操作是原子的。
JMP
地址必须相对于当前 EIP,因此我们将新函数入口点与旧函数入口点相减 - 5
。
要找到旧函数入口点,我们假设它与模块加载地址的距离始终相同。当构建后修补工具保存函数 RVA 时,它也保存了当前模块加载地址。现在我们可能有了新的模块加载地址,我们可以通过简单的减法重新计算函数 RVA。
x64 函数修补
修补是通过以下方式实现的:
- 在函数入口点之前的 12 个字节 (链接器使用
/FUNCTIONMINPAD
创建的空间) 写入以下内容:MOV RAX,XXXXXXXXXXXXXXXX
,其中XXXXXXXXXXXXXXXX
是新入口点的绝对跳转地址。JMP RAX
- 将函数入口点的前两个字节替换为
0xFw 0xEB
,这是 "JMP $ - 12
" 的汇编代码,此操作是原子的。
Microsoft 表示 x64 热更新只需要 6 个字节,但我不知道如何做到。如果您使用相对 JMP
就像 x86 一样,它只能接受 32 位参数,并且在新入口点比旧入口点远 (超过 31 位,因为第 32 位是符号扩展) 的情况下,您无法跳转。因此,我决定通过 JMP RAX
进行修补,它能够跳转到无条件的 64 位绝对地址。
RAX
寄存器被认为是易失性的,不用于函数参数传递。因此,使用它进行热更新是安全的。如果您需要更高的安全性,可以使用 JMP
[address],您可以将要跳转的地址存储在内存中,但这需要更多的填充字节。
Visual Studio 优化/行为
如果程序中的一个函数只被调用一次,那么它可能无法被修补。考虑这个例子
// Actual prototype
void foo(int x);
如果这个函数只被调用一次,比如 foo(5)
,那么编译器可能会优化函数,而不是将 5
传递给堆栈或寄存器,而是将该值硬编码在函数内部,从而将实际函数转换为如下:
// Compiled prototype
void foo()
{
int x = 5;
}
这意味着您提供的修补函数将不会与被修补的函数具有相同的签名,它会在堆栈/寄存器中查找 x
参数,然后就崩溃了。
另外一点。当使用代理调用函数时,必须为被调用者提供足够的堆栈空间,因为 VS 似乎需要额外的堆栈字节来完成工作。
方法 1:使用 DLL
无需执行任何操作。所有工作都由修补 DLL 完成,因此可执行文件可以不感知修补。
您只需要加载 DLL 并调用某个导出的函数来修补您的可执行文件。
HINSTANCE hL = LoadLibrary(L"..\\dllpatch\\dllpatch.dll");
HRESULT(__stdcall *patch)() = (HRESULT(__stdcall *)())GetProcAddress(hL,"Patch");
if (patch)
patch();
从修补 DLL 使用库
HOTPATCH hp;
hp.ApplyPatchFor(GetModuleHandle(0),L"FOO::PatchableFunction1",PatchableFunction1);
就是这样。您只需指定函数名称 (使用 DIA SDK
保存的名称相同) 和
替换,它必须具有相同的函数签名 (否则会崩溃)。
方法 2:使用 COM 服务器
好吧,现在这将会是一次疯狂的尝试。能否从自己的可执行文件中修补自己?
假设您有一个 APP.EXE,它从您的服务器下载一个新的 APP.EXE。但是您没有修补 DLL,而且您厌倦了编写一个。您能用自己来修补自己吗?
您必须使用进程间通信机制,即这个疯狂的操作序列:
- 将下载的应用程序注册为 COM 服务器
- 查询 COM 服务器以获取修补名称
- 自己修补函数
但因为修补函数不再存在于您自己的地址空间中,您必须使用一个“代理”进行修补,该代理将从当前调用中获取参数,保存所有这些参数,然后通过互操作传递给 COM 服务器。但因为参数也需要传递,所以这个新的“代理”必须是动态的 (即,每个修补程序都有一个新的),而不是您代码中的一个 static
函数。因此,必须动态创建一些(?)-汇编-代码 (使用 VirtualAlloc()
和 VirtualProtect()
),然后在此内存中构造一个可读、可写且可执行的 COMCALL
对象,然后将此内存保存所有寄存器 (x86 上为 8 个,x64 上为 16 个) 到每个修补程序唯一的内存区域,然后保存 IDispatch
* COM 服务器接口,然后通过 IDispatch::Invoke()
传递给远程服务器。
注册/注销自己为 COM 服务器
这很简单,您调用 HOTPATCH
::PrepareExecutableForCOMPatching
并提供一个临时 CLSID
和 PROGID
,然后 HOTPATCH
::RegisterCOMServer
和 HOTPATCH::Unregister
通过写入或删除 HKEY_CURRENT_USER
下的一些键来完成工作。您不再需要管理员权限,因为热更新接口不是一个新接口 (因此不需要注册),它仅仅是一个 IDispatch
。
注册修补名称
COM 服务器实现一个 IClassFactory
和一个 IDispatch
,后者通过 2 个额外的成员函数来完成工作。
程序调用 HOTPATCH::AckGetPatchNames
来获取要修补的函数名称。此函数内部通过 COM 互操作调用 COM 服务器的 IHotPatch::GetNames
。它返回一个 BSTR
,其中包含 COM 服务器准备好修补的空格分隔的函数名称。现在库是用纯 IDispatch
实现的,没有直接调用,而是间接调用 IDispatch::Invoke()
。
通过安装代理修补函数
当调用 HOTPATCH::ApplyCOMPatchFor
时,它会对函数进行修补,用动态创建的可执行缓冲区替换它,而不是目标函数 (它现在位于另一个地址空间)。
#pragma pack(push,1)
struct COMCALL
{
unsigned char jmp1 = 0xE9;
unsigned char jmp2 = 0x27;
unsigned char jmp3 = 0x01;
unsigned char jmp4 = 0x00;
unsigned char jmp5 = 0x00; // JMP $ + 300
void* HPPointer = 0;
void* HPClass = 0;
#ifdef _WIN64
char data[179];
#else
char data[187];
#endif
char name[100];
// Push pop to stack
#ifdef _WIN64
struct MOVER
{
unsigned char pushreg = 0;
unsigned char poprax = 0x58;
unsigned short movrax = 0xA348;
unsigned long long addr = 0;
};
#else
struct MOVER
{
unsigned char pushreg = 0x66;
unsigned char popeax = 0x58;
unsigned char moveax = 0xA3;
unsigned long addr = 0;
};
#endif
MOVER m1[8]; // base registers
#ifdef _WIN64
struct MOVER2
{
unsigned char pushregp = 0x41;
unsigned char pushreg = 0;
unsigned char poprax = 0x58;
unsigned short movrax = 0xA348;
unsigned long long addr = 0;
};
MOVER2 m2[8]; // r8-r15 registers
#endif
// Call the COMPatchGeneric
#ifdef _WIN64
#ifndef XCXCALL
unsigned short movd1 = 0xB848;
unsigned long long regaddr = 0;
unsigned short movd2x = 0xA348;
unsigned long long movd2 = 0;
#else
unsigned char pushrbp = 0x55;
unsigned char movrbprsp_1 = 0x48;
unsigned char movrbprsp_2 = 0x89;
unsigned char movrbprsp_3 = 0xe5;
unsigned short movrcx = 0xB948;
unsigned long long regaddr = 0;
unsigned short subrsp100_1 = 0x8148;
unsigned short subrsp100_2 = 0x00EC;
unsigned short subrsp100_3 = 0x0001;
unsigned char subrsp100_4 = 0;
#endif // XCXCALL
unsigned short movrax = 0xB848;
unsigned long long calladdr = 0;
unsigned short callrax = 0xD0FF;
#ifdef XCXCALL
// Restore the stack
unsigned short addrsp100_1 = 0x8148;
unsigned short addrsp100_2 = 0x00C4;
unsigned short addrsp100_3 = 0x0001;
unsigned char addrsp100_4 = 0;
#endif
unsigned char poprbp = 0x5d;
unsigned char ret = 0xC3;
#else
// Call the COMPatchGeneric
#ifndef XCXCALL
unsigned short movd1 = 0x05C7;
unsigned long movd2 = 0;
unsigned long regaddr = 0;
#else
unsigned char pushebp = 0x55;
unsigned char movebpesp_1 = 0x89;
unsigned char movebpesp_2 = 0xe5;
unsigned char movecx = 0xB9;
unsigned long regaddr = 0;
// Give the callee some stack to work with
unsigned short subesp100_1 = 0xEC81;
unsigned short subesp100_2 = 0x0100;
unsigned short subesp100_3 = 0x0000;
#endif
unsigned char moveax = 0xB8;
unsigned long calladdr = 0;
unsigned short calleax = 0xD0FF;
#ifdef XCXCALL
// Restore the stack
unsigned short addesp100_1 = 0xC481;
unsigned short addesp100_2 = 0x0100;
unsigned short addesp100_3 = 0x0000;
#endif
unsigned char popebp = 0x5d;
unsigned char ret = 0xC3;
#endif // WIN64
COMCALL(IHotPatch*dispp,HOTPATCH* hpx,size_t targetcall,const wchar_t* fname)
{
HPPointer = dispp;
HPClass = hpx;
calladdr = targetcall;
regaddr = (size_t)this;
#ifndef XCXCALL
movd2 = (size_t)&COMCALPTR;
#endif
for (int i = 0; i < 8; i++)
{
m1[i].pushreg = (unsigned char)(0x50 + i);
m1[i].addr = (size_t)(data + i * sizeof(size_t));
}
#ifdef _WIN64
for (int i = 0; i < 8; i++)
{
m2[i].pushreg = (unsigned char)(0x50 + i);
m2[i].addr = (unsigned long long)(data + (i + 8) * 8);
}
#endif
size_t le = wcslen(fname);
if (le > 50)
le = 50;
memcpy(name,fname,le * 2);
}
};
#pragma pack(pop)
前 5 个字节是 JMP $ + 300
,这为保存数据留出了空间。在 data[]
和 name[]
中,通过执行 "MOVER
" 结构 8 次 (即 PUSH EXX, POP EAX, MOV [address], EAX
) 来设置寄存器的当前值,其中 EXX
和地址由 COMCALL32
结构在运行时构造。
最后,有一个序列将 IHotPatch*
指针的地址保存到 ECX
,然后编码 MOV EAX, COMPatchGeneric, CALL EAX, RET
。
在调用 COMPatchGeneric
/ USMPatchGeneric
之前,会添加和删除一些堆栈。
COMPatchGeneric
是另一个 C++ 函数,它从 ECX/RCX
读取结构地址,然后从 COMCALL32
结构中读取所有必需的数据,创建适当的 BSTR
和 SAFEARRAY
COM 变量以将它们传递给 COM 服务器,最后通过 IHotPatch::Call
将它们传递。
对于 x64,情况类似,只是需要保存 16 个寄存器 (r9-r15
也包括在内)。COMCALL
结构中的代理字节大小也会改变。添加 push rbp/pop rbp 解决了旧的 x64 崩溃问题。
方法 3:使用共享内存
您以为我讲完了?哈哈。没那么快。我计划使用我的 USM 来做同样的过程,但不需要 COM。减少注册表项,最重要的是,减少有问题的 COM 代理。非常直接。
使用共享内存的机制与 COM 模式类似。您创建一个共享内存区域,最初用于请求修补名称。然后手动运行修补程序,并使其 (例如,通过命令行选项) 也打开相同的共享内存区域并返回名称。这在 HOTPATCH::StartUSMServer
中实现。
修补程序不是将数据传递给 COM,而是使用 mutual
类 (在 USM 中找到) 来“调用”远程函数。这种方法的优点是它不需要注册,但它需要额外的努力来从修补程序发送/接收数据。
方法 4:自 EXE 作为 DLL
当然,如您所知,您不能 LoadLibrary
一个 EXE 文件,因为即使它是可重定位的,CRT
也不是相同的。即使您加载了 EXE 并通过 GetProcAddress
获得了指针,调用该函数很可能会导致崩溃,因为没有发生正确的初始化。
实际上,您可以 LoadLibrary
一个 EXE,并可以在您的 EXE 的相同地址空间中准备好它。这些方法的机制将在我关于 使用 EXE 作为 DLL 的文章中解释。
但是,强烈建议不要使用此方法,因为它非常粗糙且不可靠。
方法 5:自 DLL
此方法要求您的最终应用程序本身就是一个 DLL。因此,方法 4 中的问题不再存在,但您必须有一个引导程序来启动您的应用程序 (例如,通过调用 rundll32
)。
待办事项
我还需要找到一种方法来排除 DIA SDK 中的内容。我放置了一些 string
搜索,如 std::
, ATL::
等,以排除 Microsoft 的内容。如果有人知道如何仅包含您源代码中找到的函数,请告诉我。
最后的几句话...
正如我所说,我的客户现在正在使用这个库,这样他们就永远不会退出他们如此喜爱的游戏。然而,我的女朋友也卡在电脑屏幕前,而且由于游戏在打补丁后不需要重启,她再也不会为我做饭了。
因此,如果您或您的公司发现此库有用,请告诉您的老板。他可能会雇佣我,这样我就能买得起食物了。:)
祝您好运!
历史
- 2018-10-23: 代码清理并添加了第 5 种方法。VS 2017 更新
- 2016-08-28: 增强了库,COM 不再需要管理员权限,接口已清理
- 2015-11-01: 添加了另一种方法,通过将 EXE 作为 DLL 来修补 EXE - 关于此的 artikkel即将发布