UE4 SDKdump原理及魔改解决办法

之前只会使用工具去dump对其原理没有细究 魔改就直接G了,本研究基于UE4Dumper/jni/Mem.h at master · revercc/UE4Dumper项目进行分析

后半段其实写的一坨,最核心的主要是拿源码的数据结构去对照,这里我用的AI,总之具体的寻找方法肯定是找对应的ue4小版本的数据结构去对照

DumpString

1
./ue4dumper --strings --newue+ --gname 0x4E2EC00 --package com.tencent.ace.gamematch2024final --output /data/local/tmp

这条命令的本质是通过 process_vm_readv 系统调用读取游戏进程的内存,按照 UE4.25+ 的 FNamePool 数据结构格式,遍历所有字符串池块,解析每个 FNameEntry 的 header + 字符串数据,最终导出引擎内部注册的所有名字(类名、属性名、函数名等)。

process_vm_readv跨进程读取数据

一:find_pid(),找到 cmdline == “com.tencent.xxx” 的进程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
pid_t find_pid(const char *process_name) {
int id;
pid_t pid = -1;
DIR *dir;
FILE *fp;
char filename[32];
char cmdline[256];

struct dirent *entry;
if (process_name == NULL) {
return -1;
}
dir = opendir("/proc");
if (dir == NULL) {
return -1;
}
while ((entry = readdir(dir)) != NULL) {
id = atoi(entry->d_name);
if (id != 0) {
sprintf(filename, "/proc/%d/cmdline", id);
fp = fopen(filename, "r");
if (fp) {
fgets(cmdline, sizeof(cmdline), fp);
fclose(fp);

if (strcmp(process_name, cmdline) == 0) {
/* process found */
pid = id;
break;
}
}
}
}

closedir(dir);
return pid;
}

得到target_pid

二:找到 libUE4.so 的基地址

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
kaddr get_module_base(const char *module_name) {
FILE *fp;
kaddr addr = 0;
char filename[32], buffer[1024];
snprintf(filename, sizeof(filename), "/proc/%d/maps", target_pid);
fp = fopen(filename, "rt");
if (fp != nullptr) {
while (fgets(buffer, sizeof(buffer), fp)) {
if (strstr(buffer, module_name)) {
#if defined(__LP64__)
sscanf(buffer, "%lx-%*s", &addr);
#else
sscanf(buffer, "%x-%*s", &addr);
#endif
break;
}
}
fclose(fp);
}
return addr;
}

找到包含 “libUE4.so” 的行,解析出起始地址 libbase

getRealOffset(0x4E2EC00) = libbase + 0x4E2EC00

0x4E2EC00 是 GNames 在 so 文件中的偏移,加上运行时基地址就是内存中的绝对地址。

0x4E2EC00 为示例

三:pvm()

它用 Linux 的 process_vm_readv 系统调用

1
2
3
4
5
syscall(376,         // ARM32 的 process_vm_readv 系统调用号
target_pid, // 要读的进程
local, 1, // 本进程的 buffer
remote, 1, // 目标进程的地址
0);

不需要 ptrace attach。需要 root 权限

四:DumpStrings()

1
2
3
4
5
6
7
8
9
10
11
12
① 计算 FNamePool 地址
FNamePool = getRealOffset(GNames) + GNamesToFNamePool
↑ 0x28

② 读取池的当前状态
CurrentBlock = Read<uint32>(FNamePool + 0x0) // 当前使用到第几个 Block
CurrentByteCursor = Read<uint32>(FNamePool + 0x4) // 当前 Block 用了多少字节

③ 遍历所有 Block
for BlockIdx = 0 .. CurrentBlock:
DumpBlocks423(每个 Block 的大小 = FNameStride * 65536 = 0x20000)
最后一个 Block 的大小 = CurrentByteCursor

GNamesToFNamePool = 0x28 不是通用的。 这个值是 GNames 全局变量到 FNamePool 内部数据的偏移,不同引擎版本、不同平台(32/64位)、不同游戏(特别是魔改版)都可能不同。

这个偏移存在的原因是,GNames 这个全局符号指向的不是 FNamePool 本身,而是一个包裹它的外层结构(FNamePool 被嵌入在一个更大的对象里,前面有一些元数据或虚函数表指针),所以需要一个偏移跳过那些前缀字段。

更准确地说,之所以要加这个偏移,是因为你拿到的那个GNames 地址并不一定直接等于 FNamePool 结构体起始地址,而可能是:

  1. 指向了一个外层对象
  2. 或者指向了这个对象里的某个成员入口
  3. 而真正的FNamePool在这个对象内部,有一个固定偏移

所以要先跳过前缀,才能落到真正的 FNamePool 上。

FNamePool 是某个大结构里的成员,比如伪代码像这样:

1
2
3
4
5
6
struct Wrapper {
void* vftable;
int some_meta;
char pad[0x10];
FNamePool NamePool;
};

那拿到的符号如果是 Wrapper*,就必须:

1
FNamePool* pool = (FNamePool*)((char*)WrapperPtr + offset);
1
2
3
4
5
6
FNamePool 就像一个笔记本:                                      
├── 第 0 页(Block 0):写了很多个字符串,写满了
├── 第 1 页(Block 1):写了很多个字符串,写满了
├── 第 2 页(Block 2):正在写,还没写满
│ ↑ 写到这里了(CurrentByteCursor)
└── CurrentBlock = 2(当前用到第 2 页)

一个 Block 是一大块连续内存(大小 = FNameStride × 65536 = 0x20000 = 128KB),里面紧密排列着很多个 FNameEntry(字符串条目),一个挨一个

整个 FNamePool 只有一个 CurrentBlock 和 一个 CurrentByteCursor。它们描述的是写入游标的位置

1
2
3
4
5
6
7
FNamePool
├── CurrentBlock (uint32) +0x0
├── CurrentByteCursor (uint32) +0x4
├── Blocks[] (指针数组) +0x8
│ ├── Blocks[0] → [FNameEntry][FNameEntry][FNameEntry]...
│ ├── Blocks[1] → [FNameEntry][FNameEntry][FNameEntry]...
│ └── ...

FNameEntry是什么

1
2
3
4
5
+0x0: int16 Header                                              
├── bit 0: isWide (是否 UTF-16)
├── bit 1-5: 其他标志
└── bit 6-15: 字符串长度 (Header >> 6)
+0x2: char[] 字符串数据 (紧跟 header 后面)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
while (It < End) {                                              
// 1. 读 2 字节 header
int16 Header = Read<int16>(FNameEntry);
int StrLength = Header >> 6; // 取高 10 位 = 字符串长度
bool wide = Header & 1; // 最低位 = 是否宽字符

// 2. 从 FNameEntry + 2 处读取字符串
string str = ReadStr2(FNameEntry + 2, StrLength);

// 3. 写入文件: "[key]: StringName"
// key = (BlockIdx << 16 | Offset) → 就是 FName 的 ID

// 4. 跳到下一个 entry(按 FNameStride=2 对齐)
uint16 bytes = 2 + StrLength * charSize;
It += ALIGN_UP(bytes, 2);
}

通过 process_vm_readv 系统调用读取游戏进程的内存,按照 UE4.25+ 的 FNamePool 数据结构格式,遍历所有字符串池块,解析每个 FNameEntry 的 header + 字符串数据,最终导出引擎内部注册的所有名字(类名、属性名、函数名等)

读取代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
if (isUE423_UE425 || isUE425) {
uint32 Block = index >> 16;
uint16 Offset = index & 65535;

kaddr FNamePool = getRealOffset(Offsets::GNames) + Offsets::GNamesToFNamePool;

kaddr NamePoolChunk = getPtr(
FNamePool + Offsets::FNamePoolToBlocks + (Block * Offsets::PointerSize));
kaddr FNameEntry = NamePoolChunk + (Offsets::FNameStride * Offset);

int16 FNameEntryHeader = Read<int16>(FNameEntry);
kaddr StrPtr = FNameEntry + Offsets::FNameEntryToString;
int StrLength = FNameEntryHeader >> Offsets::FNameEntryToLenBit;

///Unicode Dumping Not Supported Yet
if (StrLength > 0 && StrLength < 250) {
bool wide = FNameEntryHeader & 1;
if (wide) {
return WideStr::getString(StrPtr, StrLength);
} else {
return ReadStr2(StrPtr, StrLength);
}
} else {
return "None";
}
}

上面所讲的是,UE4.23以后,UE4.23 以前TNameEntryArray(数组套数组)

1
2
3
4
5
6
7
8
GNames → TNameEntryArray(指针数组的数组)
├── Chunk[0] → [ptr0, ptr1, ptr2, ..., ptr16383] ← 每个 chunk 16384 个指针
├── Chunk[1] → [ptr16384, ptr16385, ...]
└── ...
每个 ptr 指向一个 FNameEntry:
FNameEntry:
+0x0: 一些索引/哈希字段
+0x8: "Actor\0" ← FNameEntryToNameString = 0x8

特点:

  • 每个 FNameEntry 是独立堆分配的对象,通过指针数组索引
  • 用 FName ID 可以直接随机访问(O(1) 数组下标)
  • 浪费内存(每个 entry 一次 malloc,大量指针)

查找方式:

1
2
3
4
// index = FName ID,比如 0x4E
Chunk = TNameEntryArray[index / 0x4000] // 哪个 chunk
Entry = Chunk[index % 0x4000] // chunk 内第几个指针
string = ReadStr(Entry + 0x8) // 读字符串

DumpObjects

UE4 里几乎所有东西都是 UObject。它是引擎的基类,类似 Java 的 java.lang.Object。

1
2
3
4
5
6
7
8
9
10
标准 UObject (32位):          魔改后:
+0x00: VTablePtr +0x00: VTablePtr
+0x04: ObjectFlags +0x04: ObjectFlags
+0x08: InternalIndex +0x08: InternalIndex
+0x0C: ClassPrivate +0x0C: ??? (插入 4 字节)
+0x10: FNameIndex +0x10: ??? (插入 4 字节)
+0x14: OuterPrivate +0x14: ClassPrivate
+0x18: FNameIndex
+0x1C: ??? (可能是对齐)
+0x20: OuterPrivate

这里的魔改后针对后面的题目,这里是32位的

第一步:获取 Object 总数

1
2
3
4
 int32 GetObjectCount() {
// 读 GUObjectArray + 0x10 + 0xC 处的 int32
// 即 FUObjectArray.ObjObjects.NumElements
}

GUObjectArray 是UE4 引擎里的一个全局变量,类型是 FUObjectArray,作用是登记所有活着的 UObject 实例。

image-20260408171558895

image-20260408194103960

第二步:通过 index 取出每个 UObject 指针

这里 4.23+ 和 4.23 以下有区别

UE4.23+ 路径(chunked 分块数组)

1
2
3
4
5
6
7
8
9
kaddr GetUObjectFromID(uint32 index) {
// TUObjectArray 是一个"数组的数组"(分块)
kaddr TUObjectArray = getPtr(GUObjectArray + 0x10);

// 每个 chunk 存 0x10000 (65536) 个元素
kaddr Chunk = getPtr(TUObjectArray + (index / 0x10000) * PointerSize);
// chunk 内定位:每个 FUObjectItem 大小 0x14,前 0x4 字节是 padding
return getPtr(Chunk + 0x4 + (index % 0x10000) * 0x14);
}

UE4.23 以下路径(简单的一维数组)

1
2
3
4
5
6
7
8
kaddr GetUObjectFromID(uint32 index) {
kaddr FUObjectArray = getRealOffset(Offsets::GUObjectArray);
// 直接一级指针,取出连续数组
kaddr TUObjectArray = getPtr(getPtr(FUObjectArray + 0x10));

// 没有分块,直接 index * ItemSize
return getPtr(TUObjectArray + index * FUObjectItemSize);
}

image-20260408171902676

image-20260408171918447

第三步:读取每个 UObject 的信息

对每个有效的 UObject 指针:

1
2
3
4
5
6
7
for (int32 i = 0; i < ocount; i++) {
kaddr uobj = GetUObjectFromID(i); // 从全局数组取指针
if (UObject::isValid(uobj)) { // 检查指针非空、name>0、class>0
UObject::getName(uobj); // 读 +0x18 的 FNameIndex → 查 FNamePool
UObject::getClassName(uobj); // 读 +0x14 的 ClassPrivate → 再读它的 FNameIndex
}
}

DumpSDK

SDKu vs SDKw 的区别 ,本质区别只有一个:怎么找到要 dump 的类。

SDKu (–sdku, DumpSDK):通过 GUObjectArray ,需要:GNames + GUObjectArray,遍历 GUObjectArray 中所有对象:

1
2
3
4
for i in 0..ObjectCount:
uobj = GetUObjectFromID(i)
class = uobj->ClassPrivate // 这个对象是什么类
writeStruct(class) // dump 这个类的结构

就是暴力遍历全局对象数组,对遇到的每个对象,dump 它的类定义。

SDKw (–sdkw, DumpSDKW):通过 GWorld 需要: GNames + GWorld

1
2
3
4
GWorld → UWorld* → dump UWorld 类
→ PersistentLevel → ActorList
for each actor:
dump actor 的类

从 GWorld 出发:

它从当前世界(地图)的 Actor 列表入手,只 dump 当前关卡里活着的 Actor 的类。

优点:不需要 GUObjectArray 地址;更快(只处理当前关卡的 Actor)。

缺点:覆盖面不全,只能看到当前地图里的东西。

对每个类,writeStruct 做了什么, 不管 SDKu 还是SDKw,找到类之后都调用同一个函数 writeStruct()。

1
2
3
4
5
6
7
8
9
10
writeStruct(class):                                             
// 沿继承链向上遍历
currStruct = class
while currStruct 有效:
dump currStruct 的字段和函数 ← 这里有 4.23 以下/以上的区别
currStruct = currStruct->SuperStruct // 父类

// 递归 dump 引用到的其他类
for each 引用到的类型(如 ObjectProperty 引用的类):
writeStruct(那个类)

UE4.23 以下 vs 4.23+ 的区别

UE4.23 以下:字段和函数混在一起

1
2
3
4
5
6
7
8
9
10
11
12
13
UStruct (比如 Actor 类)

└─ Children (UField* 链表)
↓ UField::getNext()
[IntProperty "Health"] ← 是 UObject 子类

[FloatProperty "Speed"] ← 是 UObject 子类

[Function "TakeDamage"] ← 也是 UObject 子类

[BoolProperty "bDead"] ← 是 UObject 子类

nullptr

所有属性(Property)和函数(Function)都继承自 UObject,混在同一条 Children 链表里。

代码(writeStructChild,行 163)

1
2
3
4
5
6
kaddr child = UStruct::getChildren(clazz);  // 一条链表         
while (child) {
string cname = UObject::getClassName(prop); // 用 UObject 方式取名字
// ... 判断是 Property 还是 Function,统一处理
child = UField::getNext(child); // 同一条链遍历
}

UE4.23+(特别是 4.25+):字段和函数分开了 ,Epic 在 4.23 做了重大重构,属性不再是 UObject,变成了轻量级的 FField。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
UStruct (比如 Actor 类)

├─ ChildProperties (FField* 链表) ← 新增!只存属性
│ ↓ FField::getNext()
│ [IntProperty "Health"] ← FField,不是 UObject!
│ ↓
│ [FloatProperty "Speed"] ← FField
│ ↓
│ [BoolProperty "bDead"] ← FField
│ ↓
│ nullptr

└─ Children (UField* 链表) ← 只剩函数了
↓ UField::getNext()
[Function "TakeDamage"] ← 仍然是 UObject 子类

nullptr
1
2
3
4
5
6
7
8
9
if (isUE425) {
// 属性:从 ChildProperties 走 FField 链
writeStructChild423(sdk, UStruct::getChildProperties(currStruct));
// 函数:从 Children 走 UField 链(仍是 UObject)
writeStructChild423_Func(sdk, UStruct::getChildren(currStruct));
} else {
// 4.23以下:属性和函数混在 Children 一条链里
writeStructChild(sdk, UStruct::getChildren(currStruct));
}

image-20260408173843053

DumpSDKw

第一步:从 GWorld 找到 Actor 列表

1
2
3
GWorld → UWorld* → +UWorldToPersistentLevel → ULevel
ULevel → +ULevelToAActors → Actor 指针数组
→ +ULevelToAActorsCount → Actor 数量

需要:UWorldToPersistentLevel、ULevelToAActors、ULevelToAActorsCount

image-20260408201529539

第二步:对每个 Actor 读基本信息

1
2
actor → +UObjectToClassPrivate → UClass(这个 actor 是什么类)
→ +UObjectToFNameIndex → 名字 ID

需要:UObjectToClassPrivate、UObjectToFNameIndex

第三步:writeStruct 遍历类的继承链

1
UClass → +UStructToSuperStruct → 父类 → 父类的父类 → ... → nullptr

需要:UStructToSuperStruct

第四步:dump 属性(UE4.25+ 走 FField 链)

1
2
3
4
5
6
7
UClass → +UStructToChildProperties → FField(第一个属性)
→ +FFieldToName → 属性名
→ +FFieldToClass → 属性类型名
→ +UPropertyToElementSize → 大小
→ +UPropertyToPropertyFlags → 标志
→ +UPropertyToOffsetInternal → 在结构体内的偏移
→ +FFieldToNext → 下一个 FField → ...

需要:UStructToChildProperties、FFieldToName、FFieldToClass、FFieldToNext、UPropertyToElementSize、UPropertyToPropertyFlags、UPropertyToOffsetInternal

第五步:dump 函数(仍是 UField 链)

1
2
3
4
5
UClass → +UStructToChildren → UFunction(第一个函数)
→ +UObjectToFNameIndex → 函数名
→ +UFunctionToFunctionFlags → 是否 static 等
→ +UFunctionToFunc → 函数地址
→ +UFieldToNext → 下一个函数 → ...

需要:UStructToChildren、UFunctionToFunctionFlags、UFunctionToFunc、UFieldToNext

第六步:特殊属性类型(额外读一个指针)

1
2
3
4
5
6
ObjectProperty  → +UObjectPropertyToPropertyClass   → 引用的类
ClassProperty → +UClassPropertyToMetaClass → 元类
ArrayProperty → +UArrayPropertyToInnerProperty → 内部元素类型
MapProperty → +UMapPropertyToKeyProp/ValueProp → 键值类型
BoolProperty → +UBoolPropertyToByteOffset/ByteMask/FieldMask
StructProperty → +UStructPropertyToStruct → 引用的结构体

魔改处理

本部分以Tencent2024游戏安全竞赛的决赛题为例进行分析

Object处理

第零步:现象——Strings 正常,Objects 和 SDK 全是垃圾

1
2
3
4
./ue4dumper --strings --newue+ --gname 0x4E2EC00 --package com.tencent.ace.gamematch2024final --output /data/local/tmp
./ue4dumper --objs --newue+ --gname 0x4E2EC00 --guobj 0x4E533AC --package com.tencent.ace.gamematch2024final --output /data/local/tmp
./ue4dumper --sdku --newue --gname 0x4E2EC00 --guobj 0x4E533AC --package com.tencent.ace.gamematch2024final --output /data/local/tmp --verbose
./ue4dumper --sdkw --newue+ --gname 0x4E2EC00 --gworld 0x4F5C0D0 --package com.tencent.ace.gamematch2024final --output /data/local/tmp

标准dump只有第一条生效,标准FUObjectItem: size=0x10, UObject* 在 +0x0

GUObjectArray = 0x4E533AC

0x4E533AC + 0x10 = 0x4E533BC → TUObjectArray(chunk 指针数组)

0x4E533AC + 0x10 + 0xC = 0x4E533C8 → NumElements(对象总数)

搜GUObjectArray的交叉引用

定位到一些模式,如我们之前所提到的

image-20260408184700438

1
2
3
4
ptr = *(chunk + 20 * (index % 0x10000) + 0xB)                     
// ^^ ^
// stride=20=0x14 offset=B=0xB
标准应该是 16 * index + 0,这里变成了 20 * index + B。

FUObjectItemSize = 0x14

实际上这里还是有问题 因此最好直接看内存 避免静态分析

修完后跑 –objs,不再是 还是只能dump个别object

运行时 debug dump

直接git下拉了这个项目UE4Dumper/jni/Offsets.h at master · revercc/UE4Dumper

在GUObject.h加入debug代码

思路:每个 UObject 内部有一个 uint32 字段叫 FNameIndex,它存的是这个对象的名字在 FNamePool 里的编号。

1
2
3
4
5
6
7
8
假设一个 Actor 对象的 FNameIndex = 0x4E                         
FNameIndex = 0x4E

GetFNameFromID(0x4E)

去 FNamePool 里查编号 0x4E 的字符串

得到 "Actor"

我们不知道 FNameIndex 在 UObject 里的哪个偏移,所以把前 48 字节里的每一个 uint32 都盲猜成 FName ID 去 FNamePool 里查。大部分值(指针、flags)查出来要么是空要么是乱码,但真正的 FNameIndex 那个位置会查出有意义的名字(比如 “Object”、”Actor”、”Class”)。这样就定位到了 FNameIndex 在 +0x18。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
void DumpObjectsDebug(string out) {
ofstream obj(out + "/Objects_debug.txt", ofstream::out);
if (obj.is_open()) {
int32 ocount = GetObjectCount();
cout << "Objects Count: " << setbase(10) << ocount << endl;
if (ocount < 10 || ocount > 999999) {
ocount = 300000;
}
int dumped = 0;
for (int32 i = 0; i < ocount && dumped < 50; i++) {
kaddr uobj = GetUObjectFromID(i);
if (uobj == 0) continue;

// Read 48 bytes raw from each UObject
uint32 raw[12];
for (int j = 0; j < 12; j++) {
raw[j] = Read<uint32>(uobj + j * 4);
}

obj << setbase(16) << "[0x" << i << "] ObjPtr=0x" << uobj << endl;
for (int j = 0; j < 12; j++) {
obj << " +" << setbase(16) << (j*4) << ": 0x" << raw[j];
// Try to resolve as FName index
if (raw[j] > 0 && raw[j] < 0x200000) {
string name = GetFNameFromID(raw[j]); // 用修正后的 stride=0x14 从数组取出指针
if (!name.empty() && name != "None" && name.length() < 256) {
obj << " [FName: " << name << "]";
}
}
obj << endl;
}
obj << endl;
dumped++;

// Also print to stdout for first 10
if (dumped <= 10) {
cout << "[0x" << setbase(16) << i << "] ObjPtr=0x" << uobj << endl;
for (int j = 0; j < 12; j++) {
cout << " +" << setbase(16) << (j*4) << ": 0x" << raw[j];
if (raw[j] > 0 && raw[j] < 0x200000) {
string name = GetFNameFromID(raw[j]);
if (!name.empty() && name != "None" && name.length() < 256) {
cout << " [FName: " << name << "]";
}
}
cout << endl;
}
cout << endl;
}
}
obj.close();
cout << dumped << " Objects debug-dumped to Objects_debug.txt" << endl;
}
}

void DumpObjects(string out) {
uint32 count = 0;
// First run debug dump
DumpObjectsDebug(out);

image-20260408190834622

得出FNameIndex是0x18的偏移,UE4 是开源引擎,UObject 的定义在源码里写死了:

1
2
3
4
5
6
7
8
// UE4 源码 UObjectBase
class UObjectBase {
EObjectFlags ObjectFlags; // +0x04
int32 InternalIndex; // +0x08
UClass* ClassPrivate; // +0x0C (标准)
FName NamePrivate; // +0x10 (标准,FName 的第一个成员就是 index)
UObject* OuterPrivate; // +0x14 (标准)
};

字段顺序不会变,魔改只是在中间插了字节,把偏移往后推了。所以:

  • FNameIndex 确认在 +0x18
  • 它前面那个指针(+0x14)按顺序一定是 ClassPrivate
  • 它后面那个指针(+0x20)按顺序一定是 OuterPrivate

FUObjectItemPadd = 4被试出来了,因为 item 总共就 0x14 字节,UObject* 只可能在 +0x0 或 +0x4 这两个位置,试一下就知道了

image-20260408193831814

修完 FUObjectItem 和 UObject 就能正确 dump Objects.txt 了。具体是这四个值:

1
2
3
4
5
FUObjectItemSize = 0x14    (标准 0x10)  ← 不改这个,数组遍历步长错,取不到 item
FUObjectItemPadd = 0x4 (标准 0x0) ← 不改这个,从 item 里读不到 UObject*
UObjectToFNameIndex = 0x18 (标准 0x10) ← 不改这个,读出来的名字是乱码
UObjectToClassPrivate = 0x14(标准 0x0C) ← 不改这个,读出来的类名是乱码
TUObjectArrayToNumElements = 0xC(标准 0x10)← 不改这个,读不到正确的对象总数
1
2
3
4
5
6
7
8
9
10
// sub_1296C38
if ( dword_4E533C8 > index ) // ← NumElements,用来做越界检查
item = *(dword_4E533BC + ...) + 20 * ... // ← Objects 指针数组
这两个变量的地址:

dword_4E533BC = TUObjectArray 的起始地址(Objects 指针)
dword_4E533C8 = NumElements

偏移 = 0x4E533C8 - 0x4E533BC = 0xC
所以 TUObjectArrayToNumElements = 0xC。

标准值 0x10 意味着 NumElements 在 Objects 指针后面 16 字节,但这个游戏里只隔了 12 字节,说明中间少了 4 字节。

改Offset.h

image-20260408213705902

注意下面也要改

image-20260408213723879

因为这里会覆盖回去

image-20260408213813602

编译:

1
/mnt/d/AndroidNdk/android-ndk-r27d/toolchains/llvm/prebuilt/windows-x86_64/bin/armv7a-linux-androideabi21-clang++ -pie -fPIE -ffunction-sections -fdata-sections -fvisibility=hidden -Wl,--gc-sections -fno-rtti -fno-exceptions -DNDEBUG -I/home/matriy/RE/game/tencent_ctf/2024_end/UE4Dumper2/jni -I/home/matriy/RE/game/tencent_ctf/2024_end/UE4Dumper2/jni/ELF /home/matriy/RE/game/tencent_ctf/2024_end/UE4Dumper2/jni/ELF/ElfReader.cpp /home/matriy/RE/game/tencent_ctf/2024_end/UE4Dumper2/jni/ELF/ElfRebuilder.cpp /home/matriy/RE/game/tencent_ctf/2024_end/UE4Dumper2/jni/kmods.cpp -lz -llog -o /home/matriy/RE/game/tencent_ctf/2024_end/UE4Dumper2/ue4dumper2

因为改的偏移静态分析太难分析了,所以后面分析采用了CE看内存分析偏移的方式

我们先去用CE验证Object

1
2
3
4
5
6
7
8
// UE4 源码 UObjectBase
class UObjectBase {
EObjectFlags ObjectFlags; // +0x04 对象标志:是否是默认对象,是否是 public......
int32 InternalIndex; // +0x08 对象内部索引:这个对象在引擎对象管理器里的序号
UClass* ClassPrivate; // +0x0C 类指针:这个对象是什么类型,指向这个对象所属的 UClass
FName NamePrivate; // +0x10 对象名字:UE4 里对象名一般不是直接存字符串,而是存一个 FName,名字索引
UObject* OuterPrivate; // +0x14 外层对象指针:这个对象属于谁,挂在哪个外层对象下面
};

之前提到的标准的内存布局是这样的

我们现在手机上放上ceserver,然后启动,用adb forward tcp:52736 tcp:52736转发下端口,然后CE网络连接即可,随便找一个Obejct,在内存中转到0xe3116500,然后工具中分析结构,定义新结构

1
2
3
4
5
[0x3]:                              ← InternalIndex = 3,在 GUObjectArray 里排第 3
Name: MaterialExpression ← 这个对象的名字
Class: Class ← 这个对象的类型是 "Class"
ObjectPtr: 0xe3116500 ← 这个对象本身在内存中的地址
ClassPtr: 0xc035ba40 ← ClassPrivate 指针指向的地址

image-20260409101013881

image-20260409102521424

可以看到0x8处就是对象内部索引,这里的一些符号进制要自己处理下

我们可以得出

1
2
3
4
5
6
7
8
9
MaterialExpression (0xe3116500):        "Class" UClass (0xc035ba40):
+0x00: P->BFBFC198 VTable +0x00: P->BFBFC198 VTable(同一个!都是 UClass)
+0x04: 67 (0x43) ObjectFlags +0x04: 67 (0x43) ObjectFlags
+0x08: 3 InternalIndex +0x08: 0x5F InternalIndex
+0x0C: ... 插入字节 +0x0C: 0 插入字节
+0x10: ... 插入字节 +0x10: 0 插入字节
+0x14: P->C035BA40 ClassPrivate +0x14: P->C035BA40 ClassPrivate ← 指向自己!
+0x18: 48DAA FNameIndex +0x18: 384 (0x180) FNameIndex
+0x20: ... OuterPrivate +0x20: P->B83C5D40 OuterPrivate
1
2
3
4
5
[0x25f]:
Name: Class
Class: Class
ObjectPtr: 0xc035ba40
ClassPtr: 0xc035ba40

Objects.txt 里大部分 Class: Class 的条目都是类定义本身(比如 Actor、PlayerController、Object 这些都是类定义)。而 Class: Actor 的条目才是一个具体的 Actor 实例。

用 dd 调用 GetFNameFromID 的逻辑手动查,FNameIndex = 0x180,按 UE4.23+ 的编码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
Block  = 0x180 >> 16 = 0      (高 16 位)
Offset = 0x180 & 0xFFFF = 0x180(低 16 位)

FNamePool 地址 = libbase + GNames + GNamesToFNamePool

libbase = 0xbb206000 (从 /proc/PID/maps 读到的libUE4.so在内存中的加载基地址,)
GNames = 0x4E2EC00 (命令行传入的偏移)
GNamesToFNamePool = 0x28 (patchUE423_32 里设的)

FNamePool = libbase + GNames + GNamesToFNamePool
= 0xbb206000 + 0x4E2EC00 + 0x28
= 0xc0034c28

FNamePool = 0xc0034c28
Block0 pointer at = 0xc0034c30
Block0 = 0xd353b000

看到了地址 0xd353b300:
header: 0x016c → 长度 = 0x016c >> 6 = 5("Class" 正好 5 个字符)
字符串: "Class"

所以整个链路是:
CE 里看到 +0x18 = 0x180
→ Block=0, Offset=0x180
→ FNamePool.Blocks[0] + 0x180*2 = 0xd353b300
→ 读出 header + "Class"

image-20260409104359862

SDK处理

dumpObject完去dumpSDK发现全是 0xff8d87b8 这种垃圾值,说明完全读错了

SDK.txt 需要输出每个类的内部结构——有哪些字段、什么类型、偏移多少、有哪些函数

静态分析乏力

Ustruct,先介绍下结构

1
2
3
4
5
6
UObject           ← 所有东西的基类
└─ UField ← 反射系统的基类(多了一个 Next 指针)
└─ UStruct ← "有字段的结构"(多了 SuperStruct、Children 等)
├─ UClass ← 类定义(如 Actor、PlayerController)
├─ UScriptStruct ← 结构体定义(如 FVector、FRotator)
└─ UFunction ← 函数定义(如 TakeDamage)

image-20260408203101043

image-20260408203122702

image-20260409112355680

image-20260408203214792

我们关注两个类,找到 Actor 和 Object 的地址

1
2
Actor 类:  0xe3116dc0
Object 类: 0xe3122580

然后找 SuperStruct,Actor 的 SuperStruct 一定指向 Object(因为 class AActor : public UObject)。

在 CE 里跳到 0xe3116dc0(Actor),从 +0x24 开始往下扫,找哪个位置的指针值 = 0xe3122580(Object 的地址)。

因为 Actor(0xe3116dc0)在内存里不只是一个 UObject,它是 UClass 实例,继承了多层结构

image-20260409105255169

这些全在同一个对象(0xe3116dc0)里,不是不同的对象。UClass 继承自 UStruct 继承自 UField 继承自 UObject,所以一个 UClass 实例的内存里,从头到尾依次排列着所有父类的字段。

从 +0x24 开始找的原因:UObject 的字段到 +0x20(OuterPrivate)就结束了,后面是 UField 和 UStruct 的字段。SuperStruct 是 UStruct 的字段,一定在 +0x20 之后。所以从 +0x24 开始扫,找 Object 的地址出现在哪。

image-20260409105438450

SuperStruct = 0x40

找 ChildProperties 和 Children,在 +0x40 之后,找非零的指针值,它们可能是 ChildProperties 或 Children,SuperStruct 后面第一个指针,跟进去是 FField

在这个指针内能查到FField(能在 +0x14 处查到属性名),可以验证是ChildProperties

然后找UField Chilren,后面的每个指针都去 Objects.txt 里搜,看哪个是 Function

image-20260409110525965

1
2
3
4
5
6
7
8
9
10
11
+0x44 (FField ChildProperties):
+0x00: bfc041ec ← FFieldClass 指针
+0x04: c0058230 ← 某个指针
+0x10: b5c078e0 ← FFieldToNext(下一个 FField)
+0x14: 00096677 ← FFieldToName → FName ID,查一下

+0x6C (UField Children):
+0x00: bfbfc378 ← VTablePtr(是 UObject!因为 UFunction 继承自 UObject)
+0x04: 00000041 ← ObjectFlags
+0x08: 00001636 ← InternalIndex
+0x18: 0004924b ← FNameIndex

image-20260409110624921

后面的一些偏移是更细节的了,方法是一样的,先读第一个 FField(0xb5c07880)的完整内存

1
2
+0x10: b5c078e0  ← Next(下一个 FField)
+0x14: 00096677 ← FName = "PrimaryActorTick"

现在验证 FFieldToClass(+0x00 还是 +0x04)

FName 0x48 = “StructProperty”。PrimaryActorTick 的类型确实是 StructProperty。

所以 FFieldToClass = 0x04,指针指向 FFieldClass 对象,FFieldClass 的第一个字段是类型名的 FName ID。

1
2
3
4
FFieldToClass 就是 FField 里的一个字段,存的是类型的属性。
FField "PrimaryActorTick":
FFieldToClass → "StructProperty" ← 是一个结构体属性
FFieldToName → "PrimaryActorTick" ← 叫 PrimaryActorTick

现在确认 FProperty 的偏移(ElementSize、PropertyFlags、OffsetInternal):

1
2
3
4
5
6
7
8
FField (0xb5c07880) - "PrimaryActorTick" StructProperty:
+0x00: bfc041ec ← VTable/某个指针
+0x04: c0058230 ← FFieldToClass → "StructProperty"
+0x10: b5c078e0 ← FFieldToNext
+0x14: 00096677 ← FFieldToName → "PrimaryActorTick"
+0x24: 00000001 ← ?
+0x28: 00200000 ← ?(看起来像 flags)
+0x34: 00000024 ← 0x24 = 36... 可能是 OffsetInternal

假设dump输出bool bNetTemporary;//[Offset: 0x44, Size: 0x1],这行的每个信息就来自 FProperty 的字段

1
2
3
4
5
6
FProperty "bNetTemporary":
FFieldToClass → "BoolProperty" → 输出 "bool"
FFieldToName → "bNetTemporary" → 输出 "bNetTemporary"
ElementSize → 0x1 → 输出 "Size: 0x1"
OffsetInternal → 0x44 → 输出 "Offset: 0x44"
PropertyFlags → 各种标志位 → 判断 const、out 等修饰符

image-20260409113603449

image-20260409113627942

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
标准 FProperty(32位 UE4.25+)

── FField 部分 ──
+0x00: VTable
+0x04: FFieldClass* ← FFieldToClass
+0x08: Owner*
+0x0C: FField* Next ← FFieldToNext
+0x10: FName ← FFieldToName

── FProperty 部分 ──
+0x20: ArrayDim
+0x24: ElementSize ← UPropertyToElementSize
+0x28: PropertyFlags ← UPropertyToPropertyFlags(8 字节)
+0x30: RepNotifyFunc
+0x34: OffsetInternal ← UPropertyToOffsetInternal

标准 UFunction(32位 UE4.25+)

── UObject 部分 ──
+0x00 ~ +0x14: VTable, Flags, Index, Class, Name, Outer

── UField 部分 ──
+0x18: Next* ← UFieldToNext

── UStruct 部分 ──
+0x28: SuperStruct
+0x2C: Children
+0x30: ChildProperties
...

── UFunction 部分 ──
+0x6C: FunctionFlags ← UFunctionToFunctionFlags
+0x88: Func* ← UFunctionToFunc

PrimaryActorTick 是 Actor 的第一个属性,它的 OffsetInternal 应该是一个较小的正整数。+0x34 = 0x24 看起来合理。第二个 FField = “bNetTemporary”。

第一个 “PrimaryActorTick”: +0x34 = 0x24 (36)

第二个 “bNetTemporary”: +0x34 = 0x44 (68)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
+0x20: 00000001
+0x24: 00000020 = 32 ← PrimaryActorTick 的 ElementSize = 32 字节(合理,结构体)
+0x28: 00010001 ← PropertyFlags 低 32 位
+0x2C: 00001000 ← PropertyFlags 高 32 位(共 64 位)
+0x30: 00000000
+0x34: 00000024 = 36 ← OffsetInternal

对比第二个 FField(bNetTemporary,BoolProperty):

+0x24: 00000001 = 1 ← ElementSize = 1 字节(bool,合理!)
+0x34: 00000044 = 68 ← OffsetInternal

确认:
UPropertyToElementSize = 0x24
UPropertyToPropertyFlags = 0x28 (64位值,占 8 字节)
UPropertyToOffsetInternal = 0x34

UFunction 偏移现在看第一个 UFunction(0xb5c03ac0 = “WasRecentlyRendered”):

image-20260409114313086.png

1
2
3
4
5
6
7
UFunction 是 UObject 子类,前面的布局和 UObject 一样:
+0x00: bfbfc378 VTablePtr
+0x04: 00000041 ObjectFlags
+0x08: 00001636 InternalIndex
+0x14: c035b6c0 ClassPrivate
+0x18: 0004924b FNameIndex → "WasRecentlyRendered"
+0x20: e3116dc0 OuterPrivate → Actor 类(函数属于 Actor)

找 UFieldToNext(下一个函数的指针)+0x2C = 0xb5c03b80 → “UserConstructionScript” (Function)

image-20260409114433160

UFunctionToFunctionFlags = 0x84,UFunctionToFunc = 0xA4

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
UObject:
UObjectToInternalIndex = 0x08
UObjectToClassPrivate = 0x14
UObjectToFNameIndex = 0x18
UObjectToOuterPrivate = 0x20

UField:
UFieldToNext = 0x2C

UStruct:
UStructToSuperStruct = 0x40
UStructToChildProperties = 0x44
UStructToChildren = 0x6C

FField:
FFieldToClass = 0x04
FFieldToNext = 0x10
FFieldToName = 0x14

FProperty:
UPropertyToElementSize = 0x24
UPropertyToPropertyFlags = 0x28
UPropertyToOffsetInternal = 0x34

UFunction:
UFunctionToFunctionFlags = 0x84
UFunctionToFunc = 0xA4