使用 Frida 增强 FART 脱壳能力

FART 的局限性分析

FART 作为首款 ART 环境自动化脱壳工具,虽然开创了系统级自动脱壳的先河,但在实际使用中存在一些局限性:

需要刷机

FART 原版的核心实现需要修改 Android 系统源码(AOSP)并重新编译为自定义 ROM,然后刷入目标手机。这一要求带来了几个问题:

  • 设备受限:不是所有 Android 手机都支持刷入自定义 ROM,特别是搭载高通 Bootloader 锁的设备
  • 版本固定:FART 原版仅支持 Android 6.0 和 7.0,升级到更高版本需要大量适配工作
  • 维护成本高:每次需要更换目标设备或 Android 版本时,都需要重新编译和刷机
  • 日常使用不便:刷了 FART ROM 的手机不适合作为日常主力机使用

版本兼容性受限

Android 系统的快速迭代导致 ART 内部结构频繁变化。FART 依赖的 ClassLinker::LinkClass 函数在不同 Android 版本中的签名和内部实现差异较大:

  • Android 8.0 重构了类链接的部分逻辑
  • Android 10 引入了 hiddenapi 限制
  • Android 12+ 对 ART 内部符号进行了大量混淆和限制

这意味着 FART 原版很难直接在较新的 Android 版本上使用,每次版本升级都需要进行代码适配。

检测与对抗

虽然 FART 本身是系统级实现,难以被应用层直接检测,但壳程序可以通过其他方式间接对抗 FART:

  • 检测自定义 ROM:检查系统属性、build 信息,发现非官方 ROM 则拒绝运行
  • 延迟加载 DEX:分多次、多阶段加载 DEX,FART 只能 dump 已加载的部分
  • 运行时完整性校验:检查内存中 DEX 数据的完整性,发现被读取则清除
  • 反调试手段:检测 ptrace、frida-server 等调试工具

使用 Frida 实现无刷机脱壳

鉴于 FART 原版的局限性,使用 Frida 实现"无需刷机"的脱壳方案成为了更灵活的选择。Frida 是一个动态代码注入框架,可以在运行时 hook Java 方法和 native 函数,非常适合用于脱壳场景。

Frida 脱壳的基本思路

Frida 脱壳的核心思路是模拟 FART 的脱壳流程,但不需要修改系统源码:

  1. 通过 Frida 注入目标进程
  2. hook ART 虚拟机的关键函数(与 FART 相同的脱壳点)
  3. 在 hook 回调中执行 DEX dump 逻辑
  4. 将 dump 的数据保存到文件

相比 FART,Frida 方案只需要 root 权限(不需要刷机),且可以快速适配不同 Android 版本。

基础环境搭建

使用 Frida 进行脱壳前的准备工作:

# 安装 frida-tools
pip install frida-tools

# 手机端安装 frida-server(需要 root)
adb push frida-server /data/local/tmp/
adb shell chmod 755 /data/local/tmp/frida-server
adb shell /data/local/tmp/frida-server &

# 验证连接
frida-ps -U | grep target_app

Frida 脚本模拟 FART 脱壳流程

核心 dump 逻辑

以下 Frida 脚本模拟了 FART 在 ClassLinker::LinkClass 阶段的 dump 逻辑:

// frida_fart.js —— 模拟 FART 脱壳流程
Java.perform(function() {
    var artModule = Process.findModuleByName("libart.so");
    if (!artModule) {
        console.log("[-] libart.so not found");
        return;
    }

    // 存储已 dump 的 DEX,避免重复
    var dumpedDexSet = new Set();

    // 查找 ClassLinker::LinkClass 的符号地址
    // 注意:符号名会随 Android 版本变化
    var symbols = artModule.enumerateSymbols();
    var linkClassAddr = null;

    for (var i = 0; i < symbols.length; i++) {
        if (symbols[i].name.indexOf("ClassLinker") !== -1 &&
            symbols[i].name.indexOf("LinkClass") !== -1 &&
            symbols[i].type === 'function') {
            linkClassAddr = symbols[i].address;
            console.log("[*] Found LinkClass at: " + linkClassAddr);
            break;
        }
    }

    if (!linkClassAddr) {
        // 备选方案:通过偏移搜索
        console.log("[*] Trying offset-based search...");
        return;
    }

    // Hook LinkClass 函数
    Interceptor.attach(linkClassAddr, {
        onEnter: function(args) {
            // args[1] 通常是 mirror::Class 的 Handle
            this.klassHandle = args[1];
        },
        onLeave: function(retval) {
            // 链接成功才 dump
            if (retval.toInt32() !== 1) return;

            try {
                dumpDexFromClass(this.klassHandle);
            } catch(e) {
                console.log("[-] Dump error: " + e);
            }
        }
    });

    function dumpDexFromClass(klassHandle) {
        // 获取 mirror::Class 对象
        var klass = klassHandle.readPointer();
        // 获取关联的 DexFile 对象地址
        // 注意:偏移量随 Android 版本不同而变化
        var dexFilePtr = klass.add(getDexFileOffset()).readPointer();

        // 获取 DEX 文件的内存起始地址和大小
        var begin = dexFilePtr.add(getBeginOffset()).readPointer();
        var size = dexFilePtr.add(getSizeOffset()).readU32();

        if (dumpedDexSet.has(begin.toString())) return;
        dumpedDexSet.add(begin.toString());

        // 验证 DEX magic number
        var magic = Memory.readByteArray(begin, 4);
        var magicArr = new Uint8Array(magic);
        if (magicArr[0] !== 0x64 || magicArr[1] !== 0x65 || magicArr[2] !== 0x78) {
            return; // 不是 "dex" 开头
        }

        console.log("[+] Found DEX at " + begin + ", size: " + size);

        // Dump DEX 到文件
        var dexData = Memory.readByteArray(begin, size);
        var filename = "/data/local/tmp/fart_dump_" +
                       Date.now() + "_" +
                       begin.toString(16) + ".dex";
        var f = new File(filename, "wb");
        f.write(dexData);
        f.close();
        console.log("[+] DEX dumped to: " + filename);
    }
});

使用方法

# 启动目标应用并注入脚本
frida -U -f com.example.target -l frida_fart.js --no-pause

# 或附加到已运行的应用
frida -U com.example.target -l frida_fart.js

hook ClassLoader 加载时机 dump DEX

除了直接 hook ClassLinker::LinkClass,还可以从 Java 层的 ClassLoader 入手进行 dump:

hook DexClassLoader

Java.perform(function() {
    // Hook DexClassLoader 构造函数
    var DexClassLoader = Java.use("dalvik.system.DexClassLoader");
    DexClassLoader.$init.implementation = function(dexPath, optDir, libPath, parent) {
        console.log("[*] DexClassLoader created");
        console.log("    dexPath: " + dexPath);
        console.log("    optDir: " + optDir);

        // 调用原始构造函数
        this.$init(dexPath, optDir, libPath, parent);

        // 延迟一小段时间,确保 DEX 加载完成
        setTimeout(function() {
            dumpFromDexClassLoader(dexPath);
        }, 1000);
    };
});

hook BaseDexClassLoader.findClass

Java.perform(function() {
    var BaseDexClassLoader = Java.use("dalvik.system.BaseDexClassLoader");

    BaseDexClassLoader.findClass.implementation = function(name) {
        var clazz = this.findClass(name);

        if (clazz !== null) {
            // 获取类所在的 DEX 文件路径
            var dexPathList = this.pathList.value;
            var dexElements = dexPathList.dexElements.value;

            for (var i = 0; i < dexElements.length; i++) {
                var dexFile = dexElements[i].dexFile.value;
                if (dexFile !== null) {
                    var fileName = dexFile.mFileName.value;
                    console.log("[*] Class " + name + " from: " + fileName);
                }
            }
        }
        return clazz;
    };
});

通过 DexFile 对象直接 dump

Java.perform(function() {
    var DexFile = Java.use("dalvik.system.DexFile");

    // hook DexFile.openDexFile
    DexFile.openDexFile.overload(
        'java.lang.String', 'java.lang.String', 'int'
    ).implementation = function(sourceName, outputName, flags) {
        var result = this.openDexFile(sourceName, outputName, flags);
        console.log("[*] DexFile opened: " + sourceName);

        // 通过内部 API 获取 DEX 的 Cookie
        var mInternalCookie = this.mInternalCookie.value;
        var cookie = Java.cast(mInternalCookie, Java.use("long[]"));
        var cookieNative = cookie[0];

        // Cookie 中包含了 DexFile 的 native 指针
        // 可以通过指针 dump 完整 DEX
        dumpDexFromCookie(cookieNative);

        return result;
    };
});

Frida-FART 综合方案

单一的 Frida 脱壳或 FART 脱壳各有优势,将两者结合使用可以发挥更大的效果。以下是推荐的 Frida + FART 综合脱壳方案:

方案概述

阶段一:Frida 动态分析
    ├── 分析壳类型和加载流程
    ├── 定位壳程序的解密函数
    ├── 获取壳程序使用的 ClassLoader 信息
    └── 初步 dump 内存中的 DEX 片段

阶段二:FART 完整 dump
    ├── 在 FART 环境中运行目标应用
    ├── 利用阶段一获取的信息,确保所有类都被加载
    ├── FART 自动 dump 完整 DEX 和方法字节码
    └── 输出到 /sdcard/fart/

阶段三:修复与验证
    ├── 使用 dex_fix 修复 dump 的 DEX
    ├── 使用 jadx 验证修复结果
    └── 对 VMP 保护部分进行专项分析

阶段一:Frida 动态分析脚本

// phase1_analyze.js —— 分析壳程序行为
Java.perform(function() {
    console.log("=== Phase 1: Shell Analysis ===");

    // 1. 监控所有文件打开操作
    var open = Module.findExportByName("libc.so", "open");
    Interceptor.attach(open, {
        onEnter: function(args) {
            var path = Memory.readCString(args[0]);
            if (path.indexOf(".dex") !== -1 ||
                path.indexOf(".jar") !== -1 ||
                path.indexOf(".dat") !== -1 ||
                path.indexOf(".bin") !== -1) {
                console.log("[File Open] " + path);
            }
        }
    });

    // 2. 监控内存分配(大块内存分配可能是解密 DEX)
    var malloc = Module.findExportByName("libc.so", "malloc");
    Interceptor.attach(malloc, {
        onEnter: function(args) {
            this.size = args[0].toInt32();
        },
        onLeave: function(retval) {
            if (this.size > 1024 * 1024) { // 大于 1MB
                console.log("[Large malloc] " + this.size + " bytes at " + retval);
            }
        }
    });

    // 3. 监控 DexClassLoader 创建
    var DexClassLoader = Java.use("dalvik.system.DexClassLoader");
    DexClassLoader.$init.implementation = function(dexPath, optDir, libPath, parent) {
        console.log("[DexClassLoader] dexPath: " + dexPath);
        console.log("[DexClassLoader] optDir: " + optDir);
        console.log("[DexClassLoader] libPath: " + libPath);
        console.log("[DexClassLoader] parent: " + parent);
        return this.$init(dexPath, optDir, libPath, parent);
    };

    // 4. Hook Application.attachBaseContext(壳程序入口)
    var Application = Java.use("android.app.Application");
    Application.attachBaseContext.implementation = function(context) {
        console.log("[Application] attachBaseContext called");
        console.log("[Application] Class: " + this.getClass().getName());
        this.attachBaseContext(context);
    };
});

阶段二:FART dump 增强

在了解了壳程序的行为后,可以在 FART 环境中使用 Frida 辅助触发更多类加载:

// phase2_assist.js —— 辅助 FART 完整 dump
Java.perform(function() {
    console.log("=== Phase 2: Assist FART Dump ===");

    // 延迟遍历应用的所有 Activity,触发类加载
    setTimeout(function() {
        var ActivityThread = Java.use("android.app.ActivityThread");
        var app = ActivityThread.currentApplication();
        var context = app.getApplicationContext();

        // 获取 AndroidManifest.xml 中声明的所有 Activity
        var pm = context.getPackageManager();
        var packageInfo = pm.getPackageInfo(
            context.getPackageName(),
            0x00000040  // GET_ACTIVITIES
        );

        var activities = packageInfo.activities;
        console.log("[*] Found " + activities.length + " activities");

        for (var i = 0; i < activities.length; i++) {
            console.log("[*] Activity: " + activities[i].name);
            try {
                // 触发类加载(不实际启动 Activity)
                Java.use(activities[i].name);
            } catch(e) {
                console.log("[!] Failed to load: " + activities[i].name);
            }
        }
    }, 5000); // 等待壳程序完成初始化
});

实际案例:某加固 APP 的 Frida + FART 联合脱壳

以下通过一个实际案例演示 Frida + FART 联合脱壳的完整流程。目标应用使用了某商业加固方案,运行环境为 Android 7.1.2。

步骤一:识别壳类型

# 解压 APK 分析
unzip -l target.apk | grep -E "\.so|assets"

# 发现特征文件
# lib/arm64-v8a/libjiagu.so  → 360 加固
# assets/classes0.dex         → 加密的原始 DEX

步骤二:Frida 动态分析

# 注入分析脚本
frida -U -f com.example.target -l phase1_analyze.js --no-pause

# 观察输出:
# [File Open] /data/app/.../base.apk
# [File Open] /data/app/.../classes0.dex  ← 加密 DEX
# [Large malloc] 3145728 bytes           ← 解密 DEX 内存分配
# [DexClassLoader] dexPath: /data/data/.../cache/decrypted.dex
# [Application] Class: com.stub.StubApp  ← 壳 Application

分析结论:目标使用 360 加固,壳 Application 为 com.stub.StubApp,解密后的 DEX 被写入缓存目录并通过 DexClassLoader 加载。

步骤三:Frida 直接 dump

# 使用 Frida dump 脚本
frida -U -f com.example.target -l frida_fart.js --no-pause

# 输出:
# [+] Found DEX at 0x7a12345000, size: 2097152
# [+] DEX dumped to: /data/local/tmp/fart_dump_..._7a12345000.dex

步骤四:验证 dump 结果

# 拉取 dump 文件
adb pull /data/local/tmp/fart_dump_*.dex ./

# 反编译验证
jadx -d ./output/ ./fart_dump_*.dex

# 发现部分方法体为空(DEX 抽取特征),需要 FART 完整 dump

步骤五:FART 环境完整 dump

# 在刷了 FART 的设备上运行目标应用
adb shell am start -n com.example.target/.MainActivity

# 等待应用完全启动后,遍历各功能页面触发类加载

# 拉取 FART dump 数据
adb pull /sdcard/fart/ ./fart_output/

# 使用 dex_fix 修复
python dex_fix.py \
    --dex ./fart_output/com.example.target_0.dex \
    --methods ./fart_output/com.example.target_0_method.txt \
    --output ./fart_output/fixed.dex

步骤六:验证最终结果

# 反编译修复后的 DEX
jadx -d ./final_output/ ./fart_output/fixed.dex

# 检查关键方法是否完整
grep -r "targetMethod" ./final_output/sources/

进阶技巧

绕过 Frida 检测

部分壳程序会检测 Frida 的存在。常见的绕过方法:

// 绕过 Frida 检测(常见检测方式)
// 1. 检测 frida-server 端口
var socket = Module.findExportByName("libc.so", "connect");
Interceptor.attach(socket, {
    onEnter: function(args) {
        var port = args[1].add(2).readU16(); // sin_port
        if (port === 27042) { // frida 默认端口
            console.log("[!] Frida port detection blocked");
            args[1].add(2).writeU16(0); // 修改端口为 0
        }
    }
});

// 2. 检测 /tmp/frida-* 或 /data/local/tmp/frida-*
var readdir = Module.findExportByName("libc.so", "readdir");
// ... 拦截目录读取,过滤 frida 相关条目

// 3. 检测线程名 "frida-*"
var pthread_create = Module.findExportByName("libc.so", "pthread_create");
Interceptor.attach(pthread_create, {
    onEnter: function(args) {
        var name = args[2];
        if (name && !name.isNull()) {
            var nameStr = Memory.readCString(name);
            if (nameStr && nameStr.indexOf("frida") !== -1) {
                console.log("[!] Frida thread name blocked");
                Memory.writeUtf8String(name, "pool-1");
            }
        }
    }
});

多 DEX dump

对于使用了 MultiDex 的应用,需要确保所有 DEX 都被 dump:

// 枚举进程内存中所有 DEX
function enumerateAllDex() {
    var ranges = Process.enumerateRanges('r--');
    for (var i = 0; i < ranges.length; i++) {
        var range = ranges[i];
        try {
            var magic = Memory.readByteArray(range.base, 4);
            var magicArr = new Uint8Array(magic);
            if (magicArr[0] === 0x64 && magicArr[1] === 0x65 &&
                magicArr[2] === 0x78) {
                // 读取 DEX file_size
                var fileSize = range.base.add(32).readU32();
                if (fileSize > 0 && fileSize < range.size) {
                    console.log("[DEX] " + range.base + " size=" + fileSize);
                }
            }
        } catch(e) {}
    }
}

总结

Frida 为 FART 脱壳框架提供了重要的能力增强。通过 Frida 可以实现无刷机脱壳、动态分析壳程序行为、辅助触发更多类加载,以及绕过壳程序的检测机制。Frida + FART 的综合方案在实际逆向工作中表现优异:先用 Frida 进行快速分析和初步 dump,再用 FART 进行完整的方法级 dump,最后使用 dex_fix 修复并验证。这种分层脱壳策略能够应对大多数主流加固方案,是 Android 逆向分析者的必备技能组合。