banner
毅种循环

毅种循环

请将那贻笑罪过以逐字吟咏 如对冰川投以游丝般倾诉

记一次失败的逆向:Android 16+数字加固+FlutterAPP踩坑

这篇文章是我对近期分析 “某金融” APP(v5.x 版本)的全过程复盘。

事先声明:本人完全没有搞过 Android 逆向,仅仅也只是会用工具的初学者,有很多内容和想法都在 AI 的辅助下完成,避免不了出现勘误,感激不尽。

本次逆向的背景极其特殊:目标运行在 Android 16 系统上,使用了 Frida 17.5.1,且 APP 不仅加了 360 强力壳,核心业务逻辑还是基于 Flutter 开发的。

这就导致了一个有趣的现象:我原本准备好的 “Java 层脱壳三板斧” 全部失效,被迫转战 Native 层,最后还要解决高版本安卓的权限和环境问题。为了防止自己(或者后来的读者)忘掉这些基础操作,我也把最基础的环境搭建步骤记录在了第一部分。


分析目标: Android APP

运行环境:Google Pixel (Android 16), Magisk Root

工具链:Frida 17.5.1, Blutter, SoFixer, JADX, Kali


第一阶段:常规开局与基础脱壳教程#

拿到 APK 后,查壳确认为 360 加固。按照惯例,面对一代或二代壳,最快的办法就是利用 frida-dexdump 从内存中暴力搜索并导出 DEX 文件。

image

1.1 基础工具:frida-dexdump 使用指南#

虽然这次在这个 APP 上栽了跟头,但这套流程对付大多数普通加固非常有效,这里先做一个备忘录。

原理:

360 加固虽然会加密本地的 DEX 文件,但在 APP 运行时,必须把 DEX 解密加载到内存中给虚拟机执行。frida-dexdump 就是利用 Frida 脚本在内存中搜索 DEX 文件的特征头(dex\n035),然后把它 “扣” 出来。

这对于对付一代壳(整体加固)非常有效,对于部分二代壳(函数抽取)也能把整体结构 Dump 下来(虽然方法体可能是空的)。

以下是详细的使用步骤:

1. 环境准备 (至关重要)#

在使用 frida-dexdump 之前,你必须确保你的 Frida 环境是通的。

  • PC 端
    • 安装 Frida 工具包:pip3 install frida-tools
    • 安装 DexDump:pip3 install frida-dexdump
  • 手机 / 模拟器端
    • 手机需要 Root
    • 手机里必须运行 frida-server,且版本最好与 PC 端的 Frida 版本一致。
    • 确保 ADB 连接正常 (adb devices 能看到设备)。

2. 基本使用模式#

frida-dexdump 支持两种主要模式:Spawn(启动模式)Attach(附加模式)

模式 A:附加模式 (推荐)#

适用于应用已经运行,或者你需要手动绕过一些启动时的检测后再进行 Dump。

  1. 在手机上手动打开目标 App,让它停留在主界面(此时壳代码通常已经运行完毕,DEX 已解密加载到内存)。
  2. 在电脑终端运行命令:
    Bash
# -U 表示连接 USB 设备
# -F 表示自动附加到当前最前端显示的 App (Front-most)
frida-dexdump -U -F

或者指定包名 / 进程名: Bash

frida-dexdump -U -n <App包名或进程名>

模式 B:启动模式 (Spawn)#

适用于 App 启动速度很快,或者你需要在 App 启动的一瞬间就介入。但对于强壳,Spawn 模式容易被反调试检测到。

Bash

# -f 后跟包名,会自动重启 App 并注入
frida-dexdump -U -f com.example.targetapp

3. 常用参数详解#

  • -o <路径> (Output): 指定导出的 DEX 文件保存的文件夹。如果不写,默认会以包名在当前目录生成文件夹。
  • -d (Deep Search): 深度搜索模式。如果普通模式 Dump 不全或者找不到,加上这个参数。它会扫描更多内存段,速度会慢一些,但更全面。
  • --sleep <秒数>: 在启动 App 后等待多少秒再开始 Dump。有些壳解密比较慢,可以设置延时等待解密完成。

4. 结果分析#

运行成功后,终端会显示类似 [INFO] DexSize=xxxx, SavePath=... 的日志。

  1. 打开生成的文件夹。
  2. 你会看到多个 .dex 文件(如 classes.dex, classes2.dex...)。
  3. 如何辨别哪个是原本的 DEX?
    • 看大小:通常最大的那个,或者几 MB 大小的,是业务逻辑所在的 DEX。
    • 使用工具查看:使用 Jadx 打开这些 DEX 文件。
      • 如果看到包名是 com.qihoo.util 或类似的,那是壳的代码。
      • 如果看到了目标 App 的真实包名和业务代码,那就是脱壳成功了。

5. frida-dexdump 注意事项#

使用 frida-dexdump 可能会遇到以下情况:

  1. 能 Dump 出结构,但方法是空的 (nop) : 这是因为 有些加密壳使用了函数抽取技术。DEX 文件结构在内存里是完整的,但是具体的方法指令(Code Item)在执行前是空的,只有执行该方法时才临时解密填入,执行完又抹掉。
    • 表现:用 Jadx 打开 Dump 出来的 DEX,能看到类名和方法名,但点进方法看代码时,全是空的或者只有 return
    • 对策:这时候 frida-dexdump 就不够用了,通常需要使用更高级的基于 ART 运行时的主动调用工具(如 FARTYoupk 等)来通过遍历调用所有函数强行触发解密。
  2. 反调试导致闪退: 如果运行 frida-dexdump 时 App 闪退,说明被检测到了。
    • 对策:你需要先用 Frida 脚本(如 frida-il2cpp-bridge 带的 bypass 或者是专门的 Anti-Anti-Frida 脚本)去过掉反调试,或者使用魔改版的 frida-server (hluda)。

总结#

frida-dexdump 是脱壳的第一板斧。不管什么壳,先用它跑一遍(建议加 -d 参数)。运气好能直接拿代码;运气不好(遇到抽取壳),也能拿到完整的类结构,为后续分析打下基础。

环境准备:

  1. PC 端
  2. 手机端
    • 手机必须 Root
    • 手机里运行 frida-server(版本必须和电脑端的 frida-tools 一致)。
    • 确保 ADB 连接正常 (adb devices 能看到设备)。

操作步骤

  1. 在手机上点击打开目标 APP,让它停留在主界面。
  2. 在电脑终端输入命令(推荐使用附加模式,比较稳):
  3. 如果成功,工具会在当前目录下生成一个以包名命名的文件夹,里面就是脱下来的 .dex 文件。

image

预期结果:在文件夹中生成一堆 .dex 文件。实际结果:终端直接报红,进程崩溃。

ERROR:frida-dexdump:[-] Error: access violation accessing 0x70316bc000

为什么会失败?#

这是 Android 16 新特性与 360 加固对抗的产物。

  1. 内存权限收紧:Android 16 对内存页的权限管理极其严格。
  2. 加固对抗:360 加固在解密 DEX 后,故意将这段内存页的权限设置为 “仅执行” (x) 或 “不可读”
  3. 冲突:当 frida-dexdump 试图通过 readByteArray 去读取这段内存时,因为没有 r (Read) 权限,触发了系统的 Access Violation(访问违规),导致脚本或应用崩溃。

0x02 破局:手写脚本 “暴力” 提权#

既然标准工具因为 “权限不足” 读不到,接下来思路很明确:我是 Root 用户,我可以在读取之前,强行修改内存权限。

我编写了一个自定义的 Frida 脚本 dump_force.js

核心逻辑#

  1. 利用 Process.enumerateRanges 遍历所有内存段。
  2. 利用 Memory.scan 暴力搜索 DEX 文件头魔法数 64 65 78 0a (dex\n)。
  3. 关键步骤:在读取之前,使用 Memory.protect 将该内存段强制改为 rwx (可读可写可执行)。
  4. 读取数据并保存到 SD 卡。

完整代码实现#

// dump_force.js - 暴力修改权限脱壳脚本

function write_dex(ptr, len, index) {
    // 保存路径:/sdcard/Download/,方便后续 adb pull
    var filename = "/sdcard/Download/dump_" + ptr + "_" + len + ".dex";
    var file = new File(filename, "wb");
    
    try {
        // 尝试直接读取(如果是普通的内存段)
        var buffer = ptr.readByteArray(len);
        file.write(buffer);
        console.log("[*] 正常读取成功: " + filename);
    } catch (e) {
        // 捕获 Access Violation 错误
        console.log("[-] 读取受阻 (360保护),准备暴力权限...");
        
        try {
            // 【核心操作】强行修改内存页权限为 rwx
            // ptr: 内存起始地址
            // len: 大小
            Memory.protect(ptr, len, 'rwx');
            
            // 再次尝试读取
            var buffer = ptr.readByteArray(len);
            file.write(buffer);
            console.log("[+] 权限修改成功!文件已抢救: " + filename);
        } catch (e2) {
            console.log("[!] 彻底失败,可能是内核级保护或无效地址: " + e2);
        }
    }
    file.close();
}

function scan_dex() {
    console.log("[*] 开始全内存暴力扫描 DEX 头...");
    
    // 遍历所有“可读”内存段 (r--),这通常是 DEX 存放的区域
    // 如果 360 把 DEX 藏在不可读区域,这里参数可以改为 '---' 或 'r-x'
    Process.enumerateRanges('r--').forEach(function (range) {
        try {
            // 搜索 DEX 特征头: dex\n (64 65 78 0a)
            // 360 可能会抹掉版本号(035),所以只搜前4个字节
            Memory.scan(range.base, range.size, "64 65 78 0a", {
                onMatch: function (address, size) {
                    // 简单的校验:DEX 文件偏移 0x20 处存放了文件大小
                    try {
                        var dex_size = address.add(0x20).readUInt();
                        
                        // 过滤掉太小的垃圾数据 (小于 100KB)
                        if (dex_size > 100000 && dex_size < 50000000) {
                            console.log("[+] 发现 DEX 魔法数: " + address + " 大小: " + dex_size);
                            // 执行导出
                            write_dex(address, dex_size, address.toString());
                        }
                    } catch (e) {}
                },
                onError: function (reason) {},
                onComplete: function () {}
            });
        } catch (e) {}
    });
}

setImmediate(scan_dex);

执行结果#

运行命令 frida -U -F -l dump_force.js 后,终端刷出了一片绿色的 [+] 权限修改成功。

随后我使用 adb pull /sdcard/Download/. 将文件拉回电脑,得到了几个关键文件:

  • dump_0x70316bc000.dex (约 8MB)
  • dump_0x7031655480.dex (约 6.7MB)

这一步,战胜了 Android 16 的权限限制,拿到了加密后的内存镜像。


0x03 迷雾:看起来完美的 “空壳”#

由于是从内存 dump 的,文件头部的 Checksum(校验和)通常是错的。直接拖入 JADX 会报错。

我编写了一个简单的 Python 脚本修复了校验和,然后将那个 8MB 的 DEX 文件拖入 JADX

import zlib
import struct
import os
import glob

def fix_dex_checksum(filename):
    with open(filename, 'rb') as f:
        data = bytearray(f.read())

    # DEX 文件至少要有头部信息
    if len(data) < 12:
        print(f"[-] {filename} is too small, skipping.")
        return

    # 1. 修复文件长度 (FileSize) - Offset 32 (0x20), 4 bytes
    # 有些 dump 下来的文件长度可能和 header 里的不一样,顺便修了
    file_size = len(data)
    struct.pack_into('<I', data, 32, file_size)

    # 2. 修复签名 (Signature) - Offset 12 (0xC), 20 bytes (SHA-1)
    # JADX 有时候不校验签名只校验 checksum,但为了保险,标准修复通常会做 SHA1
    # 3. 修复校验和 (Checksum) - Offset 8 (0x8), 4 bytes
    # 校验范围:从 offset 12 开始到文件结束
    # 算法:Adler-32
    ignored_part = data[0:12]
    checksum_part = data[12:]
    
    new_checksum = zlib.adler32(checksum_part) & 0xFFFFFFFF
    
    # 将新的 checksum 写入 offset 8 (小端序)
    struct.pack_into('<I', data, 8, new_checksum)

    # 保存覆盖原文件
    with open(filename, 'wb') as f:
        f.write(data)
    
    print(f"[+] Fixed Checksum for: {filename}")

# 扫描当前目录下所有的 dump_force 开头的 dex
dex_files = glob.glob("dump_force_*.dex")

if not dex_files:
    print("No dump_force_*.dex files found in current directory.")
else:
    print(f"Found {len(dex_files)} dex files. Fixing...")
    for dex in dex_files:
        try:
            fix_dex_checksum(dex)
        except Exception as e:
            print(f"[-] Error fixing {dex}: {e}")

    print("Done! Try opening them in JADX now.")

JADX 成功反编译了,左侧的包结构非常清晰:

  • android.support.*
  • com.qihoo.util.* (壳代码)
  • cn.com.ljzxx.* (目标包名)

但当我兴奋地点开 cn.com.ljzxxc 下的业务类时,傻眼了:

Java

// LoginActivity.java
public class LoginActivity extends Activity {
    public void onCreate(Bundle bundle) {
        // 空的!或者只有一行 super.onCreate(bundle);
    }
    
    public void login() {
        // 空的!
    }
}

所有的方法体要么是空的,要么全是 nop 指令,要么只有简单的 return

这里的思考#

这非常符合 “二代壳(函数抽取 / 类抽取)” 的特征。

  • 原理:加固厂商把 DEX 里的具体指令(Code Item)抽走了,填入空数据。只有当这个方法被真正调用时,壳的代码才会拦截执行流,从别的地方(通常是加密的 bin 文件)解密出指令,填回去,执行完再抹掉。
  • 结论:我 Dump 下来的是 “未填充” 的骨架。

0x04 挣扎:FART 主动调用的死胡同#

既然是 “用时恢复”,那破解思路就是 “主动触发” 。这就是著名的 FART (Fast Android Art Unpacker) 原理。

只要我写个脚本,把 APP 里所有的类加载一遍,把所有函数都 “摸” 一遍,壳就不得不解密代码,我再趁机 Dump。

我编写了一个基于 Frida 的 FART 模拟脚本 fart_memory.js

// fart_memory.js - 模拟 FART 主动调用
Java.perform(function() {
    console.log("[*] 开始遍历内存中的类...");
    
    // 1. 遍历所有已加载的类
    Java.enumerateLoadedClasses({
        onMatch: function(name) {
            // 过滤:只处理目标包名
            if (name.startsWith("cn.com.ljz")) {
                console.log("[*] 尝试预热类: " + name);
                try {
                    // 2. 强行加载类
                    var clazz = Java.use(name);
                    // 3. 反射获取所有方法,触发壳的解密逻辑
                    var methods = clazz.class.getDeclaredMethods();
                    console.log("    - 触发解密成功,方法数: " + methods.length);
                } catch (e) {
                    console.log("    - 失败: " + e);
                }
            }
        },
        onComplete: function() {
            console.log("[SUCCESS] 遍历结束,请立刻 Dump!");
        }
    });
});

结果令人绝望:

脚本运行后,只打印出了寥寥无几的几个类:

  • cn.com.lj.R$id
  • cn.com.lj.R$layout
  • cn.com.lj.BuildConfig

根本没有 LoginActivity,没有 Utils,没有任何业务类

这意味着这些核心类 根本没有被加载到 Java 虚拟机中,或者 360 用了某种(如自定义 ClassLoader)把它们从 enumerateLoadedClasses 的列表中隐藏了。

我随后尝试了更底层的 Java.choose("dalvik.system.DexFile", ...) 去扫描堆内存,结果因为 Android 16 移除了相关 API 而报错。


0x05 结果:方向错了!#

我在 Java 层折腾了整整一天,尝试了脱壳、修复、主动调用、内存扫描,结果依然是一具空壳。

疑点:如果核心代码都被抽取了且没加载,那现在的 APP 界面是谁在跑?登录框是谁画出来的?

我重新审视了那个 8MB 的 DEX 文件。虽然代码是空的,但我决定看看里面的 字符串常量池。

我用 strings 命令(或记事本)搜索文件内容,突然,一行不起眼的路径映入眼帘:

/data/app/~~xxx/cn.com.ljz.../lib/arm64/libapp.so

libapp.so?这是一个非标准的 SO 命名。

我立刻解压了原始 APK,打开 lib/arm64-v8a/ 目录。

那一刻,真相大白:

image

libflutter.so   <-- 引擎
libapp.so       <-- 业务代码

这是一个 Flutter 应用!

最终结论#

  • 为什么 DEX 是空的? 因为 Flutter 应用的业务逻辑(Dart 代码)是 AOT 编译成机器码放在 libapp.so 里的。DEX 里只有 Java 层的启动引导代码,本来就没多少东西。
  • 360 保护了什么? 360 确实加了壳,但它主要保护的是 Java 层(虽然没啥用)和 加密了 libapp.so
  • 我的错误:我一直在试图脱掉它的衣服找肉体,结果发现衣服下面是机械骨骼,完全就是搞错对象了。

下一步行动:

放弃 DEX,放弃 FART。

目标锁定:libapp.so。我们需要从内存中 Dump 出这个文件,然后进行 Dart 逆向。


我们将视角从 Java 层的死胡同拉出来,正式进入 Native 层与 Flutter 虚拟机的深水区。这一部分的战斗更加硬核,涉及到内存黑洞、环境依赖地狱以及指针修正等底层细节。

历经千辛万苦利用暴力提权脚本 Dump 出了 DEX 文件,结果发现全是空壳。通过分析文件残留字符串,我确认这是一个 Flutter 应用。真正的业务逻辑藏在 Native 层的 libapp.so 中。

现在的任务很明确:拿到 ****libapp.so**** -> 还原 Dart 代码 -> Hook 获取明文。

但这在 Android 16 + 360 加固的组合拳下,比我想象的难得多。


第二阶段:0x01 尝试 1:提取 libapp.so 的三大难关#

360 加固对 SO 文件的保护通常是 “压缩 + 加密 + 内存加载”。直接解压 APK 得到的是加密文件,必须从内存中 Dump。

写了一个 Frida 脚本去 Dump,结果脚本刚跑起来就崩了。

难关一:API 的 “失踪”#

在 Android 16 上,Frida 常用的 Module.findBaseAddress ("libapp.so") 竟然抛出了 TypeError: not a function 或者找不到模块。

原因:Android 16 对 Linker 做了一些改动,导致 Frida 的部分上层 API 兼容性出现问题。

解决:我被迫使用了更底层的 Process.findModuleByName ("libapp.so"),这个 API 依然坚挺。

难关二:内存 “黑洞”#

解决了基址问题后,我尝试读取整个 SO 文件大小的内存 (readByteArray (module.size))。结果脚本再次崩溃,报错 Access Violation。

原因:360 加固,它加载 SO 后,会把 ELF 头(Header)或者某些不用的 Section 所在的内存页取消映射或者设为不可读。试图一口气读完整个文件,只要碰到这几页 “黑洞”,就会引发崩溃。

对策:分块容错读取(Chunked Dump)

我重写了脚本,像切香肠一样,每次只读 4KB(一页)。

  • 读到了?写入文件。
  • 读不到(报错)?填入 4KB 的 0x00 占位,跳过这一页。
    这样既保证了文件不崩,又保证了偏移量(Offset)不乱 —— 这对后续分析至关重要。

难关三:无处安放的 “家”#

脚本想把文件写到 /data/local/tmp,结果报错 Permission denied。

原因:Android 16 的 SELinux 策略到了变态的地步,普通 APP 进程(Frida 注入后属于 APP 进程)根本无权写入这个公共目录。

解决:利用 Java 反射获取 APP 自己的私有目录 getFilesDir (),回自己家写文件总没人管了吧!

🎯 最终成果脚本 (dump_so_final.js)#

var target_so = "libapp.so";

Java.perform(function() {
    // 1. 兼容性查找模块
    var module = Process.findModuleByName(target_so);
    
    // 2. 获取私有目录路径 (Android 16 避坑)
    var currentApp = Java.use("android.app.ActivityThread").currentApplication();
    var save_path = currentApp.getApplicationContext().getFilesDir().getAbsolutePath() + "/" + target_so + ".dump";
    var file = new File(save_path, "wb");

    // 3. 分块抗崩溃读取
    var currentPtr = module.base;
    var remaining = module.size;
    var pageSize = 4096;

    console.log("[*] 开始分块提取,遇到坏块自动填0...");
    while (remaining > 0) {
        try {
            var buffer = currentPtr.readByteArray(pageSize);
            file.write(buffer);
        } catch (e) {
            // 遇到 360 挖的坑,填 0 补齐,保持偏移量正确
            file.write(new Uint8Array(pageSize).buffer);
        }
        currentPtr = currentPtr.add(pageSize);
        remaining -= pageSize;
    }
    file.close();
    console.log("[Success] 提取完成!路径: " + save_path);
});

运行脚本后,我通过 Root 权限将这个 13MB 的文件从私有目录 cp 到了 SD 卡,终于把它拿到了电脑上。


0x02 尝试 2:Blutter 与环境的配置#

拿到了 libapp.so.dump,但这只是二进制机器码(Dart AOT Snapshot)。如果不还原符号,这就跟看天书一样。我需要 Blutter 帮我把机器码翻译回 Dart 伪代码。

Windows 环境的 “劝退”#

我首先在 Windows 上尝试运行 Blutter。

  • CMake Error: 找不到编译器。
  • Ninja not found: 找不到构建工具。
  • 最绝望的是 Failed to find all ICU components。Dart VM 的编译依赖 ICU 库,在 Windows 上配置这个简直是噩梦。

决策:不要在 Windows 上浪费生命配置环境。

果断打开了 kali。Linux 下配环境只需要一行命令:

Bash

# 一键安装所有编译依赖
sudo apt update && sudo apt install -y ninja-build build-essential cmake pkg-config libicu-dev git

修复与分析#

因为文件是从内存 Dump 的,ELF 头部是损坏的,直接喂给 Blutter 会报错。

  1. 修复头:使用 SoFixer 工具:SoFixer -s libapp.so.dump -o libapp.so
  2. 准备引擎:从原始 APK 解压出 libflutter.so
  3. 开始分析python3 blutter.py input output

看着 kali 终端里滚动的进度条,我知道稳了。

几分钟后,output/asm 目录下出现了一堆熟悉的文件名:

  • loginPwd.dart
  • encrypt.dart
  • Authentication.dart

image

打开 loginPwd.dart,我不仅看到了逻辑结构,还看到了关键信息:

image


0x03 最终:我没辙了#

准备动态加载 JS 呢进行调试呢。搞来搞去怎么样也不对,登录之后获取 hexdump 获取不到明文的返回,放弃了。
我本身抓包的参数就是明文,一开始的出发点也只是审计代码漏洞,没想到越跑越歪。

image

发现后面有些困难了,中间丢失了一些图和内容。。我也忘记了。

总之坑点在于,看到 Flutter 后,就干脆跑路吧,Flutter 编译后,只能 hook 二进制流 dump 出来。

这玩意可能无解了,这还加固什么劲?


📝 复盘总结#

回看整个过程,如果一开始我就知道它是 Flutter,我完全可以跳过第一章和第二章的几十个小时折腾。

这次逆向最大的教训是:不要在错误的战场浪费时间。

  1. 识别架构:如果 DEX 脱出来是空的,第一时间检查 lib 目录有没有 libflutter.solibcocos.so。如果是 Flutter,直接放弃 Java 层分析。
  2. 环境适配:Android 16 对 Frida 的兼容性很不友好。当标准 API (Module.findBaseAddress) 失效时,要灵活尝试底层 API (Process)。当公共目录写不进去时,要想到利用 App 的私有目录。
  3. 工具链组合:Frida (动态提取) + SoFixer (修复头) + Blutter (还原符号) 。
加载中...
此文章数据所有权由区块链加密技术和智能合约保障仅归创作者所有。