
📰 每日要闻
• YouTube 推出 AI 定制视频流功能:用户可用自然语言描述想看的内容,AI 自动生成个性化推荐 Feed,进一步压缩传统算法推荐空间。(The Verge)
• 奥本海默首次覆盖 Cardinal Infrastructure,给予跑赢大市评级:数据中心基础设施赛道持续获机构增持,AI 算力需求成为主要逻辑。(Investing.com)
• 美联储官员:能源通胀比预期更具粘性:Goolsbee 表示油价虽因美伊和谈预期有所回落,但整体仍高于战前水平,加息路径预期分歧加剧。(CNBC)
• Steam Deck 涨价,手持游戏机黄金时代或已终结:399 美元入门 PC 游戏的窗口正在关闭,掌机市场分化加速,大厂溢价化倾向明显。(The Verge)
上周有个同事找我,说他们的 App 被人二次打包重签了,里面塞了广告 SDK,渠道投诉一堆。他问我:「我们已经用了某加固平台,怎么还是被脱了?」
我当时没直接回答,而是问他:「你知道你用的是几代壳吗?」他一脸懵。
这就是问题所在——很多开发者接入了加固服务,但对壳的原理、强度、以及对应的脱壳手段完全不了解。加固不是万能的,理解它才能用好它。今天我们就来拆开这个话题,从壳原理到 FART 脱壳,走一遍全链路。
一、DEX 加固的演进:一代到三代壳
先搞清楚「壳」是什么。简单说,就是在你的原始 DEX 之外包一层或多层保护机制,让逆向工程师无法直接用 jadx、apktool 之类的工具还原出可读代码。
一代壳:DEX 整体加密
最早期的方案,思路非常直接:把原始 .dex 文件加密(AES/XOR),运行时由壳的 Application 解密,写入 /data/data/<pkg>/目录,再用 DexClassLoader 动态加载。
APK 安装
↓
壳 Application.attachBaseContext()
↓
解密 encrypted.dex → 写到 /data/data/.../files/
↓
DexClassLoader 加载解密后的 DEX
↓
原始代码正常运行
一代壳的脱壳非常简单:在文件系统里直接找。解密后的 DEX 会落盘到沙箱目录,用 root 权限 adb pull 就完事了。现在任何安全意识稍微强一点的加固平台都不会用这种方案。
二代壳:不落盘 + 函数粒度保护
二代壳的核心改进是两点:DEX 不再落盘(直接在内存中解密加载),以及引入方法体抽取(Function Extraction)——把每个方法的字节码从 DEX 中挖空,运行时动态填回去。
方法体抽取让静态分析完全失效。你用 jadx 打开加固后的 APK,所有方法要么是空的,要么只有一条 throw new RuntimeException。
但二代壳有个固有弱点:方法体最终还是要填回去才能执行。只要在 ART 执行字节码之前 hook 住关键节点,就能把完整的方法体 dump 出来。这就是 dexdump 方案的原理——hook ArtMethod::Invoke 或 interpreter 入口,在方法被调用瞬间记录完整字节码。
三代壳:VMP + 代码虚拟化
三代壳(VMP,Virtual Machine Protection)是目前最强的方案。它不只是加密字节码,而是把字节码编译成自定义的虚拟机指令集,运行时由壳内嵌的解释器执行。
脱壳难度极高:即使你 dump 出了内存,拿到的也是 VMP 字节码,而不是 Dalvik/ART 字节码。你需要逆向那个自定义解释器,才能还原出原始语义——工作量往往是数周甚至数月级别的。
主流加固平台(梆梆、爱加密、360加固、腾讯乐固)的高级版都会用三代壳对核心代码进行保护。但有一点值得注意:VMP 有较明显的性能开销,所以通常只对关键逻辑(如支付、鉴权、核心算法)使用 VMP,非核心代码仍用二代方案。这就给了我们可乘之机。
二、脱壳方法横评:各种姿势的优劣
方法一:文件系统 dump(一代壳专用)
最简单,不需要任何工具。对于落盘的 DEX:
# adb shell 进入 root
adb root
adb shell# 找 data 目录下的 dex/odex 文件
find /data/data/<pkg> \
-name "*.dex" \
-o -name "*.odex"# 拷出来
adb pull /data/data/<pkg>/files/对一代壳 100% 有效。但现在几乎所有商业加固平台都已经不落盘了,所以这个方法主要用来验证目标用的是哪代壳。
方法二:内存 dump(二代壳通用)
对于不落盘的壳,解密后的 DEX 只存在于内存中,核心思路是:在 DEX 加载完成后、方法执行前,扫描 /proc/pid/maps 找到内存中的 DEX 区域并 dump。
工具推荐:DumpDex(Frida 脚本) 和 objection 的 memory dump 功能。
// Frida 脚本:扫描内存找 DEX magic
const DEX_MAGIC = [
0x64, 0x65, 0x78,
0x0a // "dex\n"
];Process.enumerateRanges("r--")
.forEach(range => {
try {
const header =
range.base.readByteArray(4);
const arr =
new Uint8Array(header);
if (
arr[0] === DEX_MAGIC[0] &&
arr[1] === DEX_MAGIC[1] &&
arr[2] === DEX_MAGIC[2] &&
arr[3] === DEX_MAGIC[3]
) {
// 读取 DEX file_size 字段
const size =
range.base.add(0x20)
.readU32();
const dexData =
range.base.readByteArray(
size
);
// 写到 /data/local/tmp/
const f = new File(
`/data/local/tmp/
dump_${range.base}.dex`,
"wb"
);
f.write(dexData);
f.close();
}
} catch (e) {}
});内存 dump 对整体加密的壳有效,但对方法体抽取方案只能 dump 出「空壳 DEX」——方法体是空的。
方法三:FART——针对方法体抽取的主动调用脱壳
这是本文的重点。FART(Fart Android Runtime)是 2019 年由 hanbingle 开源的脱壳方案,专门针对二代壳的方法体抽取保护。
核心思想:与其被动等方法被调用,不如主动遍历所有 ClassLoader 中已加载的每个类、每个方法,强制触发一次执行,在 ArtMethod 层面把完整字节码捞出来。
三、FART 原理深挖
ArtMethod 结构与方法体位置
在 ART 虚拟机中,每个 Java 方法都对应一个 ArtMethod 结构体。二代壳进行方法体抽取时,会把字节码从 DEX 里挖走,但 ArtMethod 本身仍然存在——只是其 dex_code_item_offset_ 字段指向的 CodeItem 是空的或被篡改的。
壳在方法被调用前,会通过 JNI hook 或 inline hook 拦截执行流,把真实 CodeItem 填回 ArtMethod,再放行执行。这个「填回」的时机,就是 FART 的切入点。
FART 在 AOSP 源码层面修改了 ART 解释器,在 interpreter/interpreter.cc 的 Execute() 函数入口处插入 dump 逻辑:
// interpreter.cc(FART 修改后)
void Execute(
Thread* self,
const DexFile::CodeItem* code,
ShadowFrame& shadow_frame,
JValue* result,
bool stay_in_interpreter
) {
// FART 注入点
if (code != nullptr) {
dumpArtMethod(
shadow_frame.GetMethod()
);
}
// 原始执行逻辑...
} dumpArtMethod() 会把当前方法的 CodeItem 写入文件,记录该方法在 DEX 中的 offset 以及完整字节码。
主动调用:覆盖所有方法
仅靠「等方法被调用」是不够的——很多私有方法、初始化路径不常走的方法可能永远不会被触发。FART 的关键创新在于主动调用:
// Java 层主动枚举(FART 注入的代码)
public static void fartthread() {
new Thread(new Runnable() {
@Override
public void run() {
try {
// 等壳解密完成
Thread.sleep(1000 * 2);
fart();
} catch (Exception e) {
e.printStackTrace();
}
}}).start();
}public static void fart() {
ClassLoader cl =
Thread.currentThread()
.getContextClassLoader();
// 枚举所有已加载的类
Object[] dexFiles =
getDexFilesFromClassLoader(cl);
for (Object dexFile : dexFiles) {
// 遍历每个 Class
for (String className :
getClassNameList(dexFile)) {
loadClassAndInvoke(
cl, className
);
}
}
}private static void
loadClassAndInvoke(
ClassLoader cl,
String className
) {
try {
Class cls =
cl.loadClass(className);
// 对每个方法主动 invoke
for (Method m :
cls.getDeclaredMethods()) {
invokeMirrMethod(m);
}
} catch (Exception e) {}
} invokeMirrMethod() 是关键——它不走正常的 Java 反射调用栈,而是直接操作 native 层的 ArtMethod 结构,绕过访问权限检查,强制触发 interpreter 执行路径(哪怕方法体里是空的也不会崩)。
dump 文件结构与还原
FART 脱壳后会产出两类文件:
文件 | 内容 | 用途 |
|---|---|---|
xxx.dex | 内存中 dump 的 DEX(方法体空洞) | 提供类/方法结构骨架 |
xxx_ins.bin | 每个方法真实 CodeItem 的二进制 dump | 填充到 DEX 还原方法体 |
还原工具是 FART 配套的 dexfixer.py,它读取 .dex 和 _ins.bin,把每个方法对应的 CodeItem 按 offset 写回 DEX 文件:
# dexfixer.py 核心逻辑(简化版)
import structdef fix_dex(dex_path, ins_path):
with open(dex_path, "rb") as f:
dex = bytearray(f.read())with open(ins_path, "rb") as f:
while True:
header = f.read(16)
if not header:
break
# offset, size
offset, size = struct.unpack(
"<II", header[:8]
)
code = f.read(size)
# 写回 DEX 对应偏移
dex[offset:offset+size] = codewith open(
dex_path + "_fixed.dex", "wb"
) as f:
f.write(dex)四、实战:用 FART 脱壳某商业加固 APK
环境准备
FART 需要刷入修改过的 AOSP ROM,不能直接跑在正常 Android 设备上。目前官方支持的版本是 Android 8.1(FART 原版)和 Android 10(FART10,社区维护版本)。
推荐用 Pixel 2 + Android 8.1 的 FART ROM(原版兼容性最好)。如果没有 Pixel 2,可以用 Android Studio 模拟器 + 自编译 FART AOSP,但速度会很慢。
# 刷入 FART ROM 后验证
adb shell getprop ro.build.version.release
# 应输出 8.1.0# 检查 FART 标记
adb shell getprop ro.product.model
# FART ROM 通常改了 model 标识脱壳流程
安装目标 APK
↓
adb install target.apk
↓
启动 App,等待壳初始化完成
↓
FART 后台线程自动枚举并 dump(约30-120秒)
↓
pull dump 文件
adb pull /data/local/tmp/dex/
↓
dexfixer.py 还原 → jadx 反编译
# 拉取 dump 文件
adb pull /data/local/tmp/dex/ ./dump/# 遍历所有 .dex 找对应 _ins.bin
for dex in dump/*.dex; do
ins="${dex%.dex}_ins.bin"
if [ -f "$ins" ]; then
python3 dexfixer.py \
"$dex" "$ins"
fi
done# 用 jadx 反编译还原后的 dex
jadx -d output/ \
dump/*_fixed.dex五、加固方的反脱壳手段
知己知彼。作为防守方,了解加固平台用什么手段对抗 FART 这类工具,才能判断自己的保护强度。
1. 环境检测:识别 FART ROM
检测 ro.product.model、ro.build.fingerprint 等系统属性是否为已知 FART ROM 特征值。检测到则拒绝运行或行为变异。
// 壳的环境检测示例
val isFartEnv: Boolean get() {
val model = Build.MODEL
val fp = Build.FINGERPRINT
return model.contains(
"walleye_fart",
ignoreCase = true
) || fp.contains("fart")
}2. 主动调用检测:识别「非正常」的 invoke
FART 的主动调用有一个明显特征:在极短时间内批量调用大量方法,且调用栈深度异常浅(没有正常业务调用链)。加固平台可以 hook DeclaredMethod.invoke,检测是否来自 FART 的枚举线程。
3. 方法体按需解密(懒加载)
这是对 FART 最有效的对抗:不在应用启动时统一解密方法体,而是在方法第一次被真实业务调用时才解密填充。FART 主动 invoke 时传入的参数是随机的/null,可能触发 NPE 等异常,壳检测到异常调用就不解密,返回空体。
这也是为什么 FART 的后续改进版本(FART_bypass、强FART)要针对性地处理 NPE 异常,以及对方法执行路径加更细粒度的控制。
六、我的判断:选什么加固方案
说几个我实际用下来的结论,不代表任何平台:
1. 加固是必要的,但不是银弹。只要代码最终要在设备上跑,理论上就能被还原。加固的本质是提高逆向成本,不是让逆向变得不可能。
2. 选加固方案要看你的核心资产在哪里。如果核心算法在 Native 层(so),DEX 加固意义有限,你更应该关注 so 的混淆和反调试。如果核心逻辑都在 Java/Kotlin 层,才值得上三代壳。
3. VMP 有性能代价,别无脑全量用。实测一段业务代码用 VMP 保护后,执行速度可能慢 10-50 倍(取决于逻辑复杂度)。只保护真正需要保护的核心函数。
4. 加固 + 代码混淆 + 反调试是三件套,缺一不可。FART 能还原字节码,但还原出来的代码如果变量名都是 a/b/c、方法名都是 a0/b0/c0(R8 full mode),逆向效率还是很低。混淆和加固要组合用。
5. 你的 APK 真的需要防护吗?说真的,大多数 To B 的工具类 App,逆向价值很低。上完整加固套餐反而会增加 App 体积、影响启动速度、增加兼容性问题。想清楚你的威胁模型再决定。
安全是一场成本博弈:让攻击者的投入大于他的收益,就赢了。
尾声
FART 是 2019 年的工具,距今已经 7 年了,但它背后的原理——「主动调用触发 ART 解释器,在 native 层 dump CodeItem」——至今仍然有效,因为 ART 的基础执行模型没有根本性变化。
值得关注的是,随着 Android 14 在部分设备上引入更激进的 AOT 编译策略,以及 Profile-guided Compilation 的普及,未来 JIT 解释器的执行路径可能越来越少,纯粹依赖 interpreter hook 的脱壳方案会面临新挑战。
同样值得研究的还有 强FART(FART 改进版,针对按需解密场景)和 BlackDex(不需要特定 ROM,在标准 Android 上运行的脱壳工具)。这两个方向我后续有机会再展开聊。
本文首发于公众号,转载请注明出处。