UE4逆向实战:手动定位GName与DumpName技术详解
2026/8/3 12:57:19 网站建设 项目流程

1. 项目概述:为什么我们要动UE4的GName?

如果你正在研究UE4游戏,无论是为了制作Mod、分析游戏逻辑,还是进行安全研究,那么“GName”和“DumpName”这两个词对你来说绝对不陌生。它们就像是进入UE4引擎内部世界的大门钥匙。简单来说,GName是虚幻引擎内部管理所有字符串名称(比如类名、函数名、属性名、资源路径)的核心全局表;而DumpName,就是我们通过技术手段把这个表里的所有字符串名称“倾倒”出来,形成一个可读的列表文件的过程。

为什么这件事如此重要?想象一下,你面对一个编译后的UE4游戏,所有的C++类名、蓝图节点名在二进制文件里都变成了晦涩的内存地址和索引。没有这些字符串符号,逆向工程就像在黑暗的迷宫里摸索。一旦你拿到了GName表,就等于获得了一张标注了所有房间名称的地图。你可以通过一个函数地址,反向查找到它的函数名;可以通过一个对象的虚表,知道它属于哪个类。这对于分析游戏对象结构、定位关键函数、甚至是制作外接设备映射(比如把方向盘、飞行摇杆映射到游戏操作)都至关重要。

网上有很多现成的工具和脚本号称能一键Dump,但在实战中,尤其是在对付一些做过保护的、或者非标准编译的UE4游戏时,它们经常失灵。这时候,掌握手动定位GName的方法,就成了解决问题的终极底牌。今天,我就以Unreal Engine 4.23版本(及以下)为例,手把手带你用最经典的逆向工具Cheat Engine(CE),像侦探一样,一步步在内存中“搜”出GName的地址,并完成Dump。这个方法不依赖特定游戏版本,通用性强,是每个想深入UE4逆向的开发者都应该掌握的硬核技能。

2. 核心思路与工具准备:逆向不是瞎蒙

在开始“搜内存”之前,我们必须理解背后的原理。盲目搜索只会浪费大量时间。UE4的GName通常由一个名为FNamePoolTNameEntryArray的结构管理(不同版本有差异,4.23及以下常用的是TNameEntryArray结构的GNames)。它是一个存储了所有FNameEntry(名称条目)的数组。我们的目标就是找到这个全局数组的地址。

我们的核心思路是利用FName结构本身的特性。在UE4中,一个FName并不直接存储字符串,而是存储了一个索引(FNameEntryId),通过这个索引去GNames数组中查找对应的字符串内容。同一个字符串在全游戏中有唯一的FNameEntryId。我们可以利用这个“唯一索引”的特性,通过CE进行对比查找。

所需工具清单:

  1. Cheat Engine (CE): 主力的内存扫描和调试工具。建议使用7.4或7.5版本,稳定性好。
  2. 目标UE4游戏: 一个使用UE4 4.23或更早版本开发的游戏进程。为了演示,你可以用UE4 4.23自己打包一个空白项目,这样地址偏移更标准。
  3. 一个调试器(可选但推荐): 如x64dbg或Immunity Debugger。当CE的扫描遇到复杂情况时,调试器可以用于深入分析。
  4. 文本编辑器: 用于记录和整理找到的地址和偏移。

注意:本文所有技术仅用于学习、研究和安全测试目的,请务必在合法授权的环境下进行操作,切勿用于破坏游戏平衡或侵犯他人权益。

前期重要准备:

  • 关闭所有不必要的程序,减少内存干扰。
  • 以管理员身份运行CE,确保有足够的权限访问目标进程内存。
  • 熟悉CE的基本操作: 如何附加进程、首次扫描、再次扫描、查看内存区域、找出是什么访问了该地址等。

3. 实战第一步:定位GName的“线索”——FName的实例

我们不可能直接去搜“GName”这个字符串,因为它是一个符号,在发布版本中不存在。我们需要找到一个“跳板”,这个跳板就是游戏中实实在在存在的FName对象。

3.1 寻找稳定的FName字符串引用

在游戏中,有一些字符串是绝对稳定、从一开始就加载并且不会改变的。例如:

  • 核心的UObject类名:"Object","Actor","Pawn","Character"
  • 引擎内部模块名:"CoreUObject","Engine"
  • 一些非常基础的属性名:"RootComponent","PlayerController"

我们的策略是:先找到这些字符串在内存中的地址,然后回溯引用它的FName对象,最后从FName对象中提取出索引,再通过索引去定位存储所有字符串的数组(GName)

操作步骤:

  1. 用CE附加到目标游戏进程。
  2. 选择扫描类型为“字符串”,并勾选“Unicode”或“UTF-8”(UE4内部字符串通常是UTF-16或ANSI,但存储的FName字符串一般是ANSI)。为了稳妥,可以先从“字符串”类型开始。
  3. 在数值框里输入一个确定的字符串,比如"Object",然后进行首次扫描。
  4. 你会得到大量包含"Object"这个字符串的地址。这很正常,因为内存中可能有很多地方存了这个字符串(比如动态生成的、资源里的)。我们需要找到那个属于FNamePool/NameEntry的稳定实例。通常,这些字符串在内存中会连续排列,你可以通过查看内存区域,观察附近是否有其他熟悉的类名(如Actor,Pawn等)来辅助判断。找到一个你认为可能是FNameEntry字符串的地址,记下来,假设为String_Object_Addr

3.2 找出访问该字符串的代码

光有字符串地址还不够,我们需要知道是哪个FName的索引在用它。

  1. 在CE的地址列表中,右键点击你找到的String_Object_Addr,选择“找出是什么访问了这个地址”。
  2. 回到游戏里进行一些操作(比如移动、打开菜单),让游戏逻辑运行起来。此时,CE的调试窗口会列出所有读取或写入该字符串地址的汇编指令及其所属的模块地址。
  3. 我们需要寻找的是那种通过一个基地址加偏移来读取字符串的指令。例如,你会看到类似这样的指令:
    mov rcx, [rax+0x10] ; rcx 现在指向字符串
    或者更关键的,寻找一个将索引(通常是一个整数)作为参数传入某个函数的调用。比如:
    call UE4Module.dll+XXXXX ; FName::ToString 之类的函数 ; 调用前,ecx/rdx或栈上可能存放着一个代表索引的整数
  4. 记录下这些指令的地址。我们的目标是找到那个作为索引的整数值被加载的地方。你可能需要在这些指令上下断点,然后观察寄存器和栈的变化,来确认哪个值是FName的索引。

4. 关键推导:从FName索引到GNames数组

假设经过上一步的调试,我们确定了一个事实:当游戏要显示"Object"这个名称时,它会使用一个索引值,比如我们观察到是0x0C(十进制12)。同时,我们通过调试发现,有一个全局的数组指针,我们称之为GNames,游戏代码会这样使用:GNames[Index]来获取字符串地址。

4.1 通过两个已知字符串确定数组结构

仅凭一个索引,我们无法确定GNames的地址和元素大小。这里需要一个经典的“两点确定一条直线”的方法。

  1. 重复第3步,再找一个稳定的字符串,比如"Actor"。通过同样的“找出是什么访问了这个地址”的方法,确定它的FName索引。假设我们找到"Actor"的索引是0x2A(十进制42)。
  2. 现在我们有两个关键数据对:
    • 索引0x0C-> 字符串地址String_Object_Addr
    • 索引0x2A-> 字符串地址String_Actor_Addr
  3. 如果GNames是一个简单的指针数组(每个元素是一个FNameEntry*,占8字节),那么:GNames + (Index * 8) = 字符串指针地址我们可以列出方程来解GNames的基地址。但UE4的TNameEntryArray结构可能更复杂,它可能包含一个块分配器。不过对于4.23及以下的版本,一个常见的模式是:GNames是一个二级指针,指向一个FNameEntry**的数组。

4.2 使用Cheat Engine进行指针扫描(Pointer Scan)

这是CE里非常强大的一个功能,可以帮我们系统地找到指向某个地址的所有可能指针链。

  1. 我们已经有了String_Object_AddrString_Actor_Addr
  2. 在CE中,打开“指针扫描”工具。
  3. 首先对String_Object_Addr进行指针扫描。设置一个合理的最大偏移(比如0~1000)和深度(3~5)。扫描会得到一系列可能指向该字符串地址的指针路径,例如:[Game.exe+0x123456] -> 0x20 -> 0x8 = 字符串地址这表示在Game.exe+0x123456这个地址存储的值,加上0x20偏移,再取该地址的值,加上0x8偏移,最终指向我们的字符串。
  4. String_Actor_Addr也进行同样的指针扫描。
  5. 关键比对:对比两次指针扫描的结果,寻找共同的基地址模块和相似的偏移模式。因为"Object""Actor"都来自同一个GNames数组,所以指向它们的指针链很可能共享一个顶层的基地址(即GNames数组本身的地址),只是中间的索引偏移不同。
  6. 例如,你可能会发现两条链:
    • 链A (Object):[UE4Core.dll+0xABCD00] -> 0x0 -> [0x60] -> 0x0 = String_Object_Addr
    • 链B (Actor):[UE4Core.dll+0xABCD00] -> 0x0 -> [0x60] -> 0x18 = String_Actor_Addr注意,UE4Core.dll+0xABCD00是共同的,-> 0x0 -> [0x60]这部分也是共同的,最后的偏移0x00x18不同。这个共同的UE4Core.dll+0xABCD00就极有可能是GNames相关的全局指针。而最后的偏移差0x18 - 0x0 = 0x18(十进制24),正好等于我们两个索引的差(0x2A - 0x0C) = 0x1E(十进制30)乘以每个元素的大小?这里需要计算验证。如果每个元素是8字节指针,30*8=0xF0,对不上0x18。这说明数组结构可能不是简单的指针数组。

4.3 分析内存,验证GNames结构

找到可疑的地址(比如上面的UE4Core.dll+0xABCD00)后,在CE的内存浏览器中查看它。

  1. 跳转到该地址。它可能存储着另一个地址(一个指针),我们称之为GNamesArrayPtr
  2. 跳转到GNamesArrayPtr。你应该能看到一个巨大的数组。数组的每个元素是什么?在GNamesArrayPtr + (Index * ElementSize)的位置,存储的应该是对应FNameEntry的指针或直接是结构体。
  3. 对于4.23版本,常见的TNameEntryArray结构,其GNames变量可能直接就是一个FNameEntry**的数组。也就是说,GNames本身就是一个指针,指向一个FNameEntry*数组。GNames[0]就是第一个FNameEntry的地址,GNames[1]是第二个,以此类推。
  4. 为了验证,用我们找到的索引去计算:
    • 计算GNamesArrayPtr + (Index_Object * 8),查看该地址存储的值,是否等于String_Object_Addr
    • 计算GNamesArrayPtr + (Index_Actor * 8),查看该地址存储的值,是否等于String_Actor_Addr。 如果都匹配,那么恭喜你,GNamesArrayPtr就是我们要找的GNames数组的地址,而UE4Core.dll+0xABCD00就是指向它的全局指针。

实操心得:这个过程可能需要反复尝试和验证。不要只依赖两个字符串,用第三个(如"Pawn")甚至第四个字符串进行交叉验证,能极大地提高准确性。有时候,游戏会有多个FName相关的全局变量(如GNames用于静态名称,GEngineNames等),需要根据上下文判断哪个是我们需要的。

5. 编写脚本与DumpName:将内存数据变为文本

一旦我们确定了GNames数组的地址(假设我们最终找到的静态地址是GNamesPtr = Game.exe + 0x1234560),并且知道它是一个FNameEntry*的数组,每个元素8字节,那么Dump就变成了一个简单的遍历和读取字符串的过程。

5.1 使用Cheat Engine的Auto Assembler功能

CE内置的Auto Assembler允许我们编写简单的汇编脚本来操作内存。

  1. 在CE中,点击“内存查看窗口”的“工具”菜单,选择“Auto Assembler”。
  2. 点击“模板”,选择“代码注入”。这会生成一个框架。
  3. 我们需要编写一段脚本,遍历GNames数组,读取每个FNameEntry*,然后解析出字符串,并写入文件。由于CE的AA脚本处理文件I/O比较麻烦,一个更简单的方法是:将字符串地址和内容输出到CE的日志窗口,然后从日志中复制保存

下面是一个概念性的脚本框架(注意:这是伪代码逻辑,实际需要根据确切的内存结构调整):

[ENABLE] // 假设: // GNamesArray 地址已经找到,放在一个变量里,例如 alloc(GNamesBase, 8) // 我们知道数组的大概大小,或者可以通过遍历直到遇到空指针来判断结束。FNameEntry的数量可能成千上万。 // FNameEntry 的结构:通常前2字节是字符串长度(可能不包含结尾null),然后是ANSI字符串。 alloc(dumpName, 1024) alloc(currentIndex, 4) alloc(stringAddr, 8) alloc(logBuffer, 256) registersymbol(dumpName) dumpName: // 这里应该是你的汇编代码,实现以下逻辑: // for (int i=0; i<MaxCount; i++) { // FNameEntry* entry = *(FNameEntry***)(GNamesBase + i*8); // if (entry == null) continue; // short len = *(short*)entry; // 读取长度 // char* str = (char*)(entry + 2); // 字符串起始位置 // // 将 i 和 str 格式化输出到某个地方(如写入一个内存区域,或调用OutputDebugString) // } // 由于汇编较复杂,更实际的做法是... [DISABLE] // 清理代码 dealloc(dumpName) unregistersymbol(dumpName)

5.2 更实用的方法:使用Lua脚本

对于复杂的逻辑和文件操作,CE的Lua脚本引擎是更好的选择。你可以通过CE的Lua接口来读取内存、计算、并直接写入文件。

  1. 在CE中,打开“表格”菜单,选择“显示Cheat Table Lua脚本”。
  2. 在Lua脚本窗口中,你可以编写这样的脚本:
local gnamesPtr = 0x1234560 -- 替换为你找到的GNames静态地址(相对于模块基址的偏移) local moduleBase = getAddress("Game.exe") -- 获取模块基址 local gnamesBase = readPointer(moduleBase + gnamesPtr) -- 读取GNames数组的基地址 local outputFile = io.open("DumpedNames.txt", "w") local index = 0 local maxEntries = 2000000 -- 设置一个足够大的上限,防止无限循环 while index < maxEntries do -- 计算当前FNameEntry*的地址 local entryPtrAddr = gnamesBase + (index * 8) local entryPtr = readPointer(entryPtrAddr) if entryPtr == nil or entryPtr == 0 then -- 空指针,可能是数组末尾,但也可能是间隙,我们选择跳过 index = index + 1 goto continue end -- 读取FNameEntry(假设结构:int16长度 + ANSI字符串) local len = readShort(entryPtr) -- 读取字符串长度(2字节) if len <= 0 or len > 1024 then -- 简单的合理性检查 index = index + 1 goto continue end local str = readString(entryPtr + 2, len, true) -- 从长度后开始读取字符串,true表示ANSI if str then outputFile:write(string.format("[%06d] %s\n", index, str)) end ::continue:: index = index + 1 end outputFile:close() print("Dump完成!")
  1. 运行这个Lua脚本,它就会遍历GNames数组,将索引和对应的名称字符串写入到DumpedNames.txt文件中。

注意事项:上述Lua脚本中的内存结构(FNameEntry开头2字节是长度)是UE4早期版本的一种常见布局,但并非绝对。在4.23中,FNameEntry可能是一个更复杂的结构体,包含比较计数、哈希值等。你可能需要根据实际情况调整读取偏移。最可靠的方法是,在内存浏览器中手动分析几个已知的FNameEntry地址,观察其内存布局,确定长度和字符串的准确位置。

6. 常见问题排查与高阶技巧

在实际操作中,你几乎一定会遇到各种问题。这里记录一些典型的坑和解决方法。

6.1 扫描不到字符串或字符串地址不稳定

  • 原因1:字符串编码。UE4可能使用宽字符(UTF-16)。在CE首次扫描时,尝试选择“字符串”类型下的“Unicode”选项。
  • 原因2:字符串被压缩或混淆。一些安全措施会加密字符串。你需要先找到解密函数,或者在内存中寻找解密后的瞬间。可以尝试扫描字符串的哈希值(如CRC32、FNV),而不是明文。
  • 原因3:FName使用哈希直接比较,很少访问字符串本身。这种情况下,“找出是什么访问了这个地址”可能收效甚微。你需要寻找调用FName::ToStringFName::GetDisplayNameEntry()等函数的地方,在这些函数入口下断点来捕获索引。

6.2 指针扫描结果太多,无法找到共同模式

  • 策略:增加指针扫描的“深度”和“偏移”范围,但不要太大,否则结果爆炸。更有效的方法是,先通过调试找到至少一条确定的、从GNames到具体字符串的访问指令链,然后根据这条链的特征(如固定的模块偏移)去过滤指针扫描的结果。
  • 使用“指针映射”功能:CE的指针扫描器可以生成一个.map文件,然后用“指针扫描”对话框中的“指针映射”功能来筛选,只显示与特定模块相关的指针路径。

6.3 确定GNames地址后,Dump出来的名字是乱码或不全

  • 原因1:FNameEntry结构分析错误。这是最常见的问题。FNameEntry在4.23版本可能不是简单的长度+字符串。它可能有一个头部结构,包含:
    // 一种可能的结构 struct FNameEntry { int32 ComparisonId; // 或 Hash int32 Index; char Name[1]; // 柔性数组,实际长度由分配决定 };
    你需要用已知的字符串地址,在内存浏览器中向前多看几十个字节,观察规律。看看字符串前面是否有固定的模式(如重复的魔术字、长度值、索引值)。
  • 原因2:字符串非ANSI。可能是UTF-8。尝试用readString(..., ..., false)(最后一个参数false表示UTF-8)来读取。
  • 原因3:GNames数组不是单层。可能存在分块(Chunk)结构。GNames指向一个TNameEntryArray,它内部有多个FNameEntry*的块(Blocks)。你需要遍历所有块。这时代码会更复杂,需要先读取TNameEntryArray的结构体,获取Blocks指针和BlockSize等信息。

6.4 针对不同UE4版本的调整

  • 4.20以下GNames的结构相对简单,更接近传统的TArray<FNameEntry*>
  • 4.20 ~ 4.25:引入了FNamePool的雏形或变体,结构可能开始复杂化。重点寻找FNamePool::Get()FName::GetDisplayNameEntry()函数的实现,逆向这些函数是找到GNames关键地址的捷径。
  • 通用思路:无论版本如何变化,核心目标不变——找到将FName索引解析为字符串的代码。在IDA或GHIDRA中静态分析游戏主模块或UE4核心模块(如CoreUObject-*.dll),搜索字符串"Accessing FName with invalid index""Invalid FName index"等错误信息,这些信息通常就在FName::ToString函数附近,是极好的切入点。

6.5 利用IDAPython或GHIDRA脚本进行自动化分析

对于经常需要分析不同UE4游戏的同学,手动CE扫描毕竟效率较低。更进阶的做法是:

  1. 用IDA Pro或GHIDRA加载游戏模块。
  2. 通过特征码或字符串引用,定位到FName::ToStringFNamePool::Resolve函数。
  3. 编写IDAPython或GHIDRA脚本,分析该函数的交叉引用,找到读取全局GNamesFNamePool指针的指令。
  4. 脚本自动计算并输出这个全局指针的地址偏移。 这种方法一旦成型,对于同引擎版本的游戏,几乎可以做到秒级定位。

手动用CE定位GName的过程,本质上是一次对程序内存布局和数据结构的深度侦查。它要求你不仅会使用工具,更要理解工具背后的原理和引擎的运行机制。每一次成功的定位,都是对逆向思维和耐心的一次锤炼。当你面对一个全新的、没有符号的UE4游戏,能够凭借这套方法独立挖出GName时,那种成就感远非使用现成工具可比。这套方法的价值在于其通用性和底层性,它不依赖于任何外部签名或版本库,是真正意义上的“硬碰硬”技术。掌握了它,你就拥有了解开绝大多数UE4游戏名称迷雾的万能钥匙。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询