0%
毅种循环

返回

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

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

img 1764210708078 ca6100

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
plain

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

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

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

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

Bash

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

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 文件。

img 1764210708157 6b4a76

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

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

为什么会失败?#

这是 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 卡。

完整代码实现#

执行结果#

运行命令 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

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() {
        // 空的!
    }
}
plain

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

这里的思考#

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

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

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

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

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

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

结果令人绝望:

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

  • cn.com.ljzitc.R$id
  • cn.com.ljzitc.R$layout
  • cn.com.ljzitc.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.ljzitc.../lib/arm64/libapp.so

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

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

那一刻,真相大白:

img 1764210708244 1c4521

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

这是一个 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)#

运行脚本后,我通过 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
plain

修复与分析#

因为文件是从内存 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

img 1764210708300 41498a

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

img 1764210708359 71febc


0x03 最终:我没辙了#

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

img 1764210708445 61df22

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

总之坑点在于,看到 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 (还原符号) 。
记一次失败的 逆向Android 16+数字加固+FlutterAPP踩坑
https://astro-pure.js.org/blog/android-reverse-flutter-hardening
本文作者 r3rk04·謊言無法穿透石灰水泥
发布于 2026年1月25日
版权声明 CC BY-NC-SA 4.0
Comment seems to stuck. Try to refresh?✨