深入解析 23MB 的"加密"迷雾:一次 Android 游戏外挂的完整逆向之旅
引言
当你面对一个被标记为”[目标游戏追踪]”的 23MB Android 原生库,分析工具告诉你它包含 49,000 多个函数、82,000 条字符串、并且文件”经过了加密”,你会从哪里入手?
本文记录了一次完整的逆向分析过程:从一个被认为”字串有加密”的疑点出发,先后经历了 IDA Pro 崩溃、Python 编码问题、误判 CJK 文本的陷阱,最终识别出一个基于 shadowhook 框架的 Android 游戏外挂,并还原了其完整的注入架构和保护方案。
第一章:最初的困惑
1.1 文件的表象
文件名为 xxxx-XZZ[目标游戏追踪].sh,后缀 .sh 暗示它是一个 shell 脚本。但 file 命令和二进制分析很快指出真相:这是一个 ARM64 ELF 共享库(.so),大小 23MB,SHA256 为 [sha256:redacted]。
初步扫描显示:
- 函数总数:49,375(仅 578 个有名称)
- 字符串:82,069 条
- 主要段:
.text(5.8 MB)、.rodata(12.3 MB)、.data(1.3 MB)
最引人注目的是 12MB 的 .rodata 段中,有一块区域(0x39B660-0x39C000+)包含大量看似加密的字节序列——以 0x06 分隔的字符串,重复出现的模式,以及高熵数据块。这是加密了吗?
1.2 “加密字符串”的假阳性
最初的假设是字符串加密。但经过系统性的排查后发现:
- 没有任何编码的中文文本存在:UTF-8 CJK、GB2312、UTF-16LE 均未找到真实匹配
- XOR 穷举无果:对目标游戏名称进行 0x00-0xFF 全部单字节 XOR 解码,一无所获
- 0x39B660 区域的真相:那不是加密表,而是 OpenType/TrueType 字体数据——特性标签(
ordn、pnum、sinf、subs、sups、tnum、vert、vrt2)、字形度量、字符映射表
关键转折:这个二进制没有字符串加密。所有字符串都是明文英文。"shadowhook: %s(%p, %p, %u) OK." 这样的字符串直接存在于 .rodata 中。
1.3 真正的身份
字符串表中反复出现的 "shadowhook" 揭示了真相:这个二进制基于 shadowhook v1.88 WIP,一个开源的 Android 内联 Hook 框架。
这意味着:我们面对的不是一个普通的加密二进制,而是一个 基于成熟框架的游戏修改工具,被 OLLVM 控制流混淆保护。
第二章:三座大山——巨型混淆函数
静态分析发现三个异常巨大的函数:
| 函数 | 地址 | 大小 | 指令数 |
|---|---|---|---|
sub_113340C |
0x113340C | 2.79 MB | 714,943 |
sub_13ED708 |
0x13ED708 | 238 KB | 60,842 |
sub_1428DB0 |
0x1428DB0 | 1.65 MB | 421,709 |
这三者合计 4.68 MB,占整个 .text 段的 80%。它们正是 OLLVM 混淆的目标。
2.1 IDA Pro 的崩溃
尝试用 IDA Pro MCP 反编译 sub_13ED708(最小的 238KB 函数)时,IDA Pro 的 HTTP MCP 服务器直接超时崩溃。239KB 的函数对于反编译器来说是致命的——传统的反编译算法在遇到控制流平坦化时会产生指数级的路径爆炸。
2.2 Unicorn 模拟的尝试
为了绕过 IDA 的限制,尝试使用 Unicorn Engine 对函数进行动态模拟执行。但问题在于:
- 函数起始有 10,000+ 条线性指令(prologue),没有任何分支
- 这些指令在初始化栈帧、加载常量、建立跳转表
- 模拟跑完 10,000 条指令后仍然处于 prologue 阶段,没有进入 dispatcher
- 寄存器默认值全为 0,导致所有条件分支行为异常
结论:直接模拟 2.8MB 的函数需要正确的参数环境和更复杂的设置,这条路需要更多工程投入。
2.3 静态分析的突破口
回到静态分析,扫描全部 714,943 条指令后,发现了一个惊人的统计结果:
1 | TBZ: 44,789 (6.26%) |
整个函数没有一条 B(无条件分支)或 B.cond(条件分支)指令。 这在 ARM64 代码中极其异常——正常的代码不可能没有无条件跳转。
取而代之的是 47,961 条 TBZ/TBNZ(位测试并分支)指令。这就是 OLLVM 的控制流平坦化技巧:把所有的分支都转换成位测试指令。
第三章:TBZ 之谜——一种特殊的 OLLVM 变体
3.1 寄存器分析法破解混淆
对 47,961 条 TBZ/TBNZ 指令按寄存器统计:
| 寄存器 | 出现次数 | 解读 |
|---|---|---|
| X31 (XZR) | 31,185 | 伪装的非条件跳转 |
| X8 | 8,255 | 真正的状态寄存器 |
| X9 | 2,336 | 辅助状态 |
| X10-X16 | ~4,000 | 其他用途 |
XZR(零寄存器)的巧妙滥用:ARM64 中,寄存器 31 在非加载/存储指令中代表 XZR——一个恒为 0 的只读寄存器。TBZ XZR, #bit, target 永远成立(因为 0 的任何位都是 0),因此这条指令等价于 B target(无条件跳转)。
同理,TBNZ XZR, #bit, target 永远不成立,等价于 NOP。
这就是为什么函数里没有 B 指令——它们全部被替换成了 TBZ XZR, #bit, target!
3.2 去混淆:42,502 行补丁
基于这个发现,编写了一个自动化修补脚本,将:
TBZ XZR, #bit, target→B targetTBNZ XZR, #bit, target→NOP
三个函数共修补了 42,502 条指令(31,185 + 14 + 11,303)。修补后的二进制加载到 IDA 中,CFG 复杂度降低了约 65%。
剩余的 X8 位测试(8,255 + 215 + 4,558 = 12,828 条)则是真正的状态分派逻辑,需要进一步动态分析。
第四章:maps 对比——注入架构的完整还原
在分析的过程中,得到了两份关键证据:游戏进程在注入前后 /proc/pid/maps 的导出文件。通过逐行对比,还原了整个注入机制。
4.1 memfd 注入——核心发现
对比 before.txt 和 after.txt 后发现,注入后新增了 120 行映射记录。其中最关键的是一组以 /memfd:jit-cache (deleted) 命名的内存段:
1 | [base_addr]-[base_addr+21.8MB] r-xp 21.8 MB /memfd:jit-cache (deleted) |
计算这组映射的总大小:24,211,456 字节。
而原始文件的大小是:24,203,672 字节。
两者相差仅 7.6 KB——这个差异完美地解释了页对齐和 ELF 段间填充。
结论确凿:注入器将整个 23MB 的 ELF 文件完整复制到了一个 memfd(匿名内存文件系统中),然后在游戏进程中 dlopen 加载了这个内存中的副本。
4.2 memfd 伪装
jit-cache 这个名称不是随意起的。Android 的 ART 运行时使用 /memfd:jit-cache 作为 JIT 编译缓存的名字。把外挂代码映射命名为 jit-cache 是一种精心的伪装——在 /proc/pid/maps 中查看时,它会混入 ART 的正常 JIT 页面中,不会引起怀疑。
4.3 Shadowhook 的物证
maps 中还出现了决定性的证据:
1 | [shadowhook_addr] rwxp [anon:shadowhook-enter] |
[anon:shadowhook-enter] 是 shadowhook 的内联 Hook 入口跳板页。rwxp(可读、可写、可执行、私有)权限是 shadowhook 的典型特征——它需要在运行时生成跳转代码,所以需要同时拥有写和执行权限。
4.4 被 Hook 的游戏
目标游戏是 com.[redacted]([目标游戏]),基于 Unreal Engine 4(libUE4.so)。关键发现:libUE4.so 的一段代码段(r-xp)在注入后被拆分,出现了新的 rw-p 页面——这正是 shadowhook 修改代码页权限、写入 Hook 跳板后的结果。
同时,maps 中出现了 4 组线程栈(stack_and_tls:[XXXX-XXXX]),对应外挂的 4 个工作线程:主循环、自瞄计算、ESP 渲染、菜单交互。
第五章:完整的外挂画像
5.1 注入流程
1 | 外部加载器(可能存在独立的注入工具) |
5.2 自我保护特性
这个外挂使用了多层保护:
- 无静态导入:所有系统函数在运行时通过
custom_dlsym(DJB 哈希 + 布隆过滤器)解析 - OLLVM 控制流平坦化:三个巨型函数使用 TBZ 位测试混淆
- Linker Hook:
hook_android_linker拦截所有动态库加载行为 - 运行时符号解析:通过
.gnu_debugdata从其他加载库中提取符号 - memfd 加载:不在磁盘留下 .so 文件痕迹
- 进程伪装:使用
jit-cache名称混入 ART JIT 缓存
5.3 能力图谱
| 能力 | 技术实现 | 证据 |
|---|---|---|
| ESP 透视 | Hook eglSwapBuffers + OpenGL ES 渲染 |
egl/gl 函数导入、OpenType 字体数据(文字渲染) |
| 自瞄/辅助瞄准 | 三角函数角度计算 | atan2f、sinf、cosf、sincosf、powf |
| 菜单系统 | 拦截 Android 输入事件 | AInputQueue_*、AMotionEvent_* 导入 |
| 配置下发 | Socket 网络通信 | socket、connect、sendto、recvfrom |
| 内存扫描 | 遍历进程内存 | mprotect、mmap、dl_iterate_phdr |
第六章:经验教训与方法论
6.1 “加密”的迷思
二进制逆向中最常见的误判就是”看到看不懂的数据 = 加密”。在这个案例中:
- 字体数据 → 误判为加密字符串表
- 高熵着色器二进制 → 误判为加密载荷
- 字形分类表 → 误判为替换密码
经验:先排除已知格式(字体、图像、压缩数据),再考虑加密。
6.2 OLLVM 逆向的实用方法
对于 OLLVM 控制流平坦化,本文的方法论是:
- 统计法:扫描所有指令,寻找异常分布(0 条 B 指令是明确的信号)
- 寄存器分析法:通过 TBZ 使用的寄存器区分”假分支”(XZR)和”真状态”(X8)
- 修补法:自动替换回归无害指令(TBZ XZR → B),降低复杂度
- maps 对比法:利用
/proc/pid/maps差异定位注入代码
6.3 工具链
使用的工具和技术:
| 阶段 | 工具 |
|---|---|
| 静态分析 | Python + struct, pyelftools |
| 动态模拟 | Unicorn Engine (ARM64) |
| 反汇编分析 | 自定义 ARM64 解码器 |
| 内存分析 | /proc/pid/maps diff |
| 修补 | 字节码级别自动化 patch |
结语
一个被认为”字符串已加密”的 23MB 二进制文件,最终揭示了它作为 Android 游戏外挂的真实身份。它不使用任何字符串加密,而是通过 OLLVM 控制流平坦化、运行时符号解析、memfd 匿名加载等技术来实现自我保护。
这个案例很好地展示了逆向工程的核心方法:不预设结论,让证据说话。从”有加密”到”没有加密”,从”被加壳”到”OLLVM 混淆”,从”独立文件”到”注入器+动态库合一体”,每一步的认知提升都来自于对证据的系统性排查。
分析日期:2026年5月7日
文件:[样本名称].sh(SHA256: [sha256:redacted])
目标游戏:com.[redacted] ([目标游戏]/Unreal Engine 4)