1. 项目概述与核心价值
最近在分析一款游戏时,遇到了一个挺有意思的挑战:需要动态解析游戏引擎中定义的枚举类型(UEnum)。这可不是简单的内存扫描找字符串,而是要从游戏运行时内存里,把引擎内部用于描述枚举的那个复杂数据结构给完整地“扒”出来。这个结构里包含了枚举的名字、它的成员列表、每个成员的名字和对应的整数值,甚至还有一些引擎特有的元数据。对于做游戏逆向、外挂开发或者内存修改器(CE/OD脚本)的朋友来说,如果能稳定地获取到这些信息,就意味着你可以写更通用、更健壮的脚本,而不是每次游戏更新都要重新找偏移。比如,你想写一个自动切换角色状态的功能,如果能把游戏里那个ECharacterState枚举的所有状态值都读出来,你的代码就能自动适应游戏版本,而不是写死一堆“0=站立,1=奔跑,2=跳跃”这样的魔法数字。
这个项目,我称之为“分析UEnum结构”,核心目标就是定位并解析游戏内存中UEnum对象的完整布局。这不仅仅是找到几个地址那么简单,它涉及到对游戏引擎内存管理机制的理解、对C++虚函数表和RTTI(运行时类型信息)的运用,以及对特定引擎版本数据结构的逆向。整个过程就像是在一个庞大的、没有地图的迷宫里,根据一些已知的线索(比如字符串、虚函数地址)去推理出整个房间的构造。下面,我就把自己趟过的路、踩过的坑,以及最终稳定可用的方法,详细地拆解一遍。无论你是刚接触游戏逆向的新手,还是想深化对引擎内部机制理解的老手,相信这篇内容都能给你带来直接的帮助。
2. 逆向分析的核心思路与前置知识
2.1 为什么是UEnum?引擎数据结构的价值
在像Unreal Engine(虚幻引擎)这类大型游戏引擎中,几乎所有的游戏逻辑对象都继承自一个庞大的类层次结构。UEnum是这个结构中的一个特定类,专门用于在运行时表示枚举类型。引擎在编译时会将代码中定义的枚举(比如UENUM(BlueprintType) enum class EMyEnum : uint8)生成对应的UEnum对象,并放入引擎的全局对象表(GUObjectArray)中。这个对象不仅仅是一个名字列表,它内部关联着一个TArray<TPair<FName, int64>>,存储了每个枚举项的名字和值,还有其父类、标志位(Flags)、序列化信息等。
从逆向工程的角度看,解析UEnum有极高的实用价值:
- 动态适配:游戏更新后,枚举值的顺序或新增项可能导致旧的硬编码偏移失效。动态解析可以避免这个问题。
- 自动化脚本:可以编写脚本自动遍历所有枚举,生成对应的Lua接口、CE表格或调试信息,极大提升效率。
- 理解游戏逻辑:通过枚举名可以反推游戏状态机、技能类型、物品分类等核心逻辑,是进行深度分析的基础。
2.2 核心思路:从已知到未知的推导链
我们的目标是在游戏进程的内存空间中,找到一个UEnum对象实例,并解读其内存布局。由于我们没有引擎的源代码(或者版本不匹配),不能直接引用SDK头文件。因此,核心思路是构建一条“推导链”:
- 定位起点(Anchor):首先需要在内存中找到一个“确凿无疑”的
UEnum实例。最可靠的方法是通过其包含的字符串。例如,游戏里肯定有一个叫ECharacterState或EWeaponType的枚举,我们可以先在内存中搜索这些已知的枚举类型名称字符串(FName或FString的实例)。 - 识别对象头:在找到字符串附近,我们需要识别出UE引擎中所有
UObject共有的对象头结构。这通常包括指向虚函数表(vftable)的指针、内部索引(InternalIndex)、对象标志(ObjectFlags)等。虚函数表的地址是识别对象类型的关键指纹。 - 验证与推导:通过验证该虚函数表是否与
UEnum类的预期行为相符(例如,调用GetName()或GetFullName()函数),来确认我们找到的确实是一个UEnum对象。然后,以此为样本,分析其内存偏移,找出存储枚举项列表(Names)的成员变量位置。 - 提取与解析:最后,按照分析出的内存布局,编写代码读取
Names数组,得到每个枚举项的名字和值。
这个过程中,最考验人的是对内存的“阅读”能力和对引擎共性的把握。下面,我们就进入具体的实操环节。
3. 实操环境准备与工具链选择
3.1 目标环境与工具
- 目标游戏:基于Unreal Engine 4.27(不同版本偏移有差异,但思路通用)。
- 调试器:x64dbg 或 IDA Pro。x64dbg更适合动态跟踪和内存扫描,IDA Pro更适合静态分析和结构体定义。
- 内存查看/编辑:Cheat Engine (CE)。它的内存扫描和结构体分析功能无比强大,是我们前期探索的“眼睛”。
- 编程语言:C++ 配合 Windows API。用于编写最终的内存读取DLL或独立程序。Python(配合
pymem或keystone)也可用于快速原型验证。 - 必备知识:基本的x64汇编、C++类内存布局(特别是带有虚函数的类)、指针操作。
3.2 第一步:在内存中“锚定”目标枚举
假设我们知道游戏里有一个管理UI状态的枚举叫EUIState。我们的第一件事就是找到这个字符串在内存中的位置。
- 使用Cheat Engine附加游戏进程。
- 在CE中点击“内存查看”按钮,打开内存浏览器。
- 在内存浏览器中,右键 ->
搜索->字符串。 - 在字符串搜索框中输入
EUIState,编码选择UTF-8。点击“搜索”。 - CE会列出所有包含该字符串的内存地址。这里会有很多结果,因为字符串可能被多次引用。我们需要找到的很可能是作为
FName持久化部分存储的那个实例,它通常位于游戏的只读数据段(.rdata)或特定的名称表区域。一个技巧是观察地址范围,通常大地址(如0x7FFxxxxx)属于系统模块,小地址(如0x140xxxxx)属于游戏主模块。游戏自身的字符串一般在主模块地址范围内。 - 记录下这个字符串的地址,例如
0x1423A8B00。这个地址就是我们分析的起点。
注意:现代游戏引擎的字符串可能使用
FName池,它内部是哈希表,存储的是字符串的索引而非完整字符串本身。直接搜索字符串可能找不到UEnum对象内部直接引用的那个FName。更可靠的方法是先找到引用这个字符串的代码或数据指针。一个变通方法是搜索枚举项的名字(如EUIState::MainMenu),有时更容易定位到相关的数据数组。
4. 深入解析:定位并逆向UEnum内存结构
4.1 从字符串到对象指针
找到字符串地址0x1423A8B00后,我们需要在它附近寻找可能的结构化数据。一个UEnum对象在内存中大致布局如下(简化):
+-----------------------+ | 虚函数表指针 (vftable)* | +-----------------------+ | 内部索引 (InternalIndex)| +-----------------------+ | 对象标志 (ObjectFlags) | +-----------------------+ | 私有数据 (Private*) | +-----------------------+ | ... 其他UObject成员 ... | +-----------------------+ | UEnum特有成员开始 | +-----------------------+ | TArray<TPair<...>> Names | | (数组指针) | | (数组大小) | | (数组容量) | +-----------------------+ | ... 其他UEnum成员 ... | +-----------------------+我们需要找到这个对象的起始地址,也就是虚函数表指针所在的位置。在内存浏览器中,从字符串地址0x1423A8B00开始,向前(减小地址)查看内存数据。我们寻找一个看起来像是指针的值(在x64下是8字节),并且这个指针指向的地址位于游戏主模块的代码段(.text段,通常可执行)。这个指针很可能就是虚表指针。
假设我们在0x1423A8AF0处看到了一个值0x140123456。在内存浏览器中,跳转到0x140123456。如果这个地址附近是一系列函数代码(可以看到汇编指令),那么0x1423A8AF0就很有可能是某个对象的起始地址。我们暂且把这个地址0x1423A8AF0记为pPotentialUEnumObject。
4.2 验证对象类型
仅仅找到一个虚表指针还不够,我们需要验证它是不是UEnum的虚表。
- 手动验证:在调试器中,在
pPotentialUEnumObject地址设置硬件访问断点。然后在游戏中触发一些可能与UI状态相关的操作(比如打开主菜单)。如果断点触发,并且调用栈显示正在执行一个类似GetName或GetFullName的函数,这就是一个强烈的信号。 - 特征函数定位:更系统的方法是,先找到一个已知的、容易定位的
UObject,比如游戏中的UGameInstance。通过CE的“指针扫描”功能或调试器,找到它的地址。然后分析它的虚表,记住其中GetName或StaticClass函数的地址。因为所有UObject的虚函数顺序是固定的(由引擎定义),UEnum继承自UField再继承自UObject,所以它的GetName函数地址与UGameInstance的GetName函数地址不同,但我们可以通过偏移计算来推测。 - 调用验证:编写一个小段注入代码,尝试以
pPotentialUEnumObject为this指针,调用其GetName函数。如果返回的字符串包含“EUIState”,那么基本可以确认。这需要一定的汇编注入或DLL注入技巧。
实操心得:在实际操作中,我更喜欢用“比较法”。我会用CE同时打开两个内存区域,一个是已知的
UObject(比如一个AActor),一个是我们找到的pPotentialUEnumObject。对比两者开头几十个字节的内存布局。UObject的开头部分(vftable, InternalIndex, ObjectFlags)布局是完全一致的。如果布局匹配,那么它至少是一个UObject。然后再通过其GetName的结果来判断具体类型。
4.3 逆向UEnum特有成员:关键的Names数组
确认了pPotentialUEnumObject是UEnum后,下一步就是找到存储枚举项的Names数组成员。这个TArray在内存中通常由三部分组成:一个指向堆内存的指针(Data)、数组元素个数(Num)、数组分配容量(Max)。
我们需要在对象起始地址之后的一片区域里寻找这个结构。假设对象起始在0x1423A8AF0。
确定搜索范围:
UObject的公共部分大小相对固定(不同引擎版本在40-80字节左右)。UEnum的特有成员通常紧随其后。我们可以从对象起始+0x40开始查看。识别TArray模式:在内存中,一个典型的
TArray看起来像这样:地址: 0x1423A8B30: A0 B5 23 14 00 00 00 00 // 指针 Data = 0x1423B5A0 地址: 0x1423A8B38: 05 00 00 00 00 00 00 00 // Num = 5 地址: 0x1423A8B40: 08 00 00 00 00 00 00 00 // Max = 8这里
Num=5表示有5个枚举项。Data指针指向存储这5个元素的内存。解析数组元素:跳转到
Data指针指向的地址(0x1423B5A0)。TArray<TPair<FName, int64>>的每个元素是一个TPair。在内存中,一个TPair<FName, int64>可能这样布局:FName本身在UE中通常是一个包含索引和数字的结构。但在TArray中,为了性能,它可能直接存储一个FNameEntry的指针或一个序列化后的值。更常见的是,它存储一个FName的平面索引值(一个整数)。不过,在许多实际观察中,UEnum的Names数组里存储的往往是直接的字符串指针(const TCHAR*)和对应的整数值。这需要根据实际情况判断。- 假设我们遇到的是
(字符串指针, int64)对。那么在0x1423B5A0处你会看到:0x1423B5A0: 00 8B 23 14 00 00 00 00 // 字符串指针 -> 0x14238B00 (指向"MainMenu") 0x1423B5A8: 00 00 00 00 00 00 00 00 // int64 值 = 0 0x1423B5B0: B0 8B 23 14 00 00 00 00 // 字符串指针 -> 0x14238BB0 (指向"Playing") 0x1423B5B8: 01 00 00 00 00 00 00 00 // int64 值 = 1 ... 以此类推 ...
跳转到这些字符串指针,就能看到枚举项的名字。
计算偏移:记录下
Names这个TArray的起始地址相对于UEnum对象起始地址的偏移。假设pPotentialUEnumObject = 0x1423A8AF0,TArray的地址在0x1423A8B30,那么偏移就是0x1423A8B30 - 0x1423A8AF0 = 0x40。这个0x40就是我们要找的关键偏移。
重要提示:这个偏移
0x40是特定于你所分析的游戏和引擎版本的。UE4.18、4.25、4.27、5.0等不同版本,甚至同一版本不同编译选项下,这个偏移都可能不同。绝对不能把这个偏移值当作通用常量。我们的目标是掌握找到这个偏移的方法。
5. 编写稳定的内存读取代码
经过手动分析,我们假设得到了以下关键信息(以UE4.27为例):
UEnum对象虚表指针偏移:0x0(所有对象起始)UEnum::Names数组偏移:0x40TArray::Data偏移:+0x0TArray::Num偏移:+0x8TPair<FName, int64>中FName(假设为字符串指针)偏移:+0x0TPair<FName, int64>中int64值偏移:+0x8- 每个
TPair的大小:0x10(16字节)
下面是用C++编写读取逻辑的示例。我们假设已经通过其他方式(例如模式扫描)找到了一个UEnum对象的地址enumObjAddress。
#include <windows.h> #include <vector> #include <string> #include <iostream> // 假设我们已经获取了目标进程的句柄 HANDLE hProcess = ...; struct FNamePair { uint64_t NamePtr; // 指向字符串的指针 int64_t Value; }; bool ReadUEnumNames(uintptr_t enumObjAddress, std::vector<std::pair<std::string, int64_t>>& outNames) { outNames.clear(); // 1. 读取 Names TArray 的地址 uintptr_t namesArrayAddr = enumObjAddress + 0x40; // 偏移 uintptr_t dataPtr = 0; int32_t numElements = 0; SIZE_T bytesRead = 0; if (!ReadProcessMemory(hProcess, (LPCVOID)namesArrayAddr, &dataPtr, sizeof(dataPtr), &bytesRead) || bytesRead != sizeof(dataPtr)) { std::cerr << "Failed to read TArray Data pointer." << std::endl; return false; } if (!ReadProcessMemory(hProcess, (LPCVOID)(namesArrayAddr + 0x8), &numElements, sizeof(numElements), &bytesRead) || bytesRead != sizeof(numElements)) { std::cerr << "Failed to read TArray Num." << std::endl; return false; } if (dataPtr == 0 || numElements <= 0) { // 可能是一个空的枚举 return true; } // 2. 读取整个 TPair 数组 std::vector<FNamePair> rawPairs(numElements); SIZE_T arraySize = numElements * sizeof(FNamePair); if (!ReadProcessMemory(hProcess, (LPCVOID)dataPtr, rawPairs.data(), arraySize, &bytesRead) || bytesRead != arraySize) { std::cerr << "Failed to read TPair array." << std::endl; return false; } // 3. 为每个元素读取字符串 for (const auto& pair : rawPairs) { if (pair.NamePtr == 0) continue; // 读取以零结尾的宽字符串 (UE内部常用UTF-16) // 先尝试读取一个合理长度的字符串,这里假设最大256字符 const size_t bufferSize = 256; wchar_t wideBuffer[bufferSize] = {0}; // 注意:这里需要根据游戏实际使用的字符编码调整。可能是char,也可能是wchar_t。 // 一个更稳健的方法是先读取前几个字节判断。 if (ReadProcessMemory(hProcess, (LPCVOID)pair.NamePtr, wideBuffer, (bufferSize - 1) * sizeof(wchar_t), &bytesRead)) { wideBuffer[bufferSize - 1] = L'\0'; // 确保终止 // 将宽字符串转换为窄字符串 (UTF-16 to UTF-8 更佳,这里简化) char narrowBuffer[bufferSize * 2]; WideCharToMultiByte(CP_UTF8, 0, wideBuffer, -1, narrowBuffer, sizeof(narrowBuffer), nullptr, nullptr); outNames.emplace_back(std::string(narrowBuffer), pair.Value); } else { // 如果宽字符读取失败,尝试按ANSI字符读取 char ansiBuffer[bufferSize] = {0}; if (ReadProcessMemory(hProcess, (LPCVOID)pair.NamePtr, ansiBuffer, bufferSize - 1, &bytesRead)) { ansiBuffer[bufferSize - 1] = '\0'; outNames.emplace_back(std::string(ansiBuffer), pair.Value); } else { outNames.emplace_back("[Failed to read name]", pair.Value); } } } return true; }这段代码提供了一个基础框架。在实际应用中,你需要处理更多边界情况,比如字符串编码、内存分页保护、错误处理等。
6. 通用化与自动化探索策略
手动分析一个枚举是可行的,但我们的目标是能自动找到游戏里所有的UEnum。这需要更通用的策略。
6.1 遍历GUObjectArray
引擎将所有UObject(包括UEnum)存储在一个全局数组GUObjectArray中。如果我们能找到这个数组的地址,就可以遍历其中所有对象,通过检查对象的虚表指针(vftable)来判断其类型。
- 定位GUObjectArray:这通常需要通过引擎二进制文件中的字符串引用或特征码(AOB,Array Of Bytes)来扫描。例如,搜索字符串
“GUObjectArray”的引用,或者搜索初始化该数组的特定指令序列。 - 解析结构:
GUObjectArray是一个复杂的双层结构(FUObjectArray),包含TUObjectArray。在内存中,我们需要找到存储对象指针的线性数组ObjObjects及其数量ObjMax。 - 过滤UEnum:遍历
ObjObjects,对每个对象指针:- 读取其虚表指针。
- 通过虚表指针,可以调用对象的
GetFullName函数(需要注入代码或外部调用)。如果返回的完整名称包含“Enum”字样(例如“EUIState Enum”),则可以判定为UEnum`。 - 更高效但复杂的方法是通过虚表指针的地址范围来判断。所有
UEnum实例共享同一个虚表。如果我们能先找到一个确切的UEnum虚表地址,那么遍历时只需比较虚表指针是否等于这个地址即可。
6.2 使用引擎的反射信息
更高阶的方法是利用引擎自身的反射系统。UEnum类本身也是一个UObject,可以通过StaticClass()获取其UClass。然后,可以遍历所有UClass,找到UEnum::StaticClass(),再通过UClass内的某种链表或映射(如TMap)找到所有属于该类的实例。这种方法需要对引擎内部实现有更深的理解,但一旦实现,将是最稳定和准确的方法。
7. 常见问题、踩坑记录与排查技巧
在实战中,我遇到了无数问题。下面这个表格整理了一些典型的“坑”和解决思路:
| 问题现象 | 可能原因 | 排查思路与解决方案 |
|---|---|---|
读取到的Names数组指针为空或为0。 | 1. 偏移计算错误。 2. 枚举是空的(没有成员)。 3. 读取到了错误的内存地址(不是 TArray结构)。 | 1.验证偏移:用调试器手动查看enumObjAddress+0x40处的内存,确认是否是一个合理的指针(指向可读内存区域)。2.检查Num:读取 Num字段,如果为0,说明枚举确实为空。3.检查对象类型:再次确认传入的 enumObjAddress是否真的是一个有效的UEnum对象(通过调用GetName验证)。 |
| 读取到的字符串是乱码或访问违规。 | 1. 字符串编码判断错误(ANSI/UTF-8/UTF-16)。 2. NamePtr不是直接的字符串指针,而是FName的索引。3. 指针已失效或指向受保护内存。 | 1.编码探测:先读取指针处的2-4个字节,判断是0x00结尾(可能是ANSI/UTF-8)还是类似0x4D00 0x6100(UTF-16 LE)。2.处理FName:如果 NamePtr是一个小整数(如0x12345),那它很可能是FName的索引。需要找到游戏的GNames数组,通过索引来解析字符串。这是UE中最常见的情况!3.使用调试器:在调试器中手动跟随 NamePtr,看它到底指向什么内容。 |
虚表指针验证失败,误将其他UObject识别为UEnum。 | 1. 虚表指针比较的基准地址不对。 2. 游戏有多个模块,虚表地址不唯一。 | 1.获取准确的UEnum虚表:务必通过一个绝对可靠的UEnum实例(例如通过已知枚举字符串找到的那个)来获取其虚表地址,作为基准。2.范围判断:如果游戏有多个DLL,每个DLL可能有自己的 UEnum虚表副本。需要记录所有可能的基准虚表地址,或使用GetFullName进行字符串匹配。 |
| 游戏更新后,偏移全部失效。 | 引擎版本更新,类布局改变。 | 1.特征码扫描:不要硬编码偏移。编写特征码(AOB)来动态定位关键函数(如UEnum::GetNames)或关键数据结构的偏移。2.版本适配:为不同版本的游戏维护不同的偏移配置文件。 3.强化推导链:依赖于更稳定的特征,如虚函数顺序、 GUObjectArray的全局符号,而不是具体的偏移值。 |
| 遍历GUObjectArray时程序崩溃。 | 1. 数组边界判断错误,访问了非法内存。 2. 数组中包含已销毁或无效的对象指针。 | 1.严格检查索引:确保索引i小于ObjMax。2.验证对象指针:在解引用对象指针(读取虚表)前,先判断指针是否为 nullptr,以及是否指向一个可读的页面(可用VirtualQueryEx)。3.处理并发:游戏运行时可能正在创建或销毁对象。遍历时可以考虑暂停游戏线程(不推荐在线游戏),或接受偶尔的读取失败。 |
最重要的心得:游戏逆向没有银弹。你分析出的偏移和结构,很可能只适用于特定版本的一次编译。因此,核心价值在于掌握分析方法论和调试技巧,而不是记住某个具体的数字。每次游戏更新,都是一次重新应用这些方法的过程。养成详细记录分析步骤、内存快照和验证脚本的习惯,能极大提升下次分析的效率。
8. 扩展应用:从结构分析到实用工具
掌握了UEnum结构的分析方法后,你可以将其工程化,打造属于自己的逆向工具链:
- 自动枚举转储器:编写一个DLL,注入游戏后,自动遍历所有UEnum,将它们的名称和键值对输出到日志文件或JSON中。这可以用于快速构建游戏的状态字典。
- SDK生成器:结合对其他结构(如UClass、UProperty)的分析,可以尝试部分重构游戏的SDK,自动生成C++头文件,包含枚举定义,方便后续的Hook和Mod开发。
- 动态配置系统:你的游戏外挂或Mod的配置文件中,不再需要硬编码枚举值。可以写“
State = EUIState::Playing”,然后在运行时通过解析到的枚举表,将字符串“Playing”动态转换为整数值1。这使配置变得极其灵活和强类型。 - 游戏逻辑分析:在调试时,当你看到一个整型变量值是
3,你可以快速查询所有枚举,看哪个枚举的哪个项的值是3,从而瞬间理解这个变量在游戏逻辑中代表的含义(例如3对应ECharacterState::Attacking),极大加速逆向分析过程。
这个从内存中“提取”类型信息的过程,是游戏逆向从中级迈向高级的关键一步。它要求你将零散的内存读写知识,系统性地组合起来,去理解并驾驭游戏引擎本身的运行时结构。这个过程充满挑战,但每一次成功的解析,都会让你对游戏的理解加深一层,写出的代码也更加稳固和强大。