2023腾讯游戏安全竞赛Andorid初赛
2023腾讯游戏安全竞赛Andorid初赛
dump
dump so我使用的是gc修改器可以dump
具体操作参考https://blog.csdn.net/qq_49619863/article/details/131155604
当然用frida也行
1 | var target_so = "libil2cpp.so"; |
il2cpp.so明显混淆加密
需要dumpso 文件,使用常规方法il2cppdumper 无法成功
1 | 7d23c25000-7d24ff1000 |
方案一: sofixer (手修elf)
用sofixer还是不能直接用il2cppdumper
问题1:运行时重定位未还原
ELF 动态链接器加载 SO 时,会根据 .rela.dyn 中的重定位表修改数据段中的指针。dump 出来的文件保留的是加载后的绝对地址。SoFixer 本应修复这个问题,但没有生效。
修复遍历 .rela.dyn(108,725 条)和 .rela.plt(512 条)中所有 R_AARCH64_RELATIVE 类型的重定位条目,对每个目标地址的值减去基地址 0x7d23c25000
1 | val = read(r_offset) |
共修复了 108865个指针
问题2:Section Headers 损坏
SoFixer 重建的 section headers 质量很差:
- .rela.plt 类型标记为 REL 而不是 RELA
- section 名字合并成了 .text&ARM.extab
- 缺少 .rodata、.got、.data.rel.ro、il2cpp 等关键 section
Il2CppDumper 需要正确的section信息来定位数据。
修复:从原始 libil2cpp.so 中移植完整的 27 个 section headers,并调整 sh_offset
原始文件: sh_offset = VA - 0x1000 (因为 LOAD 段的 p_offset ≠ p_vaddr)
内存dump: sh_offset = VA (因为 dump 中 文件偏移 == 虚拟地址),所以对每个带 SHF_ALLOC 标志的 section,设置 sh_offset = sh_addr。
问题 3:Il2CppType.data 被运行时替换(CLASS/VALUETYPE)
这是最核心的问题。IL2CPP 引擎在初始化时,会通过代码(不是 RELA 重定位)把每个 Il2CppType 结构体的 data 字段从编译期的整数索引替换为运行时的堆指针:
1 | struct Il2CppType { |
Il2CppDumper需要data 是编译期的索引值,但 dump 出来的是运行时指针,导致数组越界崩溃。
问题 4:不只是 CLASS/VALUETYPE
分析发现几乎所有类型的 data 都被替换了
仅从 metadata 反推只能修复 CLASS/VALUETYPE,无法覆盖全部。
最终方案从原始 SO 直接复制
Il2CppType 结构体位于 .data 段(VA 0x11020000x119A370),不在被加密的 il2cpp段(VA0x4634740xC4E42C)中。
这意味着原始 libil2cpp.so 中的 Il2CppType.data 字段是明文可读的正确值
原始文件中 VA 0x1139780 的 Il2CppType:data = 0x82 (genericParameterIndex, 正确)
1 | type = 0x1e (MVAR) |
只需做一个地址转换(原始文件 file_offset = VA - 0x1000),就能从原始 SO 读取所有 10,472 个类型条目的正确 data 值,覆盖写入 dump 文件。共恢复了 6,980 个被篡改的字段。
中和 RELA 条目
Il2CppDumper 启动时会 Applying relocations——读取 RELA 表并重新应用。其中 3,492 条 R_AARCH64_RELATIVE 的目标地址恰好是 Il2CppType.data 字段,会把我们恢复的值再次覆盖为 RELA 中的 addend(一个 VA 指针)。
修复将这 3,492 条 RELA 的 r_info 设为 0(R_AARCH64_NONE),使其无效化。
总结流程
1 | 内存 dump (.bin) |
恢复代码:
1 | import struct |
- 手动还原重定位——遍历 10 万条 RELA,逐个减基地址
- 手动恢复 Il2CppType.data——运行时被引擎替换成堆指针,从原始 SO 逆向复原 6980 个字段
- 手动中和 RELA 条目——防止 Il2CppDumper 重复应用重定位覆盖修复
- 手动移植 section headers——从原始 SO 搬 27 个 section header 并修正偏移
其实还真有大神纯手修elf
方案二: 对比分析
其实方案一的最后也意识到这个问题了,也做了类似的处理
因此用010editor对apk中的so和dump出来的so进行比对,补上尾部的重定位表
[原创] 腾讯游戏安全技术竞赛2023 安卓客户端初赛WriteUp-Android安全-看雪安全社区|专业技术交流与安全研究论坛
直接把主要difference这段复制到libil2cpp.so(原来的)
打开可以识别了
后面还有一些difference不用管,因为 IDA 和 Il2CppDumper 一样,加载 ELF 时会自动应用重定位表。
原始文件里的数据段存的是编译期的原始值,同时 .rela.dyn 里记录了地址 0x10624b8 处,需要加上加载基地址 (R_AARCH64_RELATIVE)
- 动态链接器(安卓运行时)读 RELA,把值改成基地址 + 偏移 ,dump 出来看到的样子
- IDA 加载时也读 RELA,自己完成重定位 ,显示正确的引用
- Il2CppDumper 也一样
所以原始文件里数据段的看起来不对是正常的,是 ELF 共享库的标准工作方式。
方案三: 根本不用fix
腾讯2023SecAndroid初赛题目 | LLeaves Blog
邮电小误导了
绕了好久其实只用对dump出来的文件直接给输入基址他就自己会找CodeRegistration和MetadataRegistration
CodeRegistration存储所有编译后的 C++ 代码的注册信息:
1
2
3
4
5
6
7
8
9
10
11 typedef struct Il2CppCodeRegistration {
uint32_t methodPointersCount;
Il2CppMethodPointer* methodPointers; // 每个 C# 方法对应的函数指针
uint32_t reversePInvokeWrapperCount;
Il2CppMethodPointer* reversePInvokeWrappers; // P/Invoke 反向调用包装
uint32_t genericMethodPointersCount;
Il2CppMethodPointer* genericMethodPointers; // 泛型方法的函数指针
uint32_t invokerPointersCount;
Il2CppMethodPointer* invokerPointers; // 方法调用器
// ...
}Il2CppCodeRegistration;
简单说:C# 方法名 → 对应的 native 函数地址 的映射表。
1
2
3
4
5
6
7
8
9
10
11
12
13 MetadataRegistration
存储所有类型系统元数据的注册信息:
typedef struct Il2CppMetadataRegistration {
int32_t genericClassesCount;
Il2CppGenericClass** genericClasses; // 泛型类实例
int32_t genericInstsCount;
Il2CppGenericInst** genericInsts; // 泛型实例化参数
int32_t genericMethodTableCount;
Il2CppGenericMethodFunctionsDefinitions* genericMethodTable;
int32_t typesCount;
Il2CppType** types; // 所有类型信息(就是之前修的 Il2CppType)
// ...
}Il2CppMetadataRegistration;
简单说:所有类型、泛型、类的元数据表。
Il2CppDumper 为什么需要它们
- global-metadata.dat 知道方法叫什么名字,但不知道编译后的地址在哪
- CodeRegistration 知道每个方法编译后的地址,但不知道叫什么名字
- MetadataRegistration 提供类型系统信息,把两者关联起来
三者结合,Il2CppDumper 就能输出 void PlayerController.Update() { }
恢复符号
分别使用两个文件进行导入:运行Il2CppDumper提供的脚本ida_with_struct_py3.py,运行后分别选择script.json与il2cpp.h,耐心等待解析完毕
运行Il2CppDumper提供的脚本ida_py3.py,运行后选择script.json
frida检测对抗手段
frida有检测 可以直接用魔改版的项目,然后换个端口即可,hluda github可以搜到
1 | ./hs -l 0.0.0.0:9999 |
实际上用frida dump出内容后仍然被kill了
flag获取
从csharp代码入手
MouseController.cs :主控制器,含金币收集、碰撞、生命等核心逻辑
SmallKeyboard.cs:键盘输入组件,有个可疑的 KeyboardNum 静态字段
Oo0.cs / oO0OoOOo.cs做了混淆
TssSdtInt.cs:腾讯安全盾(TSS)的防篡改数据类型
MouseController 中有两个关键字段,Coins (类型 TssSdtInt),分数,使用反作弊保护的整数
LblWeaks (类型 Text) — 名字可疑的UI文本,不像是显示弱点,像是显示flag
C# dump出来的代码只有方法签名和RVA地址,没有方法体
7d23d01234就是 VA,RVA = VA - 模块基址
首先看 CollectCoin (RVA 0x4652E4),因为收集金币,加分
1 | void CollectCoin(this, coinCollider) { |
到这里flag生成逻辑就清楚了:flag = 某个前缀+ 设备ID前6位
从 stringliteral.json 查到索引4153的值是 secP160k1。在IDA反汇编中看到该地址的注释标记为 sec2023_。这个不管,fridahook完就知道是哪个了
不论哪个值,flag的结构是确定的:固定前缀 + deviceUniqueIdentifier[0:6]
0x4653D0 的 B.LT ,NOP掉它,不管分数多少都会走到flag生成路径
一些辅助逻辑
大概是
1 | void HitByLaser(this, laserCollider) { |
可以hook掉来保证无敌
MouseController::Start (RVA 0x464598):
1 | void Start() { |
发现隐藏的调试入口:KeyboardNum == -1 会开启无敌
Frida脚本做了这几件事:
- NOP 0x4653D0 的 B.LT — 核心bypass,让每次收集金币都走flag路径
- Replace HitByLaser 为空函数,无敌,防止还没捡到金币就死了
- Hook String.Concat + 检查返回地址在CollectCoin范围内,精准捕获flag字符串并打印
4. Hook TssSdtInt::op_Implicit ,监控实际分数 ,这样只要进游戏碰到第一个金币,flag就会直接打印到Frida控制台。
1 | // MouseController::CollectCoin = 0x4652E4 |
libil2cpp.so加密分析
要分析注册机首先从输入key开始分析,c#中发现了SmallKeyboard,显然是处理输入0x465880
先看反编译 iI1Ii
分析时发现其中有三个分支,大概是
KeyType < 2 与之前的输入拼接
KeyType == 2 按下OK提交
KeyType == 3 按下Del删除前一个输入
因此需要着重分析第二个OK的分支。
1 | if (KeyType == 2) { // EnterKey |
去混淆
追踪iI1Ii_4610736发现被保护了
这是 B(无条件跳转),不是 BL(带链接的调用)。对IDA来说,B 意味着控制流转移到目标,不会返回这里,所以 IDA 认为 0x465AB4 之后的代码不属于任何正常控制流
而 0x2A8 处又是一个 B 到 sec2023 的 .init_proc:
0x2A8: B loc_13B8DC8 ; 跳到 sec2023 unpacker
0x2AC: (data…) ; 加密/packed的函数数据
IDA 跟着这两个B走,最终把整条链路都归入 .init_proc 的函数范围。所以当我查询 0x465AB4 所属函数时
这是腾讯安全壳的unpacker
但是在上一层的下面发现了initproc
妖媚看汇编 ,要么进行处理,下面的指令应该是个正常函数的内容 却被归入了init_proc,所以反编译器不能正确处理,最简单的是直接nop掉源头
再UCP重建函数即可
VM分析
去除后大致逻辑如下:
密钥和字节码怎么来的?就是从global-metadata加载的
7ED62A9C940924585528F182C4782BE139EB7954F975F7145C28719071F7BC8E 这个长名字就是 IL2CPP 的 PrivateImplementationDetails 命名规则 — 它是数据内容的SHA256哈希值
所以反过来,可以在 global-metadata.dat 的数据块里暴力搜索匹配的SHA256
1 | import struct |
从反编译代码:
1 | OO0OoOOo_Oo0___ctor(v13, array, 0, OOoOO0, method); |
所以第二个参数 0 就是 startPC=0,意味着从字节码的第一条指令开始执行
反编译oOOoO0o0(RVA 0x46AD44),这是 VM dispatch 主循环:
1 | void Oo0__oOOoO0o0(Oo0 *this) { |
那22个handler是在 O000O000000o(RVA 0x46A55C)里注册的:
1 | void Oo0__O000O000000o(Oo0 *this) { |
然后逐个反编译这22个handler函数,例如 PUSH_IMM(RVA 0x46B578):
1 | void Oo0__O00O00000o(Oo0 *this) { |
比如我们随便找一个
1 | // 1. 读 opcode 值 |
1 | Method_OO0OoOOo_Oo0_O00O0000O0o__ |
就是OO0OoOOo+Oo0+O00O0000O0o
可以这样OO0OoOOo.Oo0$$O00O0000O0o
先把 IL2CPP 的字段名还原成有意义的名字:
1 | void Oo0__PUSH_IMM(Oo0 *this) { |
这些字段名的对应关系来自 C# dump 和构造函数分析:
1 | [FieldOffset(Offset = "0x10")] private ushort[] OoOOO00; // 字节码 |
但是里面的部分逻辑还是被混淆了以 ooooOO0O(0x46B0BC)为例:
反编译所有22个handler。每个handler都用了MBA混淆,例如 AND 被表示为:result = (v6 + (v6 ^ v8) + (v8 & ~v6) + (v8 & ~v6) + 1) & v8;用单比特真值表验证每个handler的真实操作。
我们如何去混淆[分享]Ollvm 指令替换混淆还原神器:GAMBA 使用指南-Android安全-看雪安全社区|专业技术交流与安全研究论坛
简单学习ollvm混淆&polyre例题解析 | Matriy’s blog
可以使用GAMBA
简化玩就是个与操作,这样我们可以得到所有的逻辑
Oo0 是一个栈式虚拟机,包含
22个操作码总结如下:
魔改TEA
1 | v25 = *(array_1 + 24); // 读 key 数组长度(用于边界检查) |
三层逆运算的叠加:
- 逆TEA:64轮反向,先撤销W22更新再撤销W21更新
- 逆VM重组:每字节 ^ shift 撤销XOR混合
- 逆字节变换:SUB↔ADD互逆,XOR自逆
总逻辑为用户输入数字(uint64) → 拆分为两个uint32 → VM字节变换 → TEA加密 → 与随机ID比较
VM对两个uint32分别做了字节拆分,逐字节变换 ,重新组装,我直接体现到代码里了,直接让AI搓一个脚本
1 | import struct, hashlib |
1 | import struct, sys |
libsec2023.so加密分析
通过上述代码的生成的key输入后发现无效果
hook_vadlidate.js
1 | // Frida hook: 验证 SmallKeyboard 注册码是否通过 |
发现不能通过只能再debug下,hook_debug.js
1 | // Debug hook: 追踪输入值在每一步的变化 |
发现
thunk (0x465AB0): X1 = 0xd704a6ef31bc018f 原始输入
sec2023 (0x2A8): X1 = 0xd704a6ef31bc018f 还是一样
ValidateKey (0x465AB4): X1 = 0x1160f6d544d1b994 可能是被sec2023改了
sec2023 壳在 0x2A8到 0x465AB4 之间对输入做了额外变换。我们的 keygen 算出的值是给 0x465AB4 用的,但 sec2023 在传入前做了修改。
为什么会想到去hooksec2023?因为在字符串里看到了,此外loc_13B8DC8这个地址也跟进看过
可能得分析另一个so文件
thunk il2cpp+0x465AB0 传 X1 进去,il2cpp+0x465AB4 X1 被改变。中间发生什么未知
一些尝试和发现
第一次尝试(失败):函数级 hook
sec2023 里大概有 17 个可疑函数,挨个 hook 看哪个被调。
hook_trace_sec2023.js
1 | // 精准追踪: 按OK时 sec2023 中哪些函数被调用,X1在哪一步被修改 |
只有 sub_3A054(dispatcher)被触发。其他函数没进去 ,说明 sec2023 用了间接跳转(dispatcher 表),直接 hook 地址抓不到
sec2023 用 VM-style dispatcher (BR X8/X10/X12),调用是间接跳转,IDA 里看到的函数地址根本不是实际执行的入口。
第二次尝试:Stalker 指令级追踪
为什么用 Stalker: 静态分析看不到实际调用关系(因为间接跳转),只能动态跟。Frida Stalker 追踪 sec2023 范围内每条 BLR/BR/BL/RET,打印每条指令和 X1 的值。
hook_stalker.js
1 | // 用 Stalker 追踪验证时 sec2023 执行的每个代码块 |
Stalker 是 Frida 里的动态指令跟踪器。
普通 Interceptor.attach()更像是在某个函数入口下断点进来时看参数出去时看返回值
而 Stalker是:
- 这个线程接下来执行的每一小段代码我都盯着
- 它跳到哪、call 到哪、ret 到哪,我都能跟
- 甚至执行到某条指令时,我还能插桩看寄存器
比 Interceptor更细粒度。
X1 在整个 chain 里基本不变,直到进入 sub_3A924 才被修改。发现调用链:
1 | sub_3A924 → sub_3B4B8 (循环解密"encrypt") |
1 | [Remote::mouse ]-> [+] il2cpp: 0x7c0040f000 |
Stalker打印的是 sec2023 基址的相对偏移和控制流指令。每条记录形如:
0x3a9f0 bl X1=… ← 在 sec2023+0x3a9f0 执行 BL 指令
之前 IDA 看 sub_3A924 反编译结果里有这几个关键点:
1
2
3 v10 = sub_3B4B8(&n176175656, v21); // @ 0x3a9f8
v11 = sub_3B570(&v14, &n176175656); // @ 0x3aa28
v12 = (*(vtable[113]))(v7, v9, v10, v11); // GetStaticMethodID @ 0x3aa44
现在对照 Stalker:
1
2
3
4
5
6
7
8
9 0x3a9f0 bl X1=0x7c1a5d1cd0 ← BL 到 sub_3B4B8
0x3b508 br
0x3b54c br ×19 ← sub_3B4B8 内部循环(dispatcher 跳转)
0x3b568 ret ← sub_3B4B8 返回
0x3aa28 bl X1=0x7c1a5d1cb8 ← BL 到 sub_3B570 (不同的 X1 buffer!)
0x3b5c0 br
0x3b604 br ×20 ← sub_3B570 内部循环
0x3b620 ret ← sub_3B570 返回
0x3aa44 blr X1=0x2f66 ← BLR = GetStaticMethodID (X1=0x2f66 是 jclass handle)
推理步骤,不是 Stalker 直接看出来的:
1. 位置推断: 0x3a9f0 和 0x3aa28 两次 BL,正好对应 IDA 反编译里的 sub_3B4B8(...) 和 sub_3B570(...)。 2. 循环模式说明在处理字符串: sub_3B4B8 内部 0x3b54c br 出现 19 次,sub_3B570 内部 0x3b604 br 出现 20 次。这种重复 BR 到同一地址 = 循环体在跑。 3. 循环的目的是什么: - 下一个调用是 GetStaticMethodID(env, clazz, name_ptr, sig_ptr) (vtable[113]) - 也就是说 sub_3B4B8/sub_3B570 的返回值是方法名指针和签名指针 - IDA 里能看到这两个函数用大常量(0xF712A53DC141FF65 等)做 XOR 循环 - 循环次数 ≈ 字符串长度: - "encrypt" = 7 字符 + 结尾 = 需要 ~7-8 次循环迭代 - "([B)[B" = 6 字符 + 结尾 = 需要 ~6-7 次 - 实际 BR 出现 19/20 次是因为每次循环体本身有多个 BR 跳转(典型 VM dispatch 一轮包含多次 BR) 4. 从后面的 hook_jni_call 确认: - JNI hook 抓到 [GetStaticMethodID] encrypt ([B)[B - 所以倒推回去,两次 BL 就是解密这两个字符串
1
2
3
4
5
6
7
8
9
10 Stalker trace 片段 推断的语义
──────────────────────── ──────────────
0x31188 bl X1=0x102cf sub_31164 → sub_3B8CC(user_input)
0x3ba00-0x3bb54 (多次 br/ret) sub_3B9D4 跑完预处理 (~4+4次 byte 循环)
0x3b920 bl X1=0x7c1a5d1d64 sub_3B8CC → sub_3A924(ctx, &v5, 4)
0x3a9a4 blr X1=0x4 JNI NewByteArray(size=4)
0x3a9c8 blr X1=0x39 JNI SetByteArrayRegion(arr, ...)
0x3a9f0 bl + 0x3b54c br ×19 解密 "encrypt" 方法名
0x3aa28 bl + 0x3b604 br ×20 解密 "([B)[B" 签名
0x3aa44 blr X1=0x2f66 JNI GetStaticMethodID → 得到 methodID
- Stalker offset
- BL/BR/RET 三类指令区分出 函数调用 / dispatcher 跳转 / 函数返回
- 重复 BR 到同一地址 = 循环(可能是解密 / VM 指令 / byte-loop)
- X1 值突变为大指针 = 作为函数调用的第 2 个参数传出去
- JNI hook 结果 反向确认(”抓到 encrypt 方法名 → 所以前面的解密循环产生的就是它”)
sub3A924 分析
1 | v6 = sub_1010C(); // 拿 JNI env |
关键: 字符串是运行时用 XOR 常量(0xF712A53DC141FF65 等)解密的,静态看不到明文。
抓运行时的 JNI 调用,静态看不到方法名 ,动态 hook JNI vtable,截获 GetStaticMethodID 和字节数组操作
1 | // 捕获 sub_3A924 中的 JNI 调用:方法名、签名、byte[] 内容 |
为什么 hook 这些:
- GetStaticMethodID (vtable[113]) → 抓方法名和签名
- NewByteArray (vtable[176]) → 看分配的大小
- SetByteArrayRegion (vtable[208]) → 看传给 Java 的字节
- GetByteArrayElements (vtable[184]) → 看 Java 返回的字节
sec2023把 X1 拆成 2 段 4 字节, 各自调 Java 静态方法 encrypt(byte[]) -> byte[] , 合并回 X1
确认输入输出对齐
在分析 sec2023 黑盒,已经知道:
- 用户输入 ulong 进去
- sec2023 里调了两次 Java encrypt([B)[B],每次处理 4 字节
- 出来后 X1 变成了另一个 64-bit 值
但不知道sec2023 内部是怎么把两次 encrypt 的结果拼接成新 X1 的。
可能的拼法有好几种:
方案 A: X1’ = (encrypt[0] << 32) | encrypt[1] → hi=out0, lo=out1
方案 B: X1’ = (encrypt[1] << 32) | encrypt[0] → hi=out1, lo=out0
方案 C: X1’ 的字节 = out0 字节 ‖ out1 字节(内存拼接,不管uint32)
方案 D: 更复杂的交错/异或
因此这里是验证这个的
hook_verify_encrypt.js
1 | // 对齐验证: thunk X1 原值 → 2次 encrypt 输出 → ValidateKey X1 变换后值 |
证明 X1’ = (encrypt(v4[0]) | encrypt(v4[1])),且搞清字节序。
这验证确认了 sec2023 的拼接规则:
1 | X1' = bswap32(encrypt[1]) : bswap32(encrypt[0]) |
1 | enc0_output = bswap32(X1_target.lo) # 逆第 1 次拼接 |
Java encrypt
再classes.dex 没找到encrypt相关信息,因此可能是被动态解密出来执行的,继续分析so层代码,既然 sub_3A924 的 a1+32 存着 jclass(从反编译看到 v9 = *(a1+32)),直接读出来用 Class.getName() 反查。
hook_resolve_class.js
1 | // 读 sub_3A924 的 context (a1+32) 处的 jclass,用 Java 反射查类名 |
类名 sec2023.Encrypt,但没找到这个类
手动dump(当然可以用frida-dexdump,当时忘记了)
从进程内存扫描 dex\n035\0 magic (DEX header 标识),把整个 DEX 结构 dump 下来。
var magic = [0x64, 0x65, 0x78, 0x0a, 0x30, 0x33, 0x35, 0x00];
Memory.scanSync(range.base, range.size, pattern);
dump_encrypt_dex.js
1 | // 从内存找 encrypt.dex: 扫描进程内存中的 DEX magic 'dex\n035\0' |
反编译出源码
源码是控制流扁平化 + 字符串哈希 switch
只看每个 case 的实际数据操作(忽略状态跳转):
1 | def encrypt(b): # 4 bytes in |
1 | 输入 6d 94 ca e4: |
sub_3B9D4 分析 [巧解]
sub_3A924 是真正调 Java encrypt 的函数
sub_3B8CC中调用了sub_3A924
分析到3B9D4(预处理函数)
静态追 VM dispatcher太大了,都是一块块的函数,需要处理,sub_3B9D4 看起来只有 BR X10(一个间接跳转)。手动追每条 BR 的目标会太慢。
可以用Unicorn 模拟,加载 libsec2023.so 到 Unicorn,直接跑 sub_3B9D4
坑点: raw .so 里的 dispatcher 表项是相对偏移,不是绝对地址。没应用重定位就跑,指令会跳到错误地址崩溃。可以用 pyelftools 解析 ELF 的 R_AARCH64_RELATIVE 重定位,加载后应用:
1
2 for off, addend in relocs:
struct.pack_into("<Q", image, off, LIB_BASE + addend)
emu_sub_3B9D4.py,这个函数用来探测用的,探测是否使用成功这个unicorn跑出来的结果是否跟frida hook的结果一致为后面打基础的
1 | #!/usr/bin/env python3 |
用unicorn做函数结构探测,sub_3B9D4 对我们是个黑盒:能给它输入,能拿到输出,但不知道里面在算什么。
目标是搞清楚这个黑盒的结构,最好能写个 Python 版本代替它。最懒的方法不用小变化戳一下函数,看它怎么反应。改一个比特,看输出变多少,从而猜函数的结构
1 | #!/usr/bin/env python3 |
基准点:先跑一次,记下啥都不输入的结果:f(0, 0) = 0x6d94cae4 base(基准输出)
然后每次只翻一位(bit flip),看输出变什么:
输入 lo = 0x00000001 (只在 bit 0 上是 1,其他 31 位都是 0)
基准: 0x6d94cae4 = 0110_1101 1001_0100 1100_1010 1110_0100
新的: 0x6d94cae5 = 0110_1101 1001_0100 1100_1010 1110_0101
只差一位
输入 byte[i] 只影响输出 byte[i],其他字节不受影响
假如 sub_3B9D4 是加密算法(AES、DES),翻 1 位输入,整个 32 位输出都会变得面目全非(这叫扩散,是加密算法的特征)。
但我们看到的情况翻 1 位输入,只影响 1 个字节输出。这说明 sub_3B9D4 不是加密,而是 4 个独立的字节变换串起来,每个 fi 是一个独立的 256 → 256 映射函数。
既然是 4 个独立的 f0, f1, f2, f3,每个只关心一个字节(256 种可能),穷举所有输入就能完整记录函数行为:
1 | for bp in 0..3: # 4 个字节位置 |
总共 4 × 256 = 1024 次调用 Unicorn,就能完整覆盖这个函数。
然后这 4 张表就代替了整个 sub_3B9D4 函数,keygen 只需要查表,不需要 Unicorn 了。 注意是 8 个 bit 影响同一个字节。这 8 个 bit 合起来就是输入 byte[0]。输入 byte[i] 的 8 个 bit 合起来,决定输出 byte[i] 的 8 个 bit,是一一对应的关系。
输入 byte[0] (8 bit 当一个 0255 的数) → 输出 byte[0] (8 bit 当一个 0255 的数)
建表
1 | #!/usr/bin/env python3 |
这个{“forward”: [[228, 229, 230, 231 的前几个意思就是0对应228,1对应229.3对应230
这个表是二维数组sub_3B9D4 是 4 个字节独立映射,4 个不同的函数,每个是 256 → 256 的映射,inverse是逆表
1 | {"forward": [[228, 229, 230, 231, 232, 233, 234, 235, 236, 237, 238, 239, 240, 241, 242, 243, 244, 245, 246, 247, 248, 249, 250, 251, 252, 253, 254, 255, 0, 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, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 157, 158, 159, 160, 161, 162, 163, 164, 165, 166, 167, 168, 169, 170, 171, 172, 173, 174, 175, 176, 177, 178, 179, 180, 181, 182, 183, 184, 185, 186, 187, 188, 189, 190, 191, 192, 193, 194, 195, 196, 197, 198, 199, 200, 201, 202, 203, 204, 205, 206, 207, 208, 209, 210, 211, 212, 213, 214, 215, 216, 217, 218, 219, 220, 221, 222, 223, 224, 225, 226, 227], [202, 203, 200, 201, 206, 207, 204, 205, 210, 211, 208, 209, 214, 215, 212, 213, 186, 187, 184, 185, 190, 191, 188, 189, 194, 195, 192, 193, 198, 199, 196, 197, 234, 235, 232, 233, 238, 239, 236, 237, 242, 243, 240, 241, 246, 247, 244, 245, 218, 219, 216, 217, 222, 223, 220, 221, 226, 227, 224, 225, 230, 231, 228, 229, 138, 139, 136, 137, 142, 143, 140, 141, 146, 147, 144, 145, 150, 151, 148, 149, 122, 123, 120, 121, 126, 127, 124, 125, 130, 131, 128, 129, 134, 135, 132, 133, 170, 171, 168, 169, 174, 175, 172, 173, 178, 179, 176, 177, 182, 183, 180, 181, 154, 155, 152, 153, 158, 159, 156, 157, 162, 163, 160, 161, 166, 167, 164, 165, 74, 75, 72, 73, 78, 79, 76, 77, 82, 83, 80, 81, 86, 87, 84, 85, 58, 59, 56, 57, 62, 63, 60, 61, 66, 67, 64, 65, 70, 71, 68, 69, 106, 107, 104, 105, 110, 111, 108, 109, 114, 115, 112, 113, 118, 119, 116, 117, 90, 91, 88, 89, 94, 95, 92, 93, 98, 99, 96, 97, 102, 103, 100, 101, 10, 11, 8, 9, 14, 15, 12, 13, 18, 19, 16, 17, 22, 23, 20, 21, 250, 251, 248, 249, 254, 255, 252, 253, 2, 3, 0, 1, 6, 7, 4, 5, 42, 43, 40, 41, 46, 47, 44, 45, 50, 51, 48, 49, 54, 55, 52, 53, 26, 27, 24, 25, 30, 31, 28, 29, 34, 35, 32, 33, 38, 39, 36, 37], [148, 149, 146, 147, 152, 153, 150, 151, 156, 157, 154, 155, 160, 161, 158, 159, 164, 165, 162, 163, 168, 169, 166, 167, 172, 173, 170, 171, 176, 177, 174, 175, 180, 181, 178, 179, 184, 185, 182, 183, 188, 189, 186, 187, 192, 193, 190, 191, 196, 197, 194, 195, 200, 201, 198, 199, 204, 205, 202, 203, 208, 209, 206, 207, 212, 213, 210, 211, 216, 217, 214, 215, 220, 221, 218, 219, 224, 225, 222, 223, 228, 229, 226, 227, 232, 233, 230, 231, 236, 237, 234, 235, 240, 241, 238, 239, 244, 245, 242, 243, 248, 249, 246, 247, 252, 253, 250, 251, 0, 1, 254, 255, 4, 5, 2, 3, 8, 9, 6, 7, 12, 13, 10, 11, 16, 17, 14, 15, 20, 21, 18, 19, 24, 25, 22, 23, 28, 29, 26, 27, 32, 33, 30, 31, 36, 37, 34, 35, 40, 41, 38, 39, 44, 45, 42, 43, 48, 49, 46, 47, 52, 53, 50, 51, 56, 57, 54, 55, 60, 61, 58, 59, 64, 65, 62, 63, 68, 69, 66, 67, 72, 73, 70, 71, 76, 77, 74, 75, 80, 81, 78, 79, 84, 85, 82, 83, 88, 89, 86, 87, 92, 93, 90, 91, 96, 97, 94, 95, 100, 101, 98, 99, 104, 105, 102, 103, 108, 109, 106, 107, 112, 113, 110, 111, 116, 117, 114, 115, 120, 121, 118, 119, 124, 125, 122, 123, 128, 129, 126, 127, 132, 133, 130, 131, 136, 137, 134, 135, 140, 141, 138, 139, 144, 145, 142, 143], [109, 108, 111, 110, 105, 104, 107, 106, 117, 116, 119, 118, 113, 112, 115, 114, 125, 124, 127, 126, 121, 120, 123, 122, 133, 132, 135, 134, 129, 128, 131, 130, 141, 140, 143, 142, 137, 136, 139, 138, 149, 148, 151, 150, 145, 144, 147, 146, 157, 156, 159, 158, 153, 152, 155, 154, 165, 164, 167, 166, 161, 160, 163, 162, 173, 172, 175, 174, 169, 168, 171, 170, 181, 180, 183, 182, 177, 176, 179, 178, 189, 188, 191, 190, 185, 184, 187, 186, 197, 196, 199, 198, 193, 192, 195, 194, 205, 204, 207, 206, 201, 200, 203, 202, 213, 212, 215, 214, 209, 208, 211, 210, 221, 220, 223, 222, 217, 216, 219, 218, 229, 228, 231, 230, 225, 224, 227, 226, 237, 236, 239, 238, 233, 232, 235, 234, 245, 244, 247, 246, 241, 240, 243, 242, 253, 252, 255, 254, 249, 248, 251, 250, 5, 4, 7, 6, 1, 0, 3, 2, 13, 12, 15, 14, 9, 8, 11, 10, 21, 20, 23, 22, 17, 16, 19, 18, 29, 28, 31, 30, 25, 24, 27, 26, 37, 36, 39, 38, 33, 32, 35, 34, 45, 44, 47, 46, 41, 40, 43, 42, 53, 52, 55, 54, 49, 48, 51, 50, 61, 60, 63, 62, 57, 56, 59, 58, 69, 68, 71, 70, 65, 64, 67, 66, 77, 76, 79, 78, 73, 72, 75, 74, 85, 84, 87, 86, 81, 80, 83, 82, 93, 92, 95, 94, 89, 88, 91, 90, 101, 100, 103, 102, 97, 96, 99, 98]], "inverse": [[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, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 157, 158, 159, 160, 161, 162, 163, 164, 165, 166, 167, 168, 169, 170, 171, 172, 173, 174, 175, 176, 177, 178, 179, 180, 181, 182, 183, 184, 185, 186, 187, 188, 189, 190, 191, 192, 193, 194, 195, 196, 197, 198, 199, 200, 201, 202, 203, 204, 205, 206, 207, 208, 209, 210, 211, 212, 213, 214, 215, 216, 217, 218, 219, 220, 221, 222, 223, 224, 225, 226, 227, 228, 229, 230, 231, 232, 233, 234, 235, 236, 237, 238, 239, 240, 241, 242, 243, 244, 245, 246, 247, 248, 249, 250, 251, 252, 253, 254, 255, 0, 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], [218, 219, 216, 217, 222, 223, 220, 221, 194, 195, 192, 193, 198, 199, 196, 197, 202, 203, 200, 201, 206, 207, 204, 205, 242, 243, 240, 241, 246, 247, 244, 245, 250, 251, 248, 249, 254, 255, 252, 253, 226, 227, 224, 225, 230, 231, 228, 229, 234, 235, 232, 233, 238, 239, 236, 237, 146, 147, 144, 145, 150, 151, 148, 149, 154, 155, 152, 153, 158, 159, 156, 157, 130, 131, 128, 129, 134, 135, 132, 133, 138, 139, 136, 137, 142, 143, 140, 141, 178, 179, 176, 177, 182, 183, 180, 181, 186, 187, 184, 185, 190, 191, 188, 189, 162, 163, 160, 161, 166, 167, 164, 165, 170, 171, 168, 169, 174, 175, 172, 173, 82, 83, 80, 81, 86, 87, 84, 85, 90, 91, 88, 89, 94, 95, 92, 93, 66, 67, 64, 65, 70, 71, 68, 69, 74, 75, 72, 73, 78, 79, 76, 77, 114, 115, 112, 113, 118, 119, 116, 117, 122, 123, 120, 121, 126, 127, 124, 125, 98, 99, 96, 97, 102, 103, 100, 101, 106, 107, 104, 105, 110, 111, 108, 109, 18, 19, 16, 17, 22, 23, 20, 21, 26, 27, 24, 25, 30, 31, 28, 29, 2, 3, 0, 1, 6, 7, 4, 5, 10, 11, 8, 9, 14, 15, 12, 13, 50, 51, 48, 49, 54, 55, 52, 53, 58, 59, 56, 57, 62, 63, 60, 61, 34, 35, 32, 33, 38, 39, 36, 37, 42, 43, 40, 41, 46, 47, 44, 45, 210, 211, 208, 209, 214, 215, 212, 213], [108, 109, 114, 115, 112, 113, 118, 119, 116, 117, 122, 123, 120, 121, 126, 127, 124, 125, 130, 131, 128, 129, 134, 135, 132, 133, 138, 139, 136, 137, 142, 143, 140, 141, 146, 147, 144, 145, 150, 151, 148, 149, 154, 155, 152, 153, 158, 159, 156, 157, 162, 163, 160, 161, 166, 167, 164, 165, 170, 171, 168, 169, 174, 175, 172, 173, 178, 179, 176, 177, 182, 183, 180, 181, 186, 187, 184, 185, 190, 191, 188, 189, 194, 195, 192, 193, 198, 199, 196, 197, 202, 203, 200, 201, 206, 207, 204, 205, 210, 211, 208, 209, 214, 215, 212, 213, 218, 219, 216, 217, 222, 223, 220, 221, 226, 227, 224, 225, 230, 231, 228, 229, 234, 235, 232, 233, 238, 239, 236, 237, 242, 243, 240, 241, 246, 247, 244, 245, 250, 251, 248, 249, 254, 255, 252, 253, 2, 3, 0, 1, 6, 7, 4, 5, 10, 11, 8, 9, 14, 15, 12, 13, 18, 19, 16, 17, 22, 23, 20, 21, 26, 27, 24, 25, 30, 31, 28, 29, 34, 35, 32, 33, 38, 39, 36, 37, 42, 43, 40, 41, 46, 47, 44, 45, 50, 51, 48, 49, 54, 55, 52, 53, 58, 59, 56, 57, 62, 63, 60, 61, 66, 67, 64, 65, 70, 71, 68, 69, 74, 75, 72, 73, 78, 79, 76, 77, 82, 83, 80, 81, 86, 87, 84, 85, 90, 91, 88, 89, 94, 95, 92, 93, 98, 99, 96, 97, 102, 103, 100, 101, 106, 107, 104, 105, 110, 111], [157, 156, 159, 158, 153, 152, 155, 154, 165, 164, 167, 166, 161, 160, 163, 162, 173, 172, 175, 174, 169, 168, 171, 170, 181, 180, 183, 182, 177, 176, 179, 178, 189, 188, 191, 190, 185, 184, 187, 186, 197, 196, 199, 198, 193, 192, 195, 194, 205, 204, 207, 206, 201, 200, 203, 202, 213, 212, 215, 214, 209, 208, 211, 210, 221, 220, 223, 222, 217, 216, 219, 218, 229, 228, 231, 230, 225, 224, 227, 226, 237, 236, 239, 238, 233, 232, 235, 234, 245, 244, 247, 246, 241, 240, 243, 242, 253, 252, 255, 254, 249, 248, 251, 250, 5, 4, 7, 6, 1, 0, 3, 2, 13, 12, 15, 14, 9, 8, 11, 10, 21, 20, 23, 22, 17, 16, 19, 18, 29, 28, 31, 30, 25, 24, 27, 26, 37, 36, 39, 38, 33, 32, 35, 34, 45, 44, 47, 46, 41, 40, 43, 42, 53, 52, 55, 54, 49, 48, 51, 50, 61, 60, 63, 62, 57, 56, 59, 58, 69, 68, 71, 70, 65, 64, 67, 66, 77, 76, 79, 78, 73, 72, 75, 74, 85, 84, 87, 86, 81, 80, 83, 82, 93, 92, 95, 94, 89, 88, 91, 90, 101, 100, 103, 102, 97, 96, 99, 98, 109, 108, 111, 110, 105, 104, 107, 106, 117, 116, 119, 118, 113, 112, 115, 114, 125, 124, 127, 126, 121, 120, 123, 122, 133, 132, 135, 134, 129, 128, 131, 130, 141, 140, 143, 142, 137, 136, 139, 138, 149, 148, 151, 150, 145, 144, 147, 146]]} |
去混淆 [常规解法]
IDA 只能看到 br X10,根本不知道跳到哪,因为目标地址是运行时算出来的, 既然 IDA 搞不定,就用 Unicorn 跑一遍,可以装载 .so 文件、设置寄存器、从指定地址开始执行。每执行一条指令都可以”钩住”(hook),让我们打印出来。混淆代码跑起来的时候,br X10 里的 X10 是个具体的值(比如 0x3ba34),CPU 会照着跳。我们 hook 住每条指令就行
找基本块(从 BR 边构建 CFG)
基本块 = 一段指令的直线序列,中间没有跳转,末尾以跳转结尾。
扫描 trace,找所有 br/b/ret 指令。这些跳转的目标地址就是一个新块的起点。
目标地址 = 7 个基本块的起始地址。看到自循环(0x3ba70 → 0x3ba34)说明这是内部循环。
1 | for i, ins in enumerate(trace): |
混淆后的每个基本块里,指令分两类
dispatcher 指令(骨架,没用):
- adrl/adrp X9, 0x72C40 — 加载跳转表基址
- ldr X10, [X9, X12] — 从跳转表查地址
- mov W11, #0x740078FC — 加固定常量
- add X10, X10, X11 — 算出真实目标
- br X10 — 跳
- csel / cset / cmp — 分支选择
业务指令(真正的函数逻辑):
- ldrb / strb — 读写字节
- eor / and / or — 异或与或
- add w10, w14, w10 — 算术
- lsr / lsl — 移位
- sub w11, w14, #0x5e — 减法(带小常量)
原理: dispatcher 指令都有明显的模式(用特定寄存器如 X9/X10、涉及固定常量 0x740078FC 等)。过滤掉这些,剩下的就是真实逻辑。
1 | import struct |
实际逻辑
实现注册机
python
全流程是user_input → sec2023 → X1’ → VM_forward → TEA_encrypt → (token, 0)
1 | import json, os, sys |
C++
1 | // 编译: g++ -O2 -o keygen keygen_standalone.cpp |
无敌模式触发flag
输入注册机生成的key,就触发无敌模式了
反调试分析
整个会话里我们多次看到Process terminated
不管 hook 什么,进程经常点 OK 后就被 kill
很可能代码完整性校验,不然应该一进游戏就被kill了
写一个脚本去trace,故意触发反调试,再去hook了libc.so的退出的操作
1 | // v2: 先 hook sec2023 触发反调试, 再监控 exit 类函数被谁调用 |
sec2023 绕过 libc,用直接 syscall 杀自己
静态找 SVC 指令,既然是裸 syscall,那必然有 SVC 指令。用 IDA 扫整个 libsec2023.so 的 .text 段:
1 | for ea in .text: |
整个 libsec2023.so 里只有 1 条 SVC 指令,在 0x20FEC。
这是 syscall wrapper。参数从 X0 拿 syscall number,其他参数在 X1-X6。所有 syscall 都通过它。
xref 到 sub_20FD0,找出 12 个 caller:
1 | sub_1F3A4 : syscall 0x38 (56=openat) |
每个 caller 在调 sub_20FD0 前会 MOV W0, #syscall_nr。看每个的 syscall 号,对照 AArch64 Linux syscall table。sub_367B0 用 0xAC (172=getpid) 和 0x81 (129=kill) — 完全符合自杀模式。
1 | BL sub_36818 ; 做点事 (后面会看) |
sub_3681中
还会sleep2这也是为什么2s后才会退出
trace确认下
1 | // 检查 sub_367B0 是否真的走到 getpid/kill |
sec+0x364c4 调 sub_367B0 的地方
1 | sec+0x364c4 (在 sub_364B0 里): |
函数指针是加密存储的,XOR 解密后才调。这就是为什么静态 xref 找不到这条调用路径。
hooksyscall下看看
1 | // Hook sec2023 的 syscall wrapper, 记录所有 syscall |
1 | [syscall 56=openat] path="/proc/self/maps" |
触发条件就是内存中 libsec2023.so 的 .text 段和磁盘上原版不一致, 立刻 kill。
从 syscall trace看到:
- 读 /proc/self/maps
- 读 libsec2023.so(从磁盘)
- 每次读 4KB (count=0x1000)
- 循环很多次(读完整个 .so 文件)
- 读完就死
syscall trace 里 read 调用的 backtrace里有sub_370AC和0x37548
跟到sub_37940
立刻认出来:这是 CRC32 的标准算法:
crc = table[(crc ^ byte) & 0xFF] ^ (crc >> 8)
查表位置 X13 = off_70F70 指向 unk_5B3C8,这是 CRC32 的 256 项查找表。
检测函数 vtable[2]obj 返回检测结果(0=clean, 非0=detected)。它不自己杀,只是返回结果。杀不杀由调用者决定。
我们只需要在这做hook即可,假如反反调试
1 | // Bypass: 在 CMP W0, #0 之前强制 W0 = 0 |
没有被kill











































































