尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

UE4逆向基石:GetName函数与FName系统深度解析

UE4逆向基石:GetName函数与FName系统深度解析 1. 项目概述为什么GetName是UE4逆向的基石在UE4游戏逆向的世界里无论你是想分析游戏逻辑、制作辅助工具还是研究引擎机制有一个函数是你绝对绕不开的它就是GetName。这个看似简单的函数实际上是连接虚幻引擎内部对象世界与外部可读字符串的桥梁。想象一下你面对的是一个由成千上万个UObject指针构成的复杂内存森林没有名字它们只是一堆毫无意义的十六进制地址。而GetName就像是一本花名册能告诉你每一个地址背后对应的究竟是“PlayerController”、“Weapon_BP_Rifle”还是某个关键的“GameState”属性。我之所以花大力气深挖4.23版本的GetName是因为这个版本在社区和实际项目中依然保有巨大的存量。许多经典游戏、独立作品乃至一些仍在运营的项目都基于此版本构建。更重要的是4.23版本处于UE4引擎发展中的一个“稳定期”其内存结构和函数实现相比后续版本变动较小规律性更强非常适合作为逆向学习的样板。从GetName入手你不仅能学会如何获取一个名字更能借此窥见UE4整个对象系统、名称表FNamePool管理以及字符串存储的核心机制。掌握了它后续无论是Dump整个游戏的SDK软件开发工具包还是定位特定功能的虚函数表VTable都会事半功倍。2. 核心原理GetName函数与FName系统深度拆解要逆向GetName绝不能停留在简单的函数调用层面必须深入其依赖的FName系统。在UE4中字符串处理并非直接使用std::string或TCHAR*而是通过一套高度优化的FName系统。理解这套系统是理解GetName的关键。2.1 FName的核心设计池化与索引FName的本质不是一个字符串缓冲区而是一个对“字符串池”的索引。引擎启动时会初始化一个全局的FNamePool。每当一个新的字符串如“Player”需要被用作名称时引擎会先在池中查找是否已存在。如果存在则直接返回其索引如果不存在则将其加入池中并分配一个新索引。FName对象内部主要存储的就是这个索引值以及一个用于解决哈希冲突的实例编号。这种设计带来了巨大的优势比较效率极高比较两个FName是否相等只需要比较其整数索引是O(1)操作远比逐字符比较字符串快。内存占用极低相同的字符串只在内存中存储一份大量重复的名称如“Component”、“Transform”节省了可观的内存。哈希表性能作为游戏对象属性、函数名的标识FName能极大提升TMap等容器的性能。在内存中一个典型的FName对象在4.23版本可能只包含两个成员ComparisonIndex用于快速比较的索引和DisplayIndex用于获取显示字符串的索引。GetName函数的工作就是根据对象内部的FName索引去全局FNamePool中查找出对应的原始字符串。2.2 GetName函数的源码逻辑追踪在4.23的源码中以Engine/Source/Runtime/CoreUObject/Public/UObject/UObjectBase.h为例GetName的调用链通常如下// UObjectBase 中的定义 class UObjectBase { // ... 其他成员 FName GetFName() const; const TCHAR* GetName() const; }; // 实现通常类似这样 const TCHAR* UObjectBase::GetName() const { return GetFName().GetPlainNameString(); // 或者可能是 GetFName().ToString(); }它会调用GetFName()获取对象的FName再调用FName::GetPlainNameString()或FName::ToString()将索引转换为字符串指针。而FName::GetPlainNameString()的内部最终会访问一个全局的GNames变量即FNamePool的访问接口通过索引取出字符串。逆向视角下的关键点在逆向时我们看不到源码但在二进制中这个调用链会体现为一系列的函数调用和内存访问指令。我们的目标就是定位到最终从那个全局名称表GNames中取字符串的逻辑。这个GNames的地址就是整个Dump过程的“钥匙”。2.3 内部与外部Dump的差异这是标题中提到的核心概念也是实战中的关键分水岭。内部Dump指在游戏进程内部通过注入的DLL动态链接库或调试器脚本直接调用游戏模块中的GetName函数或者直接访问GNames表来获取名称。这种方式效率极高准确性最好因为它使用的是游戏自身的逻辑和内存数据。但前提是你能将代码注入到游戏进程中。外部Dump指在游戏进程外部通过读取进程内存ReadProcessMemory自己解析内存数据重构出名称查找逻辑。这需要你逆向分析出GNames的结构、FName的索引解析算法并在自己的程序中实现。这种方式更通用、更安全无需注入但开发难度更大且对逆向分析深度要求极高。大多数逆向工具或脚本如通用UE4 Dumper在初期探索阶段往往采用外部Dump的方式来确定模式一旦模式稳定则会开发内部Dump插件以获得最佳性能。3. 实战准备定位关键地址与数据结构在开始写代码Dump之前我们需要像侦探一样在游戏的内存世界中找到几个关键的“地标”。对于4.23版本这些地标相对固定但依然需要验证。3.1 定位GNames全局变量GNames是FNamePool的静态实例指针。在4.23版本的UE4二进制文件中定位它通常有以下几种方法我推荐按顺序尝试字符串引用搜索这是最经典的方法。用x64dbg或IDA加载游戏主模块在全模块中搜索字符串GNames。你可能会在调试信息字符串或某个日志输出函数附近找到对它的引用。交叉引用Xref这个字符串找到访问它的代码通常就能找到GNames的地址。在4.23中它常常在FName::ToString或相关函数内部被加载。特征码Pattern扫描这是更稳健的自动化方法。通过分析多个4.23版本游戏或SDK总结出访问GNames的指令字节序列特征码。例如在x64架构下访问全局变量的指令通常是mov reg, [ripoffset]或lea reg, [ripoffset]。你可以提取这些指令的字节码如48 8B 0D ?? ?? ?? ??对应mov rcx, [ripoffset]然后在内存中扫描。找到后计算偏移即可得到GNames的地址。注意特征码中的??是通配符代表偏移字节。计算最终地址的公式为指令地址 指令长度 读取到的偏移值。这是外部Dump工具的核心技术。引擎偏移推测对于特定版本GNames相对于引擎模块基址的偏移可能是固定的。社区维护的逆向项目如某开源Dumper会收集这些偏移。但这种方法最不可靠因为游戏开发者可能链接了不同的引擎库或进行了定制。实操心得我通常先用方法1手动确认第一个目标分析出其特征码然后用方法2编写扫描函数这样既能理解原理又能实现自动化。记得每次游戏更新后都要重新验证。3.2 解析FNamePool结构找到GNames的地址后我们需要解析它的结构。在4.23中FNamePool通常是一个复杂的类但逆向时我们可以将其简化为一个包含多个“块”Blocks或“桶”Buckets的数组。一个简化的内存模型可能如下GNames - FNamePool | v [Block0 Ptr, Block1 Ptr, ...] // 一个指针数组每个指针指向一个FNameEntry数组块每个FNameEntry存储了实际的字符串数据。字符串并非直接跟在结构体后面而是通过一个偏移来访问。FName的索引ComparisonIndex通常被编码为一个组合值高几位表示在第几个Block低几位表示在Block中的条目索引。关键计算假设我们通过逆向得知在4.23中一个Block最多有0x1000065536个条目。那么BlockIndex NameIndex / 0x10000 EntryIndex NameIndex % 0x10000然后通过GNames[BlockIndex]找到对应的Block基地址再通过BlockBase EntryIndex * EntrySize找到FNameEntry的地址。EntrySize需要通过分析内存结构来确定早期版本可能是0x10字节。3.3 定位UObject数组GObjects虽然GetName主要涉及GNames但完整的SDK Dump离不开GObjects全局对象数组。它是所有UObject实例的列表。定位GObjects的方法与GNames类似搜索字符串GObjects。在UObject::StaticClass、UObject::GetGlobalObjects等函数中寻找其特征指令。使用特征码扫描例如寻找遍历对象数组的循环指令模式。找到GObjects后你就能遍历游戏中的所有对象对每个对象调用你的GetName实现从而Dump出完整的类名、函数名、属性名。4. 内部Dump实现注入与直接调用内部Dump的核心思想是“借力打力”直接使用游戏模块内的代码和内存。4.1 编写注入DLL你需要创建一个DLL项目。这个DLL需要导出至少一个函数如StartDump供注入器调用。// DumperDLL.cpp 示例框架 #include Windows.h #include string #include vector #include fstream // 声明从逆向分析中得到的类型和地址 typedef void* (*tGetNameByIndex)(int32_t nameIndex); // 假设的函数原型 tGetNameByIndex GetNameByIndexFunc nullptr; uintptr_t GNamesPtr 0; BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved) { if (ul_reason_for_call DLL_PROCESS_ATTACH) { // 在这里进行初始化可能不安全建议在导出的函数中初始化 DisableThreadLibraryCalls(hModule); // 可选减少线程通知 } return TRUE; } extern C __declspec(dllexport) void StartDump() { // 1. 初始化获取模块基址计算函数地址和全局变量地址 HMODULE hGameModule GetModuleHandleA(GameModuleName.dll); uintptr_t moduleBase (uintptr_t)hGameModule; // 假设我们已经通过特征码找到了偏移 GetNameByIndexFunc (tGetNameByIndex)(moduleBase 0x123456); // GetNameByIndex函数偏移 GNamesPtr *(uintptr_t*)(moduleBase 0x789ABC); // GNames指针的偏移 // 2. 遍历测试 std::ofstream outFile(InternalDump.txt); for (int32_t i 0; i 1000; i) { // 测试前1000个名字 const char* name (const char*)GetNameByIndexFunc(i); if (name name[0] ! \0) { outFile Index[ i ] name std::endl; } } outFile.close(); }4.2 获取函数地址与调用约定上面的代码假设了一个GetNameByIndex函数。实际上你可能需要调用的是FName::ToString或更底层的函数。这需要更精确的逆向分析。分析调用约定使用反汇编工具如IDA查看目标函数的开头和结尾。__fastcall、__stdcall还是__cdeclx64下通常是一种优化的__fastcall前四个参数通过RCX, RDX, R8, R9传递。确定函数原型分析函数用了哪些寄存器/栈返回了什么。例如一个返回字符串指针的函数在x64上很可能将结果放在RAX寄存器。定义正确的函数指针根据分析结果定义类型。如果函数是类的成员函数__thiscall调用时还需要传递this指针通常放在RCX。一个更真实的例子你可能需要先获取一个UObject的FName索引然后再解析。// 逆向分析得到的结构简化 struct FName { int32_t ComparisonIndex; int32_t Number; }; struct UObject { void** VfTable; int32_t ObjectFlags; // ... FName Name; // ... }; // 假设的对象获取函数和名称解析函数 typedef UObject* (*tGetObjectByIndex)(int32_t index); typedef const wchar_t* (*tFNameToString)(FName* name); void DumpObjectNames() { tGetObjectByIndex GetObjectByIndex ...; tFNameToString FNameToString ...; uintptr_t GObjectsPtr ...; int32_t objCount *(int32_t*)(GObjectsPtr 0x10); // 假设对象数量在GObjects0x10 UObject** objArray *(UObject***)(GObjectsPtr 0x18); // 假设对象数组指针在GObjects0x18 for (int i 0; i objCount; i) { UObject* obj objArray[i]; if (obj) { const wchar_t* nameStr FNameToString(obj-Name); // 输出 nameStr... } } }4.3 注入方法与风险控制将DLL注入游戏进程的常用方法有远程线程注入(CreateRemoteThreadLoadLibraryA)最常用。依赖劫持DLL Hijacking修改游戏导入表或放置同名的DLL在搜索路径。手动映射注入更隐蔽但也更复杂。重要警告注入行为违反几乎所有网络游戏的服务条款会被检测为外挂导致封号。本技术仅限用于单机游戏研究、学习引擎原理或对自己拥有完全产权的项目进行调试。在注入前请务必关闭所有反作弊软件如EasyAntiCheat, BattlEye并仅在合法授权的环境下操作。实操心得在开发阶段我强烈建议先在一个干净的、自己编译的UE4 4.23测试项目中进行。你可以直接编译引擎源码在调试模式下运行你的测试程序这样你可以直接访问GNames和GObjects符号验证你的解析逻辑100%正确然后再移植到逆向的二进制环境中。这能节省大量猜测和排查时间。5. 外部Dump实现内存读取与结构重建外部Dump不依赖游戏内部代码而是自己充当一个“外部解析器”。这需要更全面的逆向工程。5.1 读取进程内存使用Windows APIOpenProcess获取进程句柄然后用ReadProcessMemory读取数据。HANDLE hProcess OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, FALSE, pid); if (hProcess) { uintptr_t gNamesAddr 0x7FF123456789; // 通过特征码扫描得到的地址 uintptr_t namePoolPtr 0; SIZE_T bytesRead; // 读取GNames指针指向的FNamePool地址 ReadProcessMemory(hProcess, (LPCVOID)gNamesAddr, namePoolPtr, sizeof(namePoolPtr), bytesRead); // 接下来根据解析的FNamePool结构继续读取Blocks和Entries... CloseHandle(hProcess); }5.2 实现FName索引解析算法这是外部Dump的核心挑战。你需要完全复现引擎根据FName索引查找字符串的逻辑。确定索引编码如前所述分析FName结构确定ComparisonIndex如何拆分为BlockIndex和EntryIndex。可能需要静态分析多个相关函数或动态调试观察索引值与最终字符串地址的关系。解析FNameEntry找到FNameEntry在内存中的布局。在4.23中一个常见的简化结构是struct FNameEntry { uint16_t bIsWide : 1; // 标志位指示字符串是ANSI还是Wide // ... 其他标志位 char AnsiName[1]; // 或 wchar_t WideName[1]; 柔性数组实际字符串紧跟其后 };字符串的起始地址通常是FNameEntry地址加上一个固定偏移如0x4或0x8。你需要通过读取内存根据bIsWide标志决定是按char还是wchar_t来读取字符串直到遇到\0结束符。5.3 遍历GObjects并关联名称外部Dump的最终目标是生成SDK。因此在能解析名称后需要遍历GObjects。读取GObjects用类似方法读取GObjects数组。解析UObject结构你需要知道UObject的基本布局至少要知道FName Name成员在对象中的偏移量例如在4.23 x64下可能在VTable指针后第0x18字节处。这个偏移量也需要通过逆向分析得到。关联与输出对于每个有效的UObject指针读取其FName成员用你的解析函数得到字符串同时还可以读取其Class指针另一个UObject*从而建立起“对象实例 - 对象名 - 类名”的关系。最终输出成.hpp或.cs等格式的SDK文件。避坑技巧外部Dump时内存读取失败是常态。一定要在每次ReadProcessMemory后检查返回值。对于可能无效的指针如已被释放的对象要先尝试读取一个小的、确定有效的内存范围如对象地址本身来验证指针有效性避免因读取非法地址导致自身进程崩溃。6. 4.23版本特定细节与适配不同版本的UE4其内部结构会有调整。针对4.23有几个需要特别注意的点FNamePool结构变化相比更早的版本如4.18之前使用TNameEntryArray4.23的FNamePool结构已经过优化。相比后续版本如4.25它的结构又相对简单。在逆向时最好能找到4.23版本的调试符号.pdb文件或直接参考其开源代码Epic在GitHub上发布了4.26之后的完整源码但4.23的代码可以通过某些渠道找到近似版本来理解其确切布局。字符串编码确认游戏使用的是ANSIchar还是宽字符wchar_t。大部分UE4游戏内部使用宽字符UTF-16。你的解析函数需要能正确处理两种编码。FNameEntry中的标志位是关键。偏移量的稳定性GNames和GObjects的静态偏移在同一个引擎版本的不同构建中可能保持稳定但绝非绝对。如果游戏开发者使用了特殊的编译选项或链接了自定义的引擎库偏移可能会变。因此特征码扫描永远是比硬编码偏移更可靠的方法。虚幻智能指针TSharedPtr, TWeakPtr在Dump对象关系时你可能会遇到这些智能指针。它们内部包含一个对象指针和一个引用控制器。在4.23中直接读取其Object成员即可获取原始指针但要注意其可能为空。7. 常见问题与排查技巧实录在实战中你会遇到各种各样的问题。以下是我踩过的一些坑和解决方法问题现象可能原因排查思路与解决方案Dump出的名字全是乱码或空1.GNames地址错误。2. 索引解析算法错误Block/Entry计算。3. 字符串编码判断错误。1.验证地址用调试器附加游戏手动查看你找到的GNames地址指向的内存是否是一个看起来合理的结构如多个指向其他地址的指针。2.小范围测试不要一次性Dump全部。先手动计算几个已知小索引如0, 1, 2应该对应的名字可能是“None”、“ByteProperty”等对比你的输出。3.检查编码手动查看FNameEntry内存看第一个字节的标志位并尝试分别按ANSI和Wide去解读字符串。注入DLL后游戏崩溃1. 函数原型或调用约定错误。2. 访问了无效内存。3. DLL初始化代码有问题。1.仔细核对反汇编确保你的函数指针定义参数类型、数量、调用约定与二进制中的函数完全匹配。x64下注意栈平衡。2.使用异常处理在关键的读写内存操作周围加上__try/__except或SEH捕获访问违例记录错误地址。3.简化DLLDllMain中尽量不做复杂操作。将主要逻辑移到导出的函数中由注入器在合适的时机调用。外部Dump速度极慢频繁调用ReadProcessMemory且每次读取数据量太小。批量读取不要为每个字符串单独调用ReadProcessMemory。例如可以一次性读取整个FNameEntry块到本地缓冲区然后在缓冲区中解析。对于GObjects数组也是如此先批量读取对象指针数组。部分对象名称为空或重复1. 对象已被垃圾回收GC指针悬空。2.FName的Number不为0表示带数字后缀的重复名称如“Player_1”。1.有效性过滤在读取对象前检查指针是否在合理的模块内存范围内。可以尝试读取对象的VfTable指针看是否指向游戏模块内的合法地址。2.处理Number完整的名称输出应该包含FName.Number。如果Number 0需要在字符串后附加_Number。特征码在新游戏更新后失效游戏模块的代码或数据布局发生了变动。更新特征码重新分析新版本二进制文件。建立自己的特征码库并记录每个特征码对应的引擎版本和上下文如所在函数名。使用更稳定的特征比如访问GNames的函数序言prologue代码通常比访问GNames本身的指令更稳定。最后的个人体会UE4逆向尤其是像GetName这样的基础函数逆向是一个从模糊到清晰不断假设、验证、修正的过程。不要指望一蹴而就。最有效的方法是“对比学习”找一个已知可用的、开源的UE4 Dumper针对相近版本仔细阅读它的代码看它如何定位和解析GNames然后用自己的理解去实现一遍并在自己编译的UE4编辑器项目中调试验证。当你能够准确Dump出自己测试项目中的所有类名时那份成就感以及由此打开的游戏逆向大门会让你觉得所有的折腾都是值得的。记住安全第一合法使用深度钻研技术本身带来的乐趣远大于将其用于不当途径。
返回列表