行业资讯
📅 2026/7/26 13:04:16
UE游戏逆向:深入解析GetName算法与FName系统内存结构
1. 项目概述为什么我们要深挖UE的GetName算法在UEUnreal Engine虚幻引擎游戏逆向的圈子里GetName算法是一个绕不开的经典话题。无论是想分析游戏内的对象结构、定位关键函数还是制作辅助工具理解并实现这个算法都是基础中的基础。简单来说它负责将引擎内部用于标识对象如UObject、UClass、UFunction等的唯一数字IDFNameEntryId转换为我们能读懂的字符串名称。你可能会问直接读内存里的字符串不就行了事情没这么简单。UE为了极致的内存和性能优化设计了一套非常精巧的字符串池Name Pool和哈希系统。一个名字比如“PlayerController”在整个游戏运行期间只在内存中存储一份所有引用它的地方都使用一个经过计算的索引值。GetName算法就是根据这个索引值从庞大的名称池里准确找到对应字符串的那把钥匙。最近在社区里关于UE5.8的MCPModular Gameplay插件、混合空间Blend Space的逆向甚至是UE激活许可证相关的一些探讨都或多或少会触及底层对象和名称系统。如果你连一个对象的名字都拿不到后续的分析就像在黑暗里摸索。因此掌握GetName的独立实现意味着你拥有了脱离官方调试工具、自主探查游戏逻辑的能力。这对于安全研究、外挂对抗分析、游戏模组开发乃至引擎本身的学习都至关重要。这篇文章我将从一个逆向实践者的角度带你彻底拆解GetName算法。我不会只给你一个模糊的地址偏移而是会从UE对象模型的基础讲起一步步推导出算法的完整逻辑并用C实现一个可独立运行的版本。我们会涉及UE4和UE5的主要版本差异并分享我在实际逆向《方舟生存进化》、《永劫无间》等基于UE开发的游戏时定位和验证此算法的具体思路与踩过的坑。2. UE对象模型与FName系统核心原理要理解GetName必须先对UE底层的对象和名称系统有个清晰的认知。很多人一开始就扎进IDA看汇编结果被各种指针和结构体绕晕根本原因就是地基没打牢。2.1 UObject与FUObjectArrayUE中几乎所有的游戏逻辑对象都继承自UObject。引擎在启动时会创建一个全局的GUObjectArray在UE4中或FUObjectArray的某个实例在UE5中有所变化它管理着所有UObject实例的生死。你可以把它想象成一个巨大的数组每个UObject在这个数组里都有一个唯一的索引称为InternalIndex。当我们谈论一个对象的“名字”时通常指的是它的FName类型的成员。但UObject本身有一个GetName()函数其内部其实就是调用了FName的转换函数。所以问题的核心转移到了FName上。2.2 FName的精妙设计不是简单的字符串FName是UE中表示一个“名称”的轻量级结构体。它的设计目标是快速比较两个FName是否相等直接比较其内部的整数ID即可无需比较字符串。内存高效相同的字符串只存储一次。缓存友好结构体很小通常只包含两个整数。一个典型的FName在内存中以UE4.25为例可能看起来像这样struct FName { int32 ComparisonIndex; // 名称在全局名称池中的索引 int32 Number; // 用于区分同名对象如MyActor_1, MyActor_2中的数字部分 };这里的ComparisonIndex就是我们GetName算法需要破解的关键。它直接对应到全局GNames数组中的一个位置。2.3 GNames全局名称池的奥秘GNames是引擎中一个非常核心的全局变量它是一个TNameEntryArray类型的数组存储了所有FNameEntry结构体。FNameEntry就是最终存储字符串数据的地方。这个数组的结构随着UE版本迭代发生了巨大变化这也是逆向时最需要关注的地方UE4早期版本~4.22GNames是一个简单的TArrayFNameEntry*。ComparisonIndex直接就是这个数组的下标。寻址公式简单粗暴FNameEntry* Entry GNames[ComparisonIndex]。UE4后期及UE5早期为了支持更大的名称空间和更好的并发性能引入了“块”Chunk和“池”Pool的概念。GNames变成了一个FNamePool或类似结构。ComparisonIndex被拆解为两部分Block块索引和Offset块内偏移。你需要先找到对应的内存块再在块内定位。UE5.0结构进一步复杂化FNamePool的设计更加精细化可能涉及多级索引。但核心思想不变将ComparisonIndex映射到实际的FNameEntry内存地址。所以实现GetName算法的第一步也是最大的一步就是找到当前游戏版本中GNames或其等价物在内存中的地址并正确理解其内存布局。注意网络上很多过时的教程或源码只针对特定UE4版本直接套用到新版游戏上必然失败。你必须具备自己分析内存结构的能力。3. 定位GNames与解析内存结构实战理论讲完了我们进入实战环节。我将以一款使用UE4.25~4.27版本的游戏为例这是目前市面上很多网游的常用版本演示如何动态定位并分析GNames。3.1 工具准备与思路你需要以下工具逆向分析工具x64dbg、Cheat Engine (CE) 或 IDA Pro。CE对新手更友好这里以CE为例。一个运行中的UE4游戏进程。UE4的源码或符号文件如果有这能极大加快分析速度。但即使没有我们也能通过特征来定位。我们的目标是找到GNames的地址。一个经典的线索是引擎中很多函数都会用到GNames例如FName::ToString。我们可以通过字符串引用或函数调用链来定位。3.2 通过字符串特征进行定位在CE中附加游戏进程。搜索已知字符串UE游戏内一定有大量内置的类名、函数名。比如我们可以搜索字符串“Actor”。你会得到很多地址。查找引用在这些字符串地址上右键选择“找出是什么访问了这个地址”。你会看到很多访问该字符串的汇编指令。分析访问模式在这些指令中寻找一个共同的、看起来像是基地址的指针。例如你可能会反复看到类似mov rcx, [7FF634567890]这样的指令然后后面跟着用rcx加上某个偏移来获取字符串。这个7FF634567890就很有可能是GNames数组的基址或某个重要结构的地址。验证查看这个地址指向的内存。在CE的内存浏览器中跳转到该地址。如果它是GNames对于UE4.25的简单数组结构那么它应该是一个指针数组。第一个指针可能指向一个FNameEntry里面存储着像“None”这样的基础名称。你可以尝试解析前几个指针看它们指向的字符串是否符合预期如“None”, “ByteProperty”, “IntProperty”等。3.3 手动解析FNameEntry结构假设我们找到了一个地址0x7FF634560000它看起来像是一个指针数组的起点。[0x7FF634560000]可能等于0x1423AB000。跳转到0x1423AB000。对于UE4一个FNameEntry通常以一个表示字符串长度的uint16开始注意UE的字符串是UTF-16的TCHAR但在某些版本和配置下也可能是ANSI。紧跟在长度后面的就是字符串数据。例如在0x1423AB000 2的位置你可能会看到4E 00 6F 00 6E 00 65 00Unicode “None”。如果解析成功那么恭喜你0x7FF634560000很可能就是GNames数组的起始地址。3.4 应对复杂结构UE5/FNamePool对于使用FNamePool的版本情况更复杂。ComparisonIndex不再是直接下标。例如它可能被设计为ComparisonIndex (Block 某个位数) Offset你需要分析内存找到FNamePool这个结构体。它内部可能包含Blocks: 一个指向多个内存块指针数组的指针。CurrentBlock,CurrentByteCursor: 用于分配新名称的指针。定位方法类似但需要更耐心。你可以尝试搜索“FNamePool::”这样的函数名如果有符号或者通过分配新名称的代码如FName::FName(const WIDECHAR*)构造函数来反向追踪到池的结构。实操心得在没有符号的情况下我常用的方法是“运行时创建对象观察法”。写一个简单的DLL注入到游戏在运行时动态创建一个带有独特名称的UObject这需要你先实现一些引擎基础功能然后在这个对象的FName成员上设置内存断点回溯到写入ComparisonIndex的代码顺藤摸瓜找到计算和查询名称池的完整逻辑链。这是最可靠的方法。4. GetName算法的C独立实现在成功定位并理解了GNames的内存布局后我们就可以着手实现独立的GetName函数了。这里我提供一个针对UE4.25风格简单数组结构的示例实现。请注意偏移和结构需要你根据实际分析结果调整。4.1 定义基础结构首先我们需要定义从内存中读取所需的最小结构体。#include windows.h #include cstdint #include string #include vector // 假设我们分析出 FNameEntry 在目标游戏中是以下结构ANSI 字符串版本 struct FNameEntry_ANSIVersion { uint16_t bIsWide : 1; // 最低位标志位0表示ANSI1表示WIDE uint16_t Len : 15; // 字符串长度 union { char AnsiName[1]; // ANSI 字符串柔性数组 wchar_t WideName[1]; // Wide 字符串 }; }; // 我们的目标实现一个函数给定一个 FName 的 ComparisonIndex返回其字符串。 class UE4NameResolver { private: uintptr_t gNamesAddress; // GNames 数组的基地址 public: // 构造函数需要传入动态找到的 GNames 地址 UE4NameResolver(uintptr_t namesAddr) : gNamesAddress(namesAddr) {} std::string GetNameByIndex(int32_t comparisonIndex) { if (comparisonIndex 0 || gNamesAddress 0) { return Invalid; } // 1. 计算 FNameEntry* 指针在 GNames 数组中的位置 // GNames 是一个 TArrayFNameEntry*其数据部分从基址偏移开始 // 假设我们分析出在目标游戏中FNameEntry* 数组起始于 gNamesAddress 0x10 uintptr_t nameEntryArray gNamesAddress 0x10; // 2. 读取对应索引处的指针 uintptr_t entryPtrAddr nameEntryArray (comparisonIndex * sizeof(uintptr_t)); uintptr_t nameEntryAddress 0; // 这里需要根据游戏进程进行跨进程内存读取 // 为了示例清晰我们假设已有一个 ReadMemory 函数 if (!ReadMemory(entryPtrAddr, nameEntryAddress, sizeof(uintptr_t))) { return Failed to read entry pointer; } if (nameEntryAddress 0) { return Null entry; } // 3. 读取 FNameEntry 头部的长度和标志位 uint16_t info 0; if (!ReadMemory(nameEntryAddress, info, sizeof(uint16_t))) { return Failed to read entry info; } bool bIsWide (info 1) ! 0; int32_t length (info 1) 0x7FFF; // 取后15位作为长度 // 4. 根据字符串类型读取字符串 std::string result; uintptr_t stringBaseAddr nameEntryAddress sizeof(uint16_t); // 字符串数据起始地址 if (bIsWide) { // Wide String (UTF-16) std::vectorwchar_t wideBuffer(length 1); if (ReadMemory(stringBaseAddr, wideBuffer.data(), length * sizeof(wchar_t))) { wideBuffer[length] L\0; // 简化处理将宽字符转换为窄字符可能有信息丢失仅作演示 int sizeNeeded WideCharToMultiByte(CP_UTF8, 0, wideBuffer.data(), -1, NULL, 0, NULL, NULL); std::vectorchar multiByteBuffer(sizeNeeded); WideCharToMultiByte(CP_UTF8, 0, wideBuffer.data(), -1, multiByteBuffer.data(), sizeNeeded, NULL, NULL); result multiByteBuffer.data(); } } else { // ANSI String std::vectorchar ansiBuffer(length 1); if (ReadMemory(stringBaseAddr, ansiBuffer.data(), length)) { ansiBuffer[length] \0; result ansiBuffer.data(); } } return result.empty() ? Failed to read string : result; } private: // 一个简单的跨进程内存读取辅助函数需提升权限 bool ReadMemory(uintptr_t address, void* buffer, size_t size) { HANDLE hProcess OpenProcess(PROCESS_VM_READ, FALSE, GetCurrentProcessId()); // 示例用自身进程 if (hProcess NULL) return false; SIZE_T bytesRead 0; BOOL success ReadProcessMemory(hProcess, (LPCVOID)address, buffer, size, bytesRead); CloseHandle(hProcess); return success (bytesRead size); } };4.2 算法步骤详解上面的代码实现了核心算法让我们拆解一下定位数组元素GNames被当作一个指针数组。comparisonIndex就是下标。计算元素地址的公式是基址 数组数据起始偏移 index * sizeof(指针)。这里的0x10偏移是示例你必须用自己分析出的值替换。这个偏移源于TArray结构有Max、Num等成员变量。获取条目指针读取该下标处存储的指针值这就是FNameEntry结构体的实际内存地址。解析条目头读取FNameEntry的前两个字节。最低位bIsWide判断字符串编码剩余15位Len是字符串长度。这是UE内部常见的位域压缩技巧。读取字符串根据标志位从条目头之后的内存地址开始读取Len个字符ANSI为字节Wide为双字节。注意字符串没有标准的空终止符长度完全由Len字段决定。编码转换可选如果游戏使用宽字符串且你需要输出为UTF-8需要进行转换。4.3 整合FName与Number一个完整的对象名可能包含数字后缀比如“MyActor_1”。这由FName的Number成员控制。我们的GetNameByIndex只获取了基础名“MyActor”。完整的GetName函数需要这样处理std::string GetFullName(int32_t comparisonIndex, int32_t number) { std::string baseName GetNameByIndex(comparisonIndex); if (number 0) { // UE内部通常当Number0时会附加“_数字”后缀 char suffix[32]; sprintf_s(suffix, _%d, number); baseName suffix; } // 注意Number为0通常表示没有后缀但某些情况下如“None”Index为0Number也为0。 // 具体逻辑需参考引擎源码 FName::ToString 的实现。 return baseName; }5. 版本适配与高级话题应对UE5与FNamePool对于UE5或使用FNamePool的UE4版本算法核心不变但寻址过程多了一层间接性。你需要实现一个GetNameByIndex的升级版。5.1 解析FNamePool结构假设我们通过逆向分析得到了目标游戏中FNamePool的关键信息Pool的基地址FNamePool* GNamePool (FNamePool*)0x7FF634560000;内存被划分为多个Block每个Block大小可能为64KB。ComparisonIndex的构成BlockIndex (Index 某个值) 掩码OffsetWithinBlock (Index 掩码) * 元素大小。伪代码实现可能如下std::string GetNameFromPool(int32_t comparisonIndex) { const uintptr_t poolBase 0x7FF634560000; // 1. 从Pool基址读取Blocks指针数组的地址 uintptr_t blocksPtr 0; ReadMemory(poolBase 0x00, blocksPtr, 8); // 假设Blocks在偏移0x00 // 2. 计算Block索引和块内偏移具体位运算需逆向确定 int blockShift 16; // 假设低16位表示块内偏移 int blockMask (1 blockShift) - 1; int blockIndex comparisonIndex blockShift; int offsetInBlock (comparisonIndex blockMask) * 2; // 假设每个条目占2字节仅示例实际是FNameEntry* // 3. 获取目标Block的地址 uintptr_t targetBlockPtr 0; ReadMemory(blocksPtr blockIndex * sizeof(uintptr_t), targetBlockPtr, 8); // 4. 计算FNameEntry的实际地址 uintptr_t nameEntryAddress targetBlockPtr offsetInBlock; // 5. 接下来的步骤和之前一样读取Info、长度、字符串数据 // ... (调用类似解析FNameEntry的函数) }5.2 动态偏移定位技巧游戏每次更新GNames或FNamePool的静态地址都可能改变。一个健壮的实现需要支持动态定位。常用方法有特征码扫描AOB Scan分析访问GNames的指令特征。例如在FName::ToString函数里通常会有mov rcx, [rip 某个偏移]来加载GNames地址。我们可以扫描这个指令模式并计算出运行时地址。指针链遍历从一个稳定的、已知地址的全局对象如UWorld、GEngine出发通过其内部的指针关系一步步找到GNames。这需要你对UE的对象层次非常熟悉。引擎函数Hook直接HookFName::ToString或FName::FName构造函数在函数内部获取GNames指针。这是最准确但实现也最复杂的方法。6. 实战应用与常见问题排查实现了GetName算法后它能做什么又可能会遇到什么问题6.1 典型应用场景对象遍历与Dump遍历GUObjectArray对每个UObject调用GetName可以输出游戏中所有活跃对象的列表用于分析资源加载情况或寻找特定类实例。函数签名解析UFunction的名称、参数名、返回类型名都存储在名称池中。通过GetName可以还原出可读的函数签名。动态分析辅助在调试时可以将一个对象的地址快速转换为其类名和对象名极大提升逆向效率。外部工具开发这是开发内存查看器、结构分析器、内嵌调试器如ImGui Overlay的基础功能。6.2 常见问题与排查清单问题现象可能原因排查步骤返回的字符串是乱码1. 字符串编码判断错误ANSI/Wide。2. 长度字段解析错误。3. 内存读取地址错误。1. 检查bIsWide标志位是否正确读取和判断。2. 手动在内存浏览器中查看FNameEntry地址确认前2字节和后续字符串数据。3. 确认ComparisonIndex是否有效非负且不过大。返回空字符串或“Invalid”1.ComparisonIndex为0或负数。2.GNames地址不正确。3. 指针数组中的条目为空。1.Index为0通常是“None”应能正确返回。检查传入的Index值。2. 重新验证GNames基址。尝试读取前几个索引012看是否指向合理的字符串如“None”。3. 某些索引可能未被使用指向空指针是正常的。程序崩溃访问违规1. 地址计算错误访问了非法内存。2. 跨进程内存读取函数ReadMemory失败或权限不足。1. 在计算nameEntryArray、entryPtrAddr等地址时加入日志打印核对每一步的计算结果。2. 确保ReadMemory函数能成功打开目标进程并具有PROCESS_VM_READ权限。在读取前后检查地址是否可读。对于某些Index算法在新版本游戏上失效GNames内存布局已改变如升级到使用FNamePool的版本。1. 使用本文第5节的方法重新分析新版本的内存结构。2. 关注UE版本更新日志或社区讨论看FName系统是否有变动。获取到的名称缺少后缀如“_1”忘记了处理FName的Number成员。在调用GetNameByIndex后根据Number值追加“_%d”后缀。注意Number为0时通常不加。6.3 一个完整的实战示例遍历并打印UObject名称假设你已经正确实现了UE4NameResolver类并成功获取了GUObjectArray的地址这又是另一个逆向话题你可以这样使用它void DumpAllObjectNames(UE4NameResolver nameResolver, uintptr_t objectArrayAddress) { // 简化版假设我们知道如何从objectArrayAddress获取ObjObjects和ObjObjectCount int32_t objectCount /* 从 objectArrayAddress 特定偏移读取 */; uintptr_t firstObjectPtr /* 从 objectArrayAddress 另一偏移读取 */; for (int i 0; i objectCount; i) { uintptr_t objectAddress 0; ReadMemory(firstObjectPtr i * sizeof(uintptr_t), objectAddress, sizeof(uintptr_t)); if (objectAddress) { // 读取对象的 FName 的 ComparisonIndex 和 Number int32_t nameIndex 0, nameNumber 0; // 假设 FName 在 UObject 的偏移是 0x18 (UE4常见偏移) ReadMemory(objectAddress 0x18, nameIndex, sizeof(int32_t)); ReadMemory(objectAddress 0x1C, nameNumber, sizeof(int32_t)); std::string objName nameResolver.GetNameByIndex(nameIndex); if (nameNumber 0) { char buffer[64]; sprintf_s(buffer, _%d, nameNumber); objName buffer; } // 还可以读取对象的 Class 的 FName uintptr_t classPtr 0; ReadMemory(objectAddress 0x10, classPtr, sizeof(uintptr_t)); // ClassPrivate 偏移 int32_t classNameIndex 0; ReadMemory(classPtr 0x18, classNameIndex, sizeof(int32_t)); // 类本身的 FName 偏移 std::string className nameResolver.GetNameByIndex(classNameIndex); printf([%06d] Class: %-30s Name: %s\n, i, className.c_str(), objName.c_str()); } } }这个简单的Dump程序可以让你瞬间看清游戏世界里所有对象的脉络是逆向工程中强大的第一步。7. 总结与进阶方向通过以上步骤我们从原理到实战完整地拆解并实现了UE游戏逆向中的GetName算法。关键在于理解FName、GNames或FNamePool和FNameEntry三者的关系并能够动态地定位和解析目标游戏进程中的这些关键数据结构。我个人在实际操作中的体会是不要试图死记硬背偏移量。不同游戏、不同UE版本、甚至不同编译配置下的偏移都可能不同。掌握分析方法字符串引用、内存断点、结构体逆向比记住一个具体的数字重要十倍。每次分析新目标都从寻找“None”字符串的访问者开始一步步推导这个过程本身就是对UE内存模型最好的学习。最后再分享一个小技巧在实现自己的GetName工具时最好加入一个缓存机制。因为同一个ComparisonIndex可能会被频繁查询将结果缓存起来可以极大提升性能尤其是在遍历成千上万个对象时。你可以用一个std::unordered_mapint32_t, std::string来存储已解析的名称。这个算法是UE逆向的基石。在此基础上你可以进一步探索如何获取对象的完整路径名包含外部包名、如何解析属性UProperty、如何调用引擎函数等更高级的话题。当你能够稳定地获取任何对象的名称时整个游戏的内部世界对你而言就不再是一片混沌的二进制数据而是一个有清晰标识和结构的、可以逐步探索的王国。