ARTICLE DETAIL

资讯详情

深耕网站SEO优化与搜索引擎排名提升的一线实战洞察。

游戏逆向实战:动态解析虚幻引擎UEnum内存结构与自动化脚本开发

游戏逆向实战:动态解析虚幻引擎UEnum内存结构与自动化脚本开发 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中。这个对象不仅仅是一个名字列表它内部关联着一个TArrayTPairFName, 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特有成员开始 | ----------------------- | TArrayTPair... 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这里Num5表示有5个枚举项。Data指针指向存储这5个元素的内存。解析数组元素跳转到Data指针指向的地址0x1423B5A0。TArrayTPairFName, int64的每个元素是一个TPair。在内存中一个TPairFName, 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 0x1423A8AF0TArray的地址在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偏移0x8TPairFName, int64中FName假设为字符串指针偏移0x0TPairFName, 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::vectorstd::pairstd::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::vectorFNamePair 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这通常需要通过引擎二进制文件中的字符串引用或特征码AOBArray 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.验证偏移用调试器手动查看enumObjAddress0x40处的内存确认是否是一个合理的指针指向可读内存区域。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 0x6100UTF-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极大加速逆向分析过程。这个从内存中“提取”类型信息的过程是游戏逆向从中级迈向高级的关键一步。它要求你将零散的内存读写知识系统性地组合起来去理解并驾驭游戏引擎本身的运行时结构。这个过程充满挑战但每一次成功的解析都会让你对游戏的理解加深一层写出的代码也更加稳固和强大。
返回列表