三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

UE4逆向实战:定位GObjects与Hook PostRender实现游戏内部绘制

UE4逆向实战:定位GObjects与Hook PostRender实现游戏内部绘制

1. 项目概述:为什么我们要深挖GObjects与PostRender

如果你正在研究UE4游戏的逆向,无论是为了安全分析、外挂检测,还是纯粹的技术探索,那么“GObjects”和“PostRender”这两个词对你来说一定不陌生。它们就像是UE4引擎内部世界的两个核心地标:一个掌管着游戏里所有对象的“户口本”(GObjects),另一个则是决定每一帧画面最终如何呈现在屏幕上的“总导演”(PostRender)。直接从《黎明杀机》这类成熟的UE4游戏实战切入,去逆向定位这两个关键结构,远比对着空荡荡的引擎源码或者简单的Demo程序要来得真实和复杂。实战中,你会遇到代码混淆、虚函数表(VTable)动态变化、不同引擎版本间的偏移差异等一系列“坑”,而这些恰恰是通用教程里很少涉及的。

我之所以选择《黎明杀机》作为分析样本,是因为它是一款持续更新、反作弊措施相对完善的商业游戏,其逆向过程极具代表性。通过拆解在这款游戏中定位GObjects数组和Hook PostRender渲染函数的完整逻辑,我们不仅能掌握一套方法论,更能积累大量针对性的避坑经验。这些经验可以直接迁移到其他基于UE4(乃至UE5)的游戏中。简单来说,搞定了这两个点,你就拿到了深入UE4游戏内部世界的“钥匙”,无论是想实现内部绘制、信息透传,还是分析游戏逻辑,都有了坚实的基础。

2. 核心思路与方案选型:静态分析与动态调试的结合

面对一个像《黎明杀机》这样的大型商业游戏,直接无脑下断点或者漫无目的地搜索字符串,效率极低且容易被反调试机制察觉。一个高效的逆向流程,必然是静态分析与动态调试紧密结合的。我们的核心思路可以概括为“由静到动,交叉验证”。

2.1 静态分析先行:定位特征与模式

在启动游戏、挂上调试器之前,我们应该先用IDA Pro、Ghidra等静态分析工具,对游戏的主二进制文件(通常是DeadByDaylight-Win64-Shipping.exe)进行初步的扫描和分析。这个阶段的目标不是理解所有代码,而是寻找与GObjects和PostRender相关的“特征码”或固定模式。

对于GObjects,在UE4中,它通常是一个名为FUObjectArray的全局实例,其内部包含一个TUObjectArray类型的成员ObjObjects,这个TUObjectArray内部又有一个指向UObject*数组的指针。在汇编层面,访问GObjects的代码模式往往非常固定:可能会通过一个固定的偏移访问某个全局变量,或者通过lea指令加载一个绝对地址。我们可以利用已知的UE4 SDK头文件信息,或者分析一些开源项目的代码(需注意法律风险),来总结出访问GObjects的常见指令序列。例如,在某些版本中,你可能会看到类似mov rax, [7FFE12345678h]然后mov rax, [rax+0x30]这样的模式来最终获取到对象数组指针。

对于PostRender,它是UGameViewportClient类的一个虚函数(virtual void PostRender(UCanvas* Canvas))。我们的目标是找到这个虚函数在虚表中的索引,以便后续Hook。静态分析时,我们可以搜索对UGameViewportClientDrawLayout等相关函数的交叉引用,逐步逼近PostRender。更直接的方法是,寻找与绘制文本、矩形等2D元素相关的函数调用(如DrawText,DrawRect),这些函数调用很可能就发生在PostRender的内部或附近。通过分析这些调用栈的上文,可以定位到PostRender函数体。

注意:静态分析找到的地址和偏移都是“疑似”的。因为游戏可能使用了基址重定位(ASLR),并且链接器优化可能导致代码布局与SDK不同。静态分析的结果必须经过动态调试的验证。

2.2 动态调试验证:在运行时捕捉真相

动态调试是我们验证猜想、获取准确地址的最终手段。常用的工具有x64dbg、Cheat Engine(带有调试器功能)等。动态调试的关键在于“断点”和“观察”。

  • 验证GObjects:我们可以在静态分析找到的疑似访问GObjects的代码处下断点。当游戏运行时,断点命中后,观察相关寄存器的值。通过寄存器,我们可以一步步追溯,最终找到一个指向大片内存地址的指针,这些地址通常以规律的方式排列,每个地址指向一个UObject(其虚表指针通常指向引擎模块内的地址)。我们可以手动解析几个对象,查看其类名(UObject::GetFullName的实现逻辑),如果能够正确解析出World,PlayerController,Actor等熟悉的类名,那么基本可以确定我们找到了正确的GObjects地址。
  • 定位PostRender虚表索引:这个过程稍微复杂一些。一种常见的方法是:
    1. 首先找到UGameViewportClient类的实例。这可以通过搜索字符串引用(如视口标题)、或通过GObjects遍历所有UGameViewportClient对象来获得。
    2. 在调试器中,查看该实例的内存。其第一个8字节(x64下)就是虚表指针(vtable pointer)。
    3. 跟随这个虚表指针,会进入一个函数指针数组。我们需要在这个数组中定位到PostRender函数。
    4. 如何确定哪个是PostRender?我们可以对虚表中的每个函数地址下执行断点,然后触发一次游戏渲染(比如移动一下视角)。哪个断点在渲染帧中被频繁调用,并且其栈回溯或函数内部调用了UCanvas相关的绘制函数,哪个就极有可能是PostRender。通常,它的索引是相对固定的(例如第0x6A个),但绝对不能假设,必须动态验证。

2.3 方案优势与工具选型考量

这种“静动结合”的方案优势在于:

  • 可靠性高:静态分析提供线索,动态调试给出铁证,交叉验证确保结果准确。
  • 隐蔽性相对较好:静态分析阶段无需触动游戏进程。动态调试时,通过精心设置的一次性断点(如条件断点)来获取关键信息,然后立即移除,可以减少被检测的风险。
  • 可复用性强:总结出的特征码和查找模式,经过调整后可以用于其他UE4游戏。

在工具选型上,我个人的习惯是:

  • 静态分析IDA Pro是首选,其强大的反编译器和插件生态(如Hex-Rays Decompiler)能极大提升分析效率。Ghidra作为免费替代品,功能也非常强大,尤其在模式搜索方面。
  • 动态调试x64dbg在Windows平台下对PE文件的支持非常出色,条件断点、内存视图、脚本等功能完备。Cheat Engine的指针扫描和内存查看工具对于探索未知数据结构有时有奇效,可以辅助使用。
  • 辅助工具ReClass.NETCETrainer的类生成器,对于将内存数据逆向成C++结构体至关重要,尤其是在分析UObjectAActor派生类的成员时。

3. GObjects查找逻辑的深度拆解与实操

GObjects是UE4对象系统的核心,它管理着所有UObject派生类实例的全局列表。找到它,就意味着你能枚举游戏中的所有实体、角色、控制器、管理器等。

3.1 通过“字符串引用”定位的经典方法及其演变

最经典、最广为人知的方法是搜索字符串“%s%s%s”的引用。在UE4的UObject::GetFullName()函数内部,会使用这个格式化字符串来拼接对象的完整名称。在早期版本的引擎中,这个字符串引用所在的函数,距离访问GUObjectArray(GObjects的另一个常见名称)的代码非常近。

实操步骤:

  1. 在IDA Pro中,按下Shift+F12打开字符串窗口。
  2. 搜索字符串“%s%s%s”。通常能找到多个,选择位于游戏主模块或核心引擎模块(如UE4-...)中的那个。
  3. 双击跳转到该字符串所在地址,然后使用Ctrl+X查看有哪些代码引用了它。
  4. 通常你会找到一个或多个函数。进入这些函数,查看其反汇编或反编译代码。
  5. 在函数开头部分,寻找对某个全局变量或通过固定偏移获取的指针的访问。例如,你可能会看到:
    .text:0000000140ABCDEF mov rax, cs:qword_1412345678 ; 一个全局变量 .text:0000000140ABCDF6 mov rax, [rax+30h] ; 获取TUObjectArray* .text:0000000140ABCDFA mov rcx, [rax+10h] ; 获取UObject** 数组指针
    这里的rcx最终指向的就是GObjects数组的起始位置。

避坑点:

  • 字符串混淆:现代游戏或经过保护的游戏可能会混淆字符串。%s%s%s可能被拆散、加密或动态生成。此时,这个方法会失效。
  • 内联优化GetFullName函数可能被内联到多个地方,导致字符串引用分散,不易追溯到最根源的GObjects访问点。
  • 版本差异:不同UE4版本,FUObjectArray的内部结构和访问路径可能有细微差别。不能完全照搬偏移量。

3.2 通过“TUObjectArray”结构特征进行扫描

当字符串方法失效时,我们可以回归本质,直接搜索TUObjectArray在内存中的结构特征。根据UE4源码,TUObjectArray通常包含以下关键字段:

  • UObject** Objects;(指向对象指针数组的指针)
  • int32 MaxElements;(数组最大容量)
  • int32 NumElements;(当前对象数量)

我们可以利用Cheat Engine的内存扫描功能,尝试寻找符合以下特征的内存区域:

  1. 一个指针(Objects),指向一个巨大的、存放着许多指针的内存区域。
  2. 紧接着这个指针的4字节(MaxElements)是一个较大的数(如几十万)。
  3. 再紧接着的4字节(NumElements)是一个比MaxElements小,但也在万级别的数。

通过多次扫描和过滤,可以逐步缩小范围。找到疑似地址后,需要像侦探一样进行验证:读取该地址作为指针,解引用后查看它指向的数组。选取数组中的几个指针,在内存中查看它们指向的数据。一个合法的UObject,其前8字节是虚表指针,应指向引擎模块内的合法地址;并且在其固定偏移处(如UObjectNamePrivateClassPrivate成员)应该有看起来合理的字符串或指针数据。

3.3 实战验证与稳定性获取

无论通过哪种方法找到疑似地址,验证环节必不可少,且必须动态进行

  1. 基础验证:在调试器中,通过疑似地址遍历前几十个对象。对每个对象的虚表指针,检查其是否位于游戏主模块或已知的引擎模块内存范围内。如果大量虚表指针指向无效或奇怪的地址,那很可能找错了。
  2. 功能验证:尝试解析对象的名称。你需要知道FNameFString在内存中的布局。简单来说,FName通常是一个索引值。你可以写一小段脚本或手动计算,尝试打印出对象的类名。如果你能稳定地看到World,PlayerController,Character,StaticMeshActor等类名,恭喜你,成功了。
  3. 获取稳定指针:直接使用找到的静态地址是不可靠的,因为ASLR每次运行都会改变模块基址。我们需要找到指向GObjects的相对稳定的指针链。通常,在游戏主模块的.data或.rdata段,会有一个全局变量存储着FUObjectArray的地址。我们的目标就是找到这个全局变量的地址。然后,我们通过游戏模块基址 + 全局变量偏移的方式来访问它。这个偏移量在游戏版本不变的情况下是固定的。
    • 在调试器中,对你找到的GObjects访问指令(如mov rax, [7FFE12345678h])中的那个绝对地址(7FFE12345678)下硬件访问断点。
    • 重新运行游戏,当断点触发时,查看是谁写入了这个地址。这通常是在游戏初始化阶段,某个函数将FUObjectArray的地址存储到了这里。这个存储指令所在的地址,减去游戏模块的当前基址,就是我们要的静态偏移

实操心得:在《黎明杀机》这类游戏中,我通常会将几种方法结合使用。先用字符串法快速尝试,如果不行,立刻转向结构特征扫描。验证时,不要只看一两个对象,至少遍历上百个,并关注那些在游戏逻辑中活跃的对象(如本地玩家角色),它们的类名和属性更容易辨认。获取到的静态偏移一定要记录下来,这是你编写外部工具(如DLL注入、外部读写)的基础。

4. PostRender Hook的定位与实现陷阱

成功Hook PostRender是实现内部绘制(ESP、方框、射线等)的关键一步。目标是将其替换为我们自己的函数,在游戏渲染完3D场景后、呈现2D UI前,插入我们的绘制代码。

4.1 定位UGameViewportClient实例

要Hook PostRender,首先得找到UGameViewportClient对象。这里有几个途径:

  1. 通过GObjects遍历:这是我们拿到GObjects后的第一个应用。遍历所有对象,检查每个对象的ClassPrivate成员。UClass本身也是一个UObject,其NamePrivate就是类名。我们可以通过比较类名(FName)或直接比较UClass*指针来筛选出所有UGameViewportClient实例。在单进程游戏里,通常只有一个活跃的实例。
  2. 通过UWorld获取UWorld对象中通常包含一个GameViewport成员。如果你已经通过其他方式(例如通过本地玩家PlayerController回溯)找到了UWorld,那么通过其结构体偏移就能获得UGameViewportClient*
  3. 通过静态变量或全局访问器:某些游戏可能提供了获取全局GameViewport的函数或变量。可以在IDA中搜索字符串“Viewport”或“GameViewport”的交叉引用,寻找可疑的全局函数。

4.2 确定PostRender虚函数索引

找到实例后,其内存起始地址就是虚表指针(__vfptr)。我们需要在虚表中定位PostRender

  1. 动态调用追踪法(最可靠)

    • 在调试器中,给找到的UGameViewportClient实例的虚表指针指向的内存区域(即整个虚函数表)设置内存访问断点(执行)。
    • 然后正常游戏,触发渲染(比如转动视角)。断点会频繁触发。
    • 记录下触发断点的函数地址。通过多次触发,你会发现其中一个函数被调用的时机非常规律,每帧一次,且调用栈的上一层通常是引擎的渲染循环。
    • 进入这个函数,查看其反汇编。如果在其内部发现了对UCanvas::DrawTextDrawRectDrawLine等函数的调用,或者其参数中包含UCanvas* Canvas,那么这几乎可以确定就是PostRender
    • 计算这个函数地址在虚表中的索引:(函数地址 - 虚表起始地址) / sizeof(void*)
  2. 特征码搜索法

    • 分析已知的PostRender函数反编译代码。它通常以push rbp; mov rbp, rsp这样的序言开始,并且函数内部会有特定的指令模式,比如对Canvas->Color的赋值,或者调用特定的绘制函数。
    • 在游戏模块中搜索这些指令序列,可以找到PostRender的函数体。然后,你需要反向查找哪些虚表引用了这个函数体地址,从而确定索引。这个方法对静态分析能力要求较高。

《黎明杀机》中的特殊点:像《黎明杀机》这样的游戏,其PostRender函数内部可能已经包含了一些游戏本身的UI绘制逻辑(如血点、状态效果图标)。在逆向时,这反而是好事,因为这些独特的绘制调用可以作为我们定位函数的“指纹”。

4.3 Hook实现与VTable替换的注意事项

找到索引后,Hook本身在技术上很简单:替换虚表中对应索引的指针即可。但这里有巨大的陷阱。

  1. 虚表指针的指向UGameViewportClient实例的虚表指针,指向的是一张虚函数表。这张表通常位于引擎模块的只读数据段(.rdata)。你不能直接修改.rdata段的内存,因为它是只读的。尝试修改会导致访问违规。
  2. 正确的Hook方法:你需要复制整张虚表到可读写内存(例如通过VirtualAlloc分配),然后修改复制品中PostRender对应的条目为你自己的函数地址,最后将UGameViewportClient实例的虚表指针指向你复制的新表。
    // 伪代码示例 void** original_vtable = *(void***)viewport_client_instance; size_t vtable_size = EstimateVTableSize(original_vtable); // 需要估算虚表大小 void** new_vtable = (void**)VirtualAlloc(NULL, vtable_size, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); memcpy(new_vtable, original_vtable, vtable_size); new_vtable[postrender_index] = &MyPostRenderHook; *(void***)viewport_client_instance = new_vtable;
  3. 估算虚表大小:这是一个难点。你不能盲目拷贝一大片内存。通常有两种方法:
    • 保守估算:遍历虚表,直到遇到一个nullptr函数指针(但这不一定可靠,有些编译器不会在末尾放空指针)。
    • 通过RTTI信息(如果可用):如果游戏开启了RTTI,可以通过type_info结构获取类信息,进而得知虚函数数量。但很多游戏会禁用RTTI。
    • 经验值:对于UGameViewportClient,其虚函数数量通常在一个相对稳定的范围内(比如100-200个)。你可以取一个足够大的安全值(如256个指针),并确保你分配的内存页面不会越界访问到非法区域。
  4. 调用原函数:在你的MyPostRenderHook中,通常需要调用原PostRender函数,以确保游戏原有的UI绘制不被破坏。你需要保存原始的函数指针,并在你的Hook函数中适当位置调用它。
    typedef void (__thiscall* tPostRender)(void* thisptr, void* Canvas); tPostRender oPostRender = (tPostRender)original_vtable[postrender_index]; void __fastcall MyPostRenderHook(void* thisptr, void* Canvas) { // 你的绘制代码... DrawESP(Canvas); // 调用原函数 oPostRender(thisptr, Canvas); }

    重要提示__thiscall是x86的调用约定。在x64环境下,this指针通过rcx寄存器传递,参数通过寄存器传递,不存在__thiscall。你需要使用正确的函数签名,通常可以声明为void HookedPostRender(void* thisptr, void* Canvas),并在汇编层面处理调用。

5. 逆向过程中的常见问题与实战排查记录

即使思路清晰,实战中依然会踩无数的坑。下面记录几个在《黎明杀机》及类似UE4游戏逆向中最常见的问题和解决方法。

5.1 偏移失效与版本更新应对

这是最头疼的问题。游戏每次更新,引擎模块或游戏模块的基址、内部结构的偏移都可能发生变化。

  • 症状:之前能正常工作的代码,更新后无法找到对象、游戏崩溃或绘制错乱。
  • 排查
    1. 验证基址:首先检查你使用的模块基址是否正确。使用GetModuleHandle获取的基址是可靠的。
    2. 验证GObjects指针:用调试器手动走一遍查找GObjects的流程,确认静态偏移是否还指向正确的全局变量,以及该变量指向的地址是否还是有效的对象数组。
    3. 验证类成员偏移UObject内部的ClassPrivateNamePrivate等成员的偏移可能改变。你需要重新分析UObject的内存布局。可以通过在GObjects中找到一个已知类的对象(比如World),然后在其内存附近搜索指向其类名字符串的指针,来反推偏移。
  • 应对策略
    • 特征码扫描:不要硬编码偏移,而是编写特征码扫描函数。例如,扫描访问GObjects的那几条特定指令序列。即使指令地址变了,指令本身的字节模式(特征码)在同一个版本的游戏内是相对稳定的。
    • 指针扫描链:对于多层指针寻址(如[[base + offsetA] + offsetB]),存储每一级的偏移,而不是最终地址。并设计验证逻辑,确保每一级指针都是有效的。
    • 版本检测:在代码中集成简单的版本检测(如检查游戏主文件哈希值或特定地址的字节),为不同版本准备不同的偏移配置。

5.2 反调试与反作弊干扰

《黎明杀机》使用EasyAntiCheat(EAC)。其他游戏可能用BattlEye或自定义方案。

  • 症状:调试器被检测并导致游戏关闭;游戏进程自身崩溃;内存访问异常。
  • 常见反调试手段
    • IsDebuggerPresent,CheckRemoteDebuggerPresent:基础API检查。
    • NtQueryInformationProcess:查询ProcessDebugPort等标志。
    • 硬件断点检测:通过CONTEXT结构检查Dr0-Dr7调试寄存器。
    • 内存完整性校验:对代码段或关键数据(如虚表)进行CRC校验,防止被Hook。
    • 定时器检测:检测代码执行时间是否异常(被断点暂停)。
  • 规避思路(仅供学习研究)
    • 隐藏调试器:使用插件或配置使调试器对特定检测手段“隐形”。(注意:与反作弊对抗存在法律和封号风险)。
    • 内核模式调试:使用更底层的调试方式,但门槛高且仍可能被反作弊内核驱动检测。
    • 无调试器分析:尽可能依赖静态分析和运行时日志(OutputDebugString、文件日志)来获取信息,减少动态调试时间。
    • 在安全环境测试:在单机版、私服或明确允许模组/调试的版本中进行逆向分析。

5.3 绘制异常与性能问题

成功Hook并绘制后,可能会遇到画面闪烁、元素错位、性能下降等问题。

  • 画面闪烁:通常是因为你的绘制顺序或时机不对。确保你的绘制调用发生在PostRender调用原函数之前。有些游戏UI是分层的,你可能需要找到更合适的Hook点(如UWindow的绘制函数)。
  • 元素错位:屏幕坐标计算错误。UE4的屏幕坐标原点可能在左上角或左下角,且UCanvas的坐标系可能与世界坐标系转换有关。确保你正确使用了ProjectWorldLocationToScreen这类函数(需要获取PlayerControllerLocalPlayer)来将3D世界坐标转换为2D屏幕坐标。
  • 性能下降:每帧遍历所有Actor并计算绘制信息是非常耗时的。优化方法包括:
    • 距离裁剪:只计算和绘制屏幕附近或一定范围内的对象。
    • 分帧处理:不要在一帧内处理所有对象,可以将遍历任务分摊到多帧完成。
    • 缓存信息:对于位置、血量等不每帧剧烈变化的信息,可以缓存几帧,避免重复计算。
    • 简化绘制:减少绘制调用的数量,合并绘制指令。

5.4 虚表Hook导致崩溃的深层原因

替换虚表后游戏崩溃,除了前面提到的虚表大小估算错误,还有以下可能:

  1. 调用约定不匹配:这是x64环境下最常见的原因。你的Hook函数必须与原函数使用完全相同的调用约定、参数和返回值。在x64中,this指针通过rcx传递,第一个参数通过rdx传递,以此类推。你需要用__fastcall或正确的裸函数声明来确保寄存器被正确保存和恢复。一个微小的错误就会导致栈不平衡或寄存器污染,进而崩溃。
  2. 函数原型错误PostRender的原型是virtual void PostRender(UCanvas* Canvas)。但有时,编译器可能会因为一些优化(如this指针调整)生成略微不同的符号。最保险的方法是,在调试器中查看原函数的反汇编,看它在序言后是如何访问Canvas参数的,依此来调整你的Hook函数原型。
  3. 多线程竞争:渲染可能发生在多线程环境中。如果你在Hook函数中访问了未加锁的共享数据,可能导致数据竞争和崩溃。确保你的数据访问是线程安全的,或者将数据收集工作放在另一个线程,Hook函数只负责读取和绘制。

逆向工程是一个不断与不确定性斗争的过程。对于UE4游戏,尤其是像《黎明杀机》这样持续维护的游戏,没有一劳永逸的解决方案。核心在于掌握一套系统的分析方法:从静态特征识别到动态验证,从结构理解到稳定方案提取,最后通过精心设计的Hook和健壮的代码将其实现。每一次失败和崩溃,都是加深对引擎和系统理解的机会。记住,耐心和细致的观察力,是比任何工具都更重要的资产。

← 返回列表