65.9K
CodeProject 正在变化。 阅读更多。
Home

热补丁:非常)深入

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.92/5 (68投票s)

2015年10月28日

CPOL

13分钟阅读

viewsIcon

69324

downloadIcon

533

一个可用的热补丁库,有五种方法!

引言

我开发了一款游戏,其吸引力如此之大,以至于我无法允许用户在更新时退出游戏。
我已经因此面临几起诉讼,而且,除非您现在投票给我,否则我将无法支付我的律师费。

当然,我并不太在乎。他并不在乎钱,因为他不会停止玩我的游戏。但为了满足所有人的要求,我必须在 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,edimod 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 并提供一个临时 CLSIDPROGID,然后 HOTPATCH::RegisterCOMServerHOTPATCH::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 结构中读取所有必需的数据,创建适当的 BSTRSAFEARRAY 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即将发布
© . All rights reserved.