引言

当你面对一个被标记为”[目标游戏追踪]”的 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 “加密字符串”的假阳性

最初的假设是字符串加密。但经过系统性的排查后发现:

  1. 没有任何编码的中文文本存在:UTF-8 CJK、GB2312、UTF-16LE 均未找到真实匹配
  2. XOR 穷举无果:对目标游戏名称进行 0x00-0xFF 全部单字节 XOR 解码,一无所获
  3. 0x39B660 区域的真相:那不是加密表,而是 OpenType/TrueType 字体数据——特性标签(ordnpnumsinfsubssupstnumvertvrt2)、字形度量、字符映射表

关键转折:这个二进制没有字符串加密。所有字符串都是明文英文。"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
2
3
4
5
6
TBZ:  44,789  (6.26%)
TBNZ: 3,172 (0.44%)
BLR: 29,647 (4.15%)
BR: 247 (0.03%)
B: 0 (0.00%) ← 一个都没有!
B.cond: 0 (0.00%) ← 一个都没有!

整个函数没有一条 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, targetB target
  • TBNZ XZR, #bit, targetNOP

三个函数共修补了 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
2
3
[base_addr]-[base_addr+21.8MB] r-xp 21.8 MB  /memfd:jit-cache (deleted)
[base_addr+21.8MB]-[base_addr+21.9MB] r--p 32 KB /memfd:jit-cache (deleted)
[base_addr+21.9MB]-[base_addr+23.1MB] rw-p 1.3 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
2
[shadowhook_addr] rwxp  [anon:shadowhook-enter]
[bss_addr] rwxp [anon:.bss]

[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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
外部加载器(可能存在独立的注入工具)
└→ ptrace 附加到游戏进程
└→ 远程调用 dlopen 加载本 .so
└→ __libc_init → init_main_obfuscated
├→ [检测运行环境]

├→ 注入器路径(首次加载):
│ → memfd_create("jit-cache")
│ → 读取自身 ELF 到 memfd
│ → 在游戏进程内 dlopen(memfd)

└→ 外挂路径(游戏内加载):
→ shadowhook 初始化
→ Hook eglSwapBuffers
→ 创建 4 线程(主循环/自瞄/ESP/菜单)
→ Hook libUE4.so 函数
→ 分配 GPU 内存(kgsl-3d0)
→ ESP 覆盖绘制 + 自瞄计算循环

5.2 自我保护特性

这个外挂使用了多层保护:

  1. 无静态导入:所有系统函数在运行时通过 custom_dlsym(DJB 哈希 + 布隆过滤器)解析
  2. OLLVM 控制流平坦化:三个巨型函数使用 TBZ 位测试混淆
  3. Linker Hookhook_android_linker 拦截所有动态库加载行为
  4. 运行时符号解析:通过 .gnu_debugdata 从其他加载库中提取符号
  5. memfd 加载:不在磁盘留下 .so 文件痕迹
  6. 进程伪装:使用 jit-cache 名称混入 ART JIT 缓存

5.3 能力图谱

能力 技术实现 证据
ESP 透视 Hook eglSwapBuffers + OpenGL ES 渲染 egl/gl 函数导入、OpenType 字体数据(文字渲染)
自瞄/辅助瞄准 三角函数角度计算 atan2fsinfcosfsincosfpowf
菜单系统 拦截 Android 输入事件 AInputQueue_*AMotionEvent_* 导入
配置下发 Socket 网络通信 socketconnectsendtorecvfrom
内存扫描 遍历进程内存 mprotectmmapdl_iterate_phdr

第六章:经验教训与方法论

6.1 “加密”的迷思

二进制逆向中最常见的误判就是”看到看不懂的数据 = 加密”。在这个案例中:

  • 字体数据 → 误判为加密字符串表
  • 高熵着色器二进制 → 误判为加密载荷
  • 字形分类表 → 误判为替换密码

经验:先排除已知格式(字体、图像、压缩数据),再考虑加密。

6.2 OLLVM 逆向的实用方法

对于 OLLVM 控制流平坦化,本文的方法论是:

  1. 统计法:扫描所有指令,寻找异常分布(0 条 B 指令是明确的信号)
  2. 寄存器分析法:通过 TBZ 使用的寄存器区分”假分支”(XZR)和”真状态”(X8)
  3. 修补法:自动替换回归无害指令(TBZ XZR → B),降低复杂度
  4. 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)