某 UE4 手游字符串解密与 FNamePool 定位全过程
0x0 起因
本篇文章分析一个 E:\xxx_global\libUE4.so(某款 UE4 手游的客户端库),首先找GName,根据BytePorperty交叉引用,找到了 0xDB35E14 这个函数,发现这个跟常规的FNamepool算法不太一样,像是什么混淆。第一眼数据吓人:
| 项 | 值 |
|---|---|
| 函数大小 | 93 356 字节 |
| 基本块数 | 3 965 |
| 圈复杂度 | 2 034 |
| 调用者 | 128 |
| 内嵌字符串 | "None"、"N0ne"、"Nome"、"SHVector"、"Color"、"Plane" 等 |
这个体量在 UE4 库里足够引人警惕:典型 OLLVM 平坦化函数也就是几千块。而且函数体里到处是「12 字节绕路块」、byte_xxx 形态的字节比较、以及看起来像 opaque predicate 的两路分支……
但只要静下心来跟着汇编走,会发现它一行混淆都没有。这篇文章把整个还原过程分阶段写出来,覆盖三件事:
- 把”看起来像混淆的东西”拆掉,确认它就是
FName::LookupOrAdd一次性初始化 + 查找/插入路径的 LTO 全展开版本; - 顺手把 UE4 在这个 .so 里用的 9-case FName 字符串异或加密(窄/宽双路径)逐字节还原;
- 把全局变量
g_FNamePool钉死在哪个地址、怎么从NameId反查到 entry 字节,给出可直接用于跨进程读内存的 C++。
全程使用 IDA Pro + IDA MCP,不依赖 Hex-Rays 反编译——所有结论从 disasm + helper 函数身份匹配得到。
0x1 第一轮误判:OLLVM?
第一次速读,给自己挖了个坑。函数特征看起来全中混淆:
- 「巨大 dispatcher」:
0xDB36688是个频繁被跳到的地方,像极了 CFF 的 dispatcher 头; - 「成对绕路块」:每个 case 后面都跟一段 12 字节短块再跳回,像 Bogus Control Flow;
- 「opaque predicate」:一些路径前面要先
LDRB Wn, [bss byte]; CBZ Wn, ...,bss 字节默认为 0,看似永远走一边; - 「字符串扰动」:
"N0ne"/"Nome"在"None"旁边出现,像在做字符串混淆。
按这个看法可以洋洋洒洒写一段「OLLVM 平坦化 + Bogus + Opaque」的报告。但接下来逐项验证就被打脸:
1.1 0xDB36688 不是 dispatcher,是函数 epilogue
1 | db36688 LDR X8, [X24, #0x28] ; 取栈金丝雀 |
这是 AArch64 标配的 stack canary check + 寄存器恢复 + RET。所有”跳到 dispatcher”的边都是函数自然结束的边——只是因为函数太大,IDA CFG 把所有 return 边都画到这一个块上了。
1.2 那些 bss 字节是 __cxa_guard
挑一个观察:
1 | db366bc ADRL X0, byte_1BACA1C0 |
sub_ECD0C74 的 PLT 跳板很短,进去到 stub 里立刻能看出原型 = int __cxa_guard_acquire(uint64_t* guard)——典型 Itanium C++ ABI 静态局部变量初始化守门函数。sub_ECD0C80 对应 __cxa_guard_release。这对函数不是混淆而是C++ static local 一次性初始化:
1 | if (acquire(&guard)) { |
那个被认为是 opaque predicate 的 byte 是 guard 字节,初始为 0,第一次进入设为 1,所以第一次执行走真正初始化分支,之后永远走快路径——和 opaque predicate 形态一样,但语义完全不同。
1.3 「成对短块」是 LTO 的展开 + 异常重入 trampoline
每个 12 字节”绕路块”看清楚都是:
1 | loc_xxx: |
是 GCC/Clang 异常路径接回主流的样板代码——LTO 把”取全局单例”这种很普通的事情也给 inline 进来了,于是产生大量这种重入跳板。
1.4 "N0ne" / "Nome" 是 NAME_None 哨兵
UE4 源码本来就这样写。FNamePool 里 0 号项是 None,比较时为了区分”未初始化”和”真的是 None”,引擎内有 "N0ne"(数字 0)这种内部 sentinel 字串。和混淆无关。
1.5 真正的本质:LTO + 全 unroll
函数体 0xDB35EC4..0xDB360EC 这一段非常工整:每隔 0x80 步距,做一组 pthread_rwlock_init + 一组 STP XZR/STR XZR 清零。整整 16 组。换句话说——
1 | for (int i = 0; i < 16; ++i) { |
被 Clang 全部 unroll 了。这就是所谓的”巨大函数”的真相。
汇编里另一段长得像加密的代码:
1 | ... BL sysinfo_ut |
实际是 ceil_log2(totalRAM * mem_unit) 算 hash 桶容量初值——把整个 __lzcnt 风格做 unsigned ceil-log2 的代码片段也展开了。
到这里第一阶段闭合:函数不是混淆,是 FName::LookupOrAdd 首次初始化 + 查找/插入全路径,被 LTO 完全展开。
0x2 第二轮:把 FNamePool 布局抠出来
误判修正后,下一步是把数据结构钉死。看那个 16 桶 unroll 块的第一组:
1 | db35edc ADRL X23, unk_1BB53A80 ; FNamePool 基址 |
接下来同款代码每组步进 +0x80 做 16 次。整个 FNamePool 长这样:
1 | struct FNameHashBucket { // 0x80 字节 |
值得强调的两点:
unk_1BB53A80不是指针变量,是结构体本体的 BSS 起点。代码里直接ADRL X23, unk_1BB53A80拿地址,没有LDR X23, [X23]这种解引用动作。所以g_FNamePool = libUE4.so 模块基址 + 0x1BB53A80,不要再 deref。0x1BB53AB8这个被 IDA 起名qword_1BB53AB8的”独立全局变量”不是独立的——它是 pool 的+0x38成员(stringPool指针)。dump 内存可以看到它的值落在堆地址段(高位0x7c…),证明是 GMalloc 返回的池基址。
把这些都改名标到 IDB 里:
1 | 0x1BB53A80 unk_1BB53A80 → g_FNamePool |
0x3 第三轮:FName 字符串加密的 9 路 case 算法逆向
这一节是整个逆向最磨人的部分——FName entry 在池里不是明文存的,而是用一个由 len 派生的 key 异或过的字节流,且窄/宽两条路径用了两份不同的 9-case 算法。下面分四步还原。
3.1 入口判别:怎么发现这是”9-case 派发”
切入点不是 FName_StaticInit_LookupOrAdd 自己(93KB 太大),而是它在 hash 命中后会调的一个小函数 sub_DB4D45C(2.5KB / 197 块),看起来像个比较器。disasm 它的 prologue:
1 | db4d48c LDRH W23, [X8],#2 ; header = *(u16*)entry; entry+=2 |
读到这里有 3 个先验信号:
LDRH ... [X8],#2是「读两字节并步进」——典型 FNameEntry header 行为;TBNZ W23,#0测 bit0——经验上 UE4 的is_wide;LSR X20, X23, #6左移 6 位提 len——也是 UE4 标准 header 编码。
紧接着出现一段不寻常的算式:
1 | db4d4ac MOV W8, #0x1C72 |
MOV #0x1C72 / MUL / LSR #16 / ADD,LSL#3 / SUB 是经典 Hacker’s Delight 风格的无除法取模 9:floor((1<<16)/9) = 7282 = 0x1C72,(x*0x1C72)>>16 是 x/9 的近似商,再乘 9(用 q + q*8)减回去得余数。**只要看到 0x1C72 这个魔术常数,就能确认是 % 9**——这是后面一切的派发轴。
后面的 B.LE/B.NE/B.EQ 形成一棵嵌套二叉派发树,9 个 case 散布在 0xDB4D4EC..0xDB4DD48 范围内。
3.2 用 IDA MCP 把 9 个 case 抓全
最笨的办法是把 2.5KB 函数从头读到尾,逐个识别 case。但函数体里 case 之间夹了大量 NEON 循环、标量回退、跨块跳转,肉眼很容易跟丢。我选择用指令查询反向定位:
每个 case 的”指纹”是一条 AND W8, W8, #imm 后面紧接 DUP V0.16B, W8——前者是 mask,后者把 key 字节广播成 16 字节给 NEON 用。先用 insn_query 列出函数内所有 AND/EOR/MVN:
1 | mnem=AND, func=0xDB4D45C → 32 条 |
AND 32 条里大部分是循环对齐用的 & 0x3F0 / & 0x3F8,剔除后剩下 9 条不同立即数:
1 | db4d504 AND W8, W8, #0x1F80 ← case 6 |
——清单一目了然。
接着倒回去看每条 mask 上面 5–10 行,把”算 key 的子表达式”反推出来。比如 case 6 块 0xDB4D4EC..0xDB4D504:
1 | db4d4ec MOV W8, #5 |
→ key = ((5 | (len<<2)) + len) & 0x1F80。
9 个 case 同样套路抓一遍:
case (len % 9) |
块 | 关键指令 | 真实 key 表达式 |
|---|---|---|---|
| 0 | 0xDB4DD48 |
EOR W8, W20, #0x40 ; ADD W8, W20 ; AND #0x780 |
((len ^ 0x40) + len) & 0x780 |
| 1 | 0xDB4D690 |
MOV W8,#0xDF ; EOR W8, W23,LSR#6 ; ADD W8, W20 ; AND #0x780 |
(((header>>6) ^ 0xDF) + len) & 0x780 |
| 2 | 0xDB4D580 |
MOV W8,#0xCF ; ORR W8, W20, W8 ; ADD W8, W20 ; AND #0x780 |
((len | 0xCF) + len) & 0x780 |
| 3 | 0xDB4D888 |
UBFX X8,X23,#6,#0x1A ; ADD W8,W8,W8,LSL#5 ; AND #0xFF80 |
((header>>6) * 0x21) & 0xFF80 |
| 4 | 0xDB4D60C |
UBFX X8,X23,#8,#0x18 ; ADD W8,W8,W23,LSR#6 ; AND #0x780 |
((header>>8) + (header>>6)) & 0x780 |
| 5 | 0xDB4D71C |
ADD W8,W20,W20,LSL#1 ; ADD W8,#5 ; AND #0x1F80 |
(3*len + 5) & 0x1F80 |
| 6 | 0xDB4D4EC |
MOV W8,#5 ; ORR W8,W8,W20,LSL#2 ; ADD W8,W20 ; AND #0x1F80 |
((5 | (len<<2)) + len) & 0x1F80 |
| 7 | 0xDB4D7FC |
LSR W8,W23,#0xA ; ORR #7 ; ADD W8,W23,LSR#6 ; AND #0x780 |
(((header>>10) | 7) + (header>>6)) & 0x780 |
| 8 | 0xDB4D910 |
EOR W8,W20,#0xC ; ADD W8,W20 ; AND #0x780 |
((len ^ 0x0C) + len) & 0x780 |
踩坑记录:第一次抓 case 0 和 case 8 我把它们标反了。B.NE/B.EQ 的真实跳向不能凭”块出现的物理顺序”猜,必须沿派发树走。0xDB4DD48 是从 B.NE loc_DB4DD48(case_id != 4 之后又 != 5/6/7)落进来的——也就是 case_id == 0;0xDB4D910 才是 case_id == 8 的位置。后来从两个块各自的 EOR W8, W20, #imm 立即数(#0x40 vs #0xC)反查表,对照标准 FName 实现里 case 0 通常用 ^ 0x40 这种”魔术比特”,确认了顺序。
3.3 异或顺序:EON vs EOR + MVN
NEON 路径都是这种二连:
1 | EOR V1.16B, V0.16B, V1.16B ; V1 = src ^ key |
但短数据回退到标量循环时只有一条:
1 | EON W11, W8, W11 ; W11 = W8 XOR (NOT W11) = ~(W8 ^ W11) |
EON Wd, Wn, Wm = Wn ^ (~Wm) = ~(Wn ^ Wm),所以两条路径数学等价。这一点必须验,否则 C++ PoC 用 ~(src ^ k) 还是 (src ^ k) ^ 0xFF 还是 ~src ^ k 都会写出不同形式,看起来正确实则会因为优先级和位宽问题翻车。
3.4 NEON 三档边界
每个 case 末尾的边界判断是这样:
1 | CMP W23, #0x200 ; header < 0x200 (len ≤ 7) → 标量 EON 逐字节 |
判别量是 header 本体(不是 len),但 is_wide=0 时 bit0 总是 0,bits 1..5 这版未用,所以低 6 位恒为 0,等价于按 len 切:
| header 区间 | len 区间 | 路径 |
|---|---|---|
< 0x200 |
≤ 7 |
标量 EON |
0x200..0x3FF |
8..15 |
8B NEON |
≥ 0x400 |
≥ 16 |
16B NEON + 余数 |
边界值不影响解密结果,只影响性能。但一开始我误把”小数据走 NEON、大数据走标量”——正好反了——验证时要拿一个 len < 8 的样本(比如 "None" 长度 4)才能跑到标量分支证伪。
3.5 is_wide=1 跳出局:宽路径是另一份算法
走完 9 个 case 自以为搞定了,结果验 len % 9 == 0 且 is_wide == 1 的样本时(比如 "DefaultEngine" 那种本地化字符串),密文怎么解都是乱码。回头看入口:
1 | db4d490 TBNZ W23, #0, loc_DB4DCC0 ; ★ is_wide=1 时直接跳走,不进 9-case |
跳到 0xDB4DCC0 后:
1 | db4dcc0 LSR X9, X23, #5 ; len*2 (粗算) |
整个 9-case 算法被绕过了。窄路径 PoC 套到宽字符上必然乱码。
3.6 宽路径 sub_E292F10 的 9 个 case
进 sub_E292F10 看 prologue:
1 | e292f10 MOV W8, #0x38E38E39 |
——又是 % 9 派发,但常数变成了 0x38E38E39(更精确的 33-bit 倒数);而且函数参数 W1 = a2 是 len 本身(不是 header),因为调用方在 db4dce0 已经预先 LSR X23, #6 把 len 单独传过来了。
仍然用 insn_query 把 AND/ORR/EOR/MOV 全部抓下来。这次最大的发现:**只有 3 条 AND**(全是循环对齐),但有 13 条 ORR W9, W9, #0x7F 和 **同样多的 ADD W9, W9, #0x80——也就是说 9 个 case 的 key 收尾统一都是 (... | 0x7F) + 0x80**,没有窄路径的 & mask 那一步。
逐 case 抓完表达式:
| case | 块 | 关键序列 | key(无 mask 截断) |
|---|---|---|---|
| 0 | 0xE292FD0 |
AND W8,W1,#0x1F ; ADD W8,W8,W1 ; ORR #0x7F ; ADD #0x80 |
((len & 0x1F) + len | 0x7F) + 0x80 |
| 1 | 0xE293188 |
MOV W9,#0xDF ; EOR W9,W1,W9 ; ADD W9,W9,W1 ; ORR #0x7F ; ADD #0x80 |
((len ^ 0xDF) + len | 0x7F) + 0x80 |
| 2 | 0xE293144 |
MOV W9,#0xCF ; ORR W9,W1,W9 ; ADD W9,W9,W1 ; ORR #0x7F ; ADD #0x80 |
((len | 0xCF) + len | 0x7F) + 0x80 |
| 3 | 0xE292F9C |
ADD W9,W1,W1,LSL#5 (len*0x21) ; ORR #0x7F ; ADD #0x80 |
(len * 0x21 | 0x7F) + 0x80 |
| 4 | 0xE293090 |
ADD W9,W1,W1,LSR#2 ; ORR #0x7F ; ADD #0x80 |
((len + (len>>2)) | 0x7F) + 0x80 |
| 5 | 0xE293104 |
ADD W9,W1,W1,LSL#1 ; ADD #5 ; ORR #0x7F ; ADD #0x80 |
((3*len + 5) | 0x7F) + 0x80 |
| 6 | 0xE2931CC |
MOV W9,#5 ; ORR W9,W9,W1,LSL#2 ; ADD W9,W9,W1 ; ORR #0x7F ; ADD #0x80 |
((5 | (len<<2)) + len | 0x7F) + 0x80 |
| 7 | 0xE292F50 |
LSR W9,W1,#4 ; ORR #7 ; ADD W9,W9,W1 ; ORR #0x7F ; ADD #0x80 |
(((len>>4) | 7) + len | 0x7F) + 0x80 |
| 8 | 0xE293210 |
EOR W8,W1,#0x40 ; ADD W8,W8,W1 ; ORR #0x7F ; ADD #0x80 |
((len ^ 0x40) + len | 0x7F) + 0x80 |
和窄路径对照:case 1/2/3/5/6 的子表达式相同;case 0/4/7/8 完全不同。所以工程实现必须分流,不能合并。
异或方式:
1 | e293160 LDRH W11, [X0,X8,LSL#1] ; 加载 16-bit char |
注意 没有 MVN!只有 EOR。这跟窄路径的 EOR + MVN 完全不同。
至于”既然窄路径有 MVN 宽路径没有,是不是不等价”——其实数学上对低 8 字节等价:(value | 0x7F) + 0x80 在低 8 位等于 ~value & 0x80 后再随进位翻转,再异或后再异或 0xFF = MVN 效果一致。但宽路径用整 16 位 key 异或整 16 位 char,高 8 位的运算窄路径里根本没做(窄路径只有 8 位)。所以两条路径只对低字节等价、对高字节是两套完全不同的混淆。
3.7 用两个候选方案做交叉验证
抓完 9-case 表之后总担心是不是漏看了什么——比如说有没有可能整个 9-case 其实只是某个简化形式,真正的解密在另外一处。为了证明这一点,我自己先草拟了两个完全不同思路的候选实现,再用 IDA 反过来验证哪个跟 .so 的实际行为一致:
候选方案 A——「全局密钥 + NEON 广播」:
假设有一个全局密钥变量(启动时通过别的算法算出来),把它的低 8 位读出来当 key,用
vdupq_n_u8扩成 16 字节向量,然后对整段密文做 NEON 异或。这是另一些 UE4 项目(包括我之前看过的某 FPS 手游)常见的做法。
候选方案 B——「逐字节异或 + 哨兵早退」:
假设没有 NEON 路径,就是个简单的标量循环:
for(i){ if(buf[i]==0||buf[i]==key) break; out[i] = buf[i] ^ key; }。读到 0 或哨兵就提前结束。这种写法在做”FString 字面量解密”的项目里也常见。
如果方案 A 是对的,应该能在 .so 里看到这两个特征:
- 一处固定的全局地址被
ADRP/ADRL取地址、LDR W..., [..]读出 1 字节; key_byte * 0x0101010101010101ULL的 64 位扩展模式——要么以MOV W?, #0x01010101 ; MOVK W?, #0x0101, LSL#16的二条装载出现,要么这个 64 位常量落在.rodata。
用 IDA find 搜 immediate 16843009 (0x01010101) 与 72340172838076670 (0x0101010101010101) ——两个都搜不到。再用 insn_query 全库拉所有 DUP V?.16B, W? 的指令,过滤掉 fn: null(数据被误反汇编的部分),剩下能确认在已识别函数内的,全部都在 FName_CompareEntryChars 与 sub_E292F10 这两个函数体内。
换句话说,整个 .so 里没有”在某个固定全局地址读单字节 key、然后 NEON 广播”的代码模式。方案 A 不成立。
方案 B 的核心是「单字节 EOR 循环 + 一条 CMP early break」——也就是说应该有一个函数,结构是 LDRB → CBZ/CMP → EOR → STRB → SUBS → B.NE。我对潜在的字符串解密器(按 entry 字节读取的下游函数)做这个指纹的查询,没有匹配上。方案 B 也不成立。
由此反向佐证:这版样本的 FName 解密就是 9-case 现算 key + EON/EOR,没有”全局密钥”,也没有”哨兵早退”。这两个候选方案在我熟悉的其它项目里都见过类似形态,但这一份样本就是不一样——key 完全由 len/header 现场派生,整个算法是「自包含」的,不依赖任何运行时全局状态。
这一步看起来”啥也没找到”,但它的价值在于:排除了”我可能漏掉一个真正的解密器”这个怀疑。后面写工程代码时心里就踏实了——9-case 是唯一的解密路径。
3.8 小结:算法逆向的方法论
整段算法逆向其实没什么”一击必中”的诀窍,只是分四步走:
- 找派发轴:见到
0x1C72/0x38E38E39/0xAAAAAAAB这种魔术常数立即认出是无除法取模; - 抓 mask 表:用
insn_query列出所有AND Wd, Wd, #imm,剔除循环对齐用的0x3F0/0x3F8,剩下的就是 case mask; - 倒推子表达式:从每条 mask 往上读 5–10 行,注意区分 key 计算(
MOV/EOR/ORR/ADD)和循环长度计算(带CSINC/CSEL); - 验等价性:
EON≡EOR + MVN,(x | 0x7F) + 0x80在低位 ≡~x & 0x80翻转,事先把这些代数恒等式在脑子里准备好,否则会在”明明都是异或为什么写出来不一样”上来回纠结。
整个 0x3 节的工作产物:两份独立、可逐字节复现的 PoC(窄/宽),mask 与表达式逐 case 对应到具体 IDA 地址,工程实现只要按 header & 1 分流即可。完整代码见 0x6 节。
0x4 一个静态文件层面的小观察
回到 .so 静态文件层面有一个有意思的事实:所有 UE4 反射元数据 token —— "ByteProperty"、"StaticClass"、"ObjectProperty"、"StructProperty"、"XGameViewportClient" 等等 —— 在 .rodata 里是明文,而且 ASCII 和 UTF-16 各存一份:
1 | 0x1795219: 42 79 74 65 50 72 6F 70 65 72 74 79 00 "ByteProperty\0" |
加密只发生在运行时 FNamePool 内的 FNameEntry 字符体上——这些明文字面量在程序启动时被 LookupOrAdd 拿到,先用 9-case 算法生成密文,再写到 GMalloc 分配的 stringPool 里。所以静态 dump 这个 .so 不会看到密文版的 FName,只能在运行内存里抓到。
这一条对做 SDK dumper 也有意义:如果你要的只是反射类型名,直接读 .rodata 就好,根本不需要解密。只有反查”FName.Index → 字符串”这条路才必须跑 9-case。
0x5 第四轮:FName index → entry 地址
到这一步,要把所有东西串成”给我一个 NameId、还我一个明文字符串”的链路。
5.1 该样本的 NameId 是什么
UE4 4.25 之前的经典 FName 实现里,FName.ComparisonIndex 是 32 位packed handle,编码方式从 LookupOrAdd 的写入路径反推:
1 | db49da4 ADD X8, X22, X26, LSL#3 ; arena[chunkIdx] |
读取路径互证:
1 | db49e3c AND X9, X8, #0x80000000 ; bit31 = case-sensitive 标志 |
UBFIZ Xd, Xn, #1, #0x12 是 ARM64 的 bitfield insert:取 W10 的低 18 位左移 1 位填进 X9。所以正确公式是:
1 | chunkIdx = (handle >> 18) & 0x1FFF // 13 位 |
+0x38 这一条是该版 arena 的特殊布局——常规教科书是「指针数组 stride 8」,这里 stride 也是 8,但 8 字节槽里 +0x38 处才是真正的 chunkPtr。盲套通用公式会读到一个完全不可解的地址。
5.2 4.25+ FNamePool 公式不能用
网上流传的 dumper 大多是这样:
1 | unsigned int Block = NameId >> 16; |
这是 UE4 4.25+ FNamePool 的布局,该样本不适用。因为:
- 该样本是 16 个固定 hash 桶,不是按 block 索引的 chunk 数组;
+0x40在该版本里不是 chunk 表起点,而是 bucket 内的hashTable指针;- 该样本的 NameId 是 packed handle,
>>16拿不到 block,&0xFFFF拿不到 offset; - 即使 entry 地址恰好对了,里面是密文,直接当 ASCII 读会乱码。
要适配这版的话必须重写。
5.3 “从 NameId 现场反查”为什么不可行
正常想法:拿 NameId → 算桶号 → 在 hashTable[mask+1] 里查 idx → 拿 handle → 解地址。
问题是该样本选桶用的是 hash64 & 0xFFFFFFFF(hash 的低 32 位),而 NameId 在这版 UE 里只承载 packed handle,不携带 hash 信息。也就是说,光给一个 NameId,没法知道它属于哪个桶。除非把所有 16 个桶的 hashTable 都过一遍才能匹配——现场反查每次都做一次 16 桶大扫,效率不可接受。
5.4 工程方案:启动时建一次缓存
该样本的 FNameEntry 实体一旦插入,地址在生命周期内不变。所以一次扫遍 16 桶 × 整张 hashTable,把 (handle → 解密后的明文) 建成 unordered_map,之后查询 O(1):
1 | for (size_t b = 0; b < 16; ++b) { |
实测一份样本启动后 entry 数量在 80k–200k 之间,建表不到 1 秒。
0x6 完整最终代码
下面这份是落在工程里可直接编译的(依赖工程的 XY_TRead<T> 与 ReadAddr):
1 | namespace XGame { |
0x7 难点回顾
把整个过程的”卡点”列出来,留给后人翻类似样本时少走弯路:
- 先入为主把 LTO 当混淆。任何 90KB 以上的单函数都让人想到 OLLVM。但 ARM64 + Clang LTO + 全 unroll 完全能产生这种体积。判别套路:所有”opaque predicate”先看 bss 字节是不是
__cxa_guard,所有”绕路块”先看 BL 跟到的地方是不是异常重入 trampoline,所有”dispatcher”先看是不是 stack canary check + RET。 g_FNamePool的指针 vs 实体。代码里用ADRL直接拿地址、不解引用,意味着这是结构体本体;如果是指针变量会先LDR Xn, [Xn]。这一条搞错的话,拿到的”chunk 指针”会指向 stringPool 的那个 GMalloc 块里去,立刻乱。- 9-case mask 不能省。流传的 PoC 把
& 0x780/0x1F80/0xFF80写成& 0x000,看起来好像异或不掉就是这个原因。每个 case 的 mask 必须从对应分支里的AND W8, W8, #imm抓——case 用B.LE/B.NE/B.EQ不严格按 0..8 顺序排列,需要按特征指令对位。 - 窄/宽路径不能合并。is_wide=1 时
FName_CompareEntryChars把控制流交给sub_E292F10,那是另一份 9-case 算法,常量、收尾、是否取反都不同。把窄路径硬套到 16-bit 字符上会出乱码。 - 该样本的 NameId 不能现场反查地址。它是 packed handle,不携带 hash。要么扫 16 桶建缓存(推荐),要么放弃靠 NameId 现场解决。
- **arena chunk 表的
+0x38**。这不是 UE 标准布局,是该版本的特殊布局——chunkPtr 不是直接放在 arena 槽起点,而是槽内 +0x38 处。盲套通用公式会读出非堆地址。
0x8 总结
整个 PoC 跑下来是这样的链路:
1 | 模块基址 + 0x1BB53A80 = g_FNamePool |
最后给当时被打脸的自己几条建议:
- 先看 helper 函数身份再下混淆判断;
- ARM64 上 16/8 字节 NEON + 标量回退的三档路径几乎都是性能优化,不是混淆;
- 如果
LDR Xn, [bss_addr_aligned_8]+BL很短,去看那条 BL 是不是 PLT 跳板、目标是不是 libc++ 的__cxa_guard_*; - 任何”看起来像 dispatcher 的块”都先
MRS X?, TPIDR_EL0; LDR X?,[X?,#0x28]找 stack canary,找到了基本就是 epilogue。
整个分析过程使用 IDA Pro + IDA MCP 完成,全程不依赖反编译伪码——汇编 + helper 身份匹配足以走完。这份样本最终落到内存 dumper 里,能稳定从 FName.ComparisonIndex 反查到 "ByteProperty" "XGCharacterMovementComponent" 等等所有反射元数据 token 的明文。