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) { 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 结构体起始地址,而可能是:
- 指向了一个外层对象
- 或者指向了这个对象里的某个成员入口
- 而真正的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) { int16 Header = Read<int16>(FNameEntry); int StrLength = Header >> 6; bool wide = Header & 1;
string str = ReadStr2(FNameEntry + 2, StrLength);
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;
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 是UE4 引擎里的一个全局变量,类型是 FUObjectArray,作用是登记所有活着的 UObject 实例。


第二步:通过 index 取出每个 UObject 指针
这里 4.23+ 和 4.23 以下有区别
UE4.23+ 路径(chunked 分块数组)
1 2 3 4 5 6 7 8 9
| kaddr GetUObjectFromID(uint32 index) { kaddr TUObjectArray = getPtr(GUObjectArray + 0x10);
kaddr Chunk = getPtr(TUObjectArray + (index / 0x10000) * PointerSize); 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)); return getPtr(TUObjectArray + index * FUObjectItemSize); }
|


第三步:读取每个 UObject 的信息
对每个有效的 UObject 指针:
1 2 3 4 5 6 7
| for (int32 i = 0; i < ocount; i++) { kaddr uobj = GetUObjectFromID(i); if (UObject::isValid(uobj)) { UObject::getName(uobj); UObject::getClassName(uobj); } }
|
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 它的类定义。
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
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); 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) { writeStructChild423(sdk, UStruct::getChildProperties(currStruct)); writeStructChild423_Func(sdk, UStruct::getChildren(currStruct)); } else { writeStructChild(sdk, UStruct::getChildren(currStruct)); }
|

DumpSDKw
第一步:从 GWorld 找到 Actor 列表
1 2 3
| GWorld → UWorld* → +UWorldToPersistentLevel → ULevel ULevel → +ULevelToAActors → Actor 指针数组 → +ULevelToAActorsCount → Actor 数量
|
需要:UWorldToPersistentLevel、ULevelToAActors、ULevelToAActorsCount

第二步:对每个 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的交叉引用
定位到一些模式,如我们之前所提到的

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;
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]; if (raw[j] > 0 && raw[j] < 0x200000) { string name = GetFNameFromID(raw[j]); if (!name.empty() && name != "None" && name.length() < 256) { obj << " [FName: " << name << "]"; } } obj << endl; } obj << endl; dumped++;
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; DumpObjectsDebug(out);
|

得出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 这两个位置,试一下就知道了

修完 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

注意下面也要改

因为这里会覆盖回去

编译:
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
| class UObjectBase { EObjectFlags ObjectFlags; int32 InternalIndex; UClass* ClassPrivate; FName NamePrivate; UObject* OuterPrivate; };
|
之前提到的标准的内存布局是这样的
我们现在手机上放上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 指针指向的地址
|


可以看到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"
|

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)
|




我们关注两个类,找到 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 实例,继承了多层结构

这些全在同一个对象(0xe3116dc0)里,不是不同的对象。UClass 继承自 UStruct 继承自 UField 继承自 UObject,所以一个 UClass 实例的内存里,从头到尾依次排列着所有父类的字段。
从 +0x24 开始找的原因:UObject 的字段到 +0x20(OuterPrivate)就结束了,后面是 UField 和 UStruct 的字段。SuperStruct 是 UStruct 的字段,一定在 +0x20 之后。所以从 +0x24 开始扫,找 Object 的地址出现在哪。

SuperStruct = 0x40
找 ChildProperties 和 Children,在 +0x40 之后,找非零的指针值,它们可能是 ChildProperties 或 Children,SuperStruct 后面第一个指针,跟进去是 FField
在这个指针内能查到FField(能在 +0x14 处查到属性名),可以验证是ChildProperties
然后找UField Chilren,后面的每个指针都去 Objects.txt 里搜,看哪个是 Function

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
|

后面的一些偏移是更细节的了,方法是一样的,先读第一个 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 等修饰符
|


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”):

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)

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
|