这篇文章是我对近期分析 “某金融” 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 文件。

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
- 安装 Frida 工具包:
- 手机 / 模拟器端:
- 手机需要 Root。
- 手机里必须运行
frida-server,且版本最好与 PC 端的 Frida 版本一致。 - 确保 ADB 连接正常 (
adb devices能看到设备)。
2. 基本使用模式#
frida-dexdump 支持两种主要模式:Spawn(启动模式) 和 Attach(附加模式) 。
模式 A:附加模式 (推荐)#
适用于应用已经运行,或者你需要手动绕过一些启动时的检测后再进行 Dump。
- 在手机上手动打开目标 App,让它停留在主界面(此时壳代码通常已经运行完毕,DEX 已解密加载到内存)。
- 在电脑终端运行命令:
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=... 的日志。
- 打开生成的文件夹。
- 你会看到多个
.dex文件(如classes.dex,classes2.dex...)。 - 如何辨别哪个是原本的 DEX?
- 看大小:通常最大的那个,或者几 MB 大小的,是业务逻辑所在的 DEX。
- 使用工具查看:使用
Jadx打开这些 DEX 文件。- 如果看到包名是
com.qihoo.util或类似的,那是壳的代码。 - 如果看到了目标 App 的真实包名和业务代码,那就是脱壳成功了。
- 如果看到包名是
5. frida-dexdump 注意事项#
使用 frida-dexdump 可能会遇到以下情况:
- 能 Dump 出结构,但方法是空的 (nop) : 这是因为 有些加密壳使用了函数抽取技术。DEX 文件结构在内存里是完整的,但是具体的方法指令(Code Item)在执行前是空的,只有执行该方法时才临时解密填入,执行完又抹掉。
- 表现:用 Jadx 打开 Dump 出来的 DEX,能看到类名和方法名,但点进方法看代码时,全是空的或者只有
return。 - 对策:这时候
frida-dexdump就不够用了,通常需要使用更高级的基于 ART 运行时的主动调用工具(如FART、Youpk等)来通过遍历调用所有函数强行触发解密。
- 表现:用 Jadx 打开 Dump 出来的 DEX,能看到类名和方法名,但点进方法看代码时,全是空的或者只有
- 反调试导致闪退: 如果运行
frida-dexdump时 App 闪退,说明被检测到了。- 对策:你需要先用 Frida 脚本(如
frida-il2cpp-bridge带的 bypass 或者是专门的 Anti-Anti-Frida 脚本)去过掉反调试,或者使用魔改版的 frida-server (hluda)。
- 对策:你需要先用 Frida 脚本(如
总结#
frida-dexdump 是脱壳的第一板斧。不管什么壳,先用它跑一遍(建议加 -d 参数)。运气好能直接拿代码;运气不好(遇到抽取壳),也能拿到完整的类结构,为后续分析打下基础。
环境准备:
- PC 端:
- 手机端:
- 手机必须 Root。
- 手机里运行
frida-server(版本必须和电脑端的frida-tools一致)。 - 确保 ADB 连接正常 (
adb devices能看到设备)。
操作步骤:
- 在手机上点击打开目标 APP,让它停留在主界面。
- 在电脑终端输入命令(推荐使用附加模式,比较稳):
- 如果成功,工具会在当前目录下生成一个以包名命名的文件夹,里面就是脱下来的
.dex文件。

预期结果:在文件夹中生成一堆 .dex 文件。实际结果:终端直接报红,进程崩溃。
ERROR:frida-dexdump:[-] Error: access violation accessing 0x70316bc000
为什么会失败?#
这是 Android 16 新特性与 360 加固对抗的产物。
- 内存权限收紧:Android 16 对内存页的权限管理极其严格。
- 加固对抗:360 加固在解密 DEX 后,故意将这段内存页的权限设置为 “仅执行” (x) 或 “不可读” 。
- 冲突:当
frida-dexdump试图通过readByteArray去读取这段内存时,因为没有r(Read) 权限,触发了系统的Access Violation(访问违规),导致脚本或应用崩溃。
0x02 破局:手写脚本 “暴力” 提权#
既然标准工具因为 “权限不足” 读不到,接下来思路很明确:我是 Root 用户,我可以在读取之前,强行修改内存权限。
我编写了一个自定义的 Frida 脚本 dump_force.js。
核心逻辑#
- 利用
Process.enumerateRanges遍历所有内存段。 - 利用
Memory.scan暴力搜索 DEX 文件头魔法数64 65 78 0a(dex\n)。 - 关键步骤:在读取之前,使用
Memory.protect将该内存段强制改为rwx(可读可写可执行)。 - 读取数据并保存到 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$idcn.com.lj.R$layoutcn.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/ 目录。
那一刻,真相大白:

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 会报错。
- 修复头:使用
SoFixer工具:SoFixer -s libapp.so.dump -o libapp.so。 - 准备引擎:从原始 APK 解压出
libflutter.so。 - 开始分析:
python3 blutter.py input output。
看着 kali 终端里滚动的进度条,我知道稳了。
几分钟后,output/asm 目录下出现了一堆熟悉的文件名:
loginPwd.dartencrypt.dartAuthentication.dart

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

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

发现后面有些困难了,中间丢失了一些图和内容。。我也忘记了。
总之坑点在于,看到 Flutter 后,就干脆跑路吧,Flutter 编译后,只能 hook 二进制流 dump 出来。
这玩意可能无解了,这还加固什么劲?
📝 复盘总结#
回看整个过程,如果一开始我就知道它是 Flutter,我完全可以跳过第一章和第二章的几十个小时折腾。
这次逆向最大的教训是:不要在错误的战场浪费时间。
- 识别架构:如果 DEX 脱出来是空的,第一时间检查
lib目录有没有libflutter.so或libcocos.so。如果是 Flutter,直接放弃 Java 层分析。 - 环境适配:Android 16 对 Frida 的兼容性很不友好。当标准 API (
Module.findBaseAddress) 失效时,要灵活尝试底层 API (Process)。当公共目录写不进去时,要想到利用 App 的私有目录。 - 工具链组合:Frida (动态提取) + SoFixer (修复头) + Blutter (还原符号) 。