应用安全 --- 反向工程 之 Flutter exe app.so
Dart 代码的本质就是html+js+css为一种代码风格并编译到apk和exe
当 Flutter 应用以 Release 模式 编译后,Dart 源码被 AOT(Ahead-Of-Time)编译成原生机器码:
安装包/
├── data/
│ └── app.so ← 所有 Dart 业务代码(AOT 产物)
⚠️ 注意:虽然 Windows 上叫
.so,但它实际上是 ELF 格式(Linux shared object),只是 Flutter 团队没改扩展名。
用 aotopsy doctor 检测
aotopsy.exe doctor app.so
输出:
ELF: OK (13697968 bytes)
Snapshot: OK
Dart: 3.10.7 ← Dart SDK 版本
Pointers: uncompressed (8 bytes)
Support: OK ← 工具支持该版本
Hash: 1ce86630892e2dca9a8543fdb8ed8e22 ← Snapshot 版本哈希
Features: product no-code_comments dwarf_stack_traces_mode
dedup_instructions no-tsan no-msan no-shared_data
x64 windows no-compressed-pointers
关键信息解读:
- Dart 3.10.7 — 2024 年的稳定版
product— Release 模式编译no-code_comments— 注释被移除dedup_instructions— 指令去重x64 windows— Windows x64 平台
2.3 ELF Section Headers
两个段加起来 13.06 MB —— 这就是全部的 Dart 业务代码。
2.4 Dart Snapshot 统计
code: 8,840,856 bytes at VA 0x49EBE8
instructions: 39,594 entries (32,578 stubs + 7,016 actual code)
classes: 5,739 layouts ← 类定义
scripts: 1,984 entries ← Dart 源文件(含原始路径)
instances: 4,345 entries ← 常量实例
type_arguments: 2,574 entries ← 泛型参数
closure_data: 5,273 entries ← 闭包
library_functions: 1,654 entries ← 库函数
ffi_bridges: 1 entry ← FFI 桥接
三、反编译工具选择
3.1 主流工具对比
3.2 最终选择:aotopsy v1.4.0
选择理由:
- 原生支持 Dart 3.10.7(正好匹配目标)
- 支持 x86_64(Windows 平台)
export-dart --max 0可以导出完整 Dart 源码-app-only过滤掉 Flutter/Dart 框架代码- 纯 Go 实现,无依赖,单文件 7 MB
- 准确率宣称 89.8% 函数名恢复 + 100% 有效 Dart 语法
3.3 下载地址
https://github.com/BroNils/aotopsy/releases
→ aotopsy_v1.4.0_windows_amd64.zip
四、完整反编译步骤
Step 1:下载并解压工具
# 下载
Invoke-WebRequest -Uri "https://github.com/BroNils/aotopsy/releases/download/v1.4.0/aotopsy_v1.4.0_windows_amd64.zip" `
-OutFile "aotopsy.zip"
# 解压
Expand-Archive -Path "aotopsy.zip" -DestinationPath "aotopsy"
Step 2:诊断扫描
aotopsy.exe doctor nthucc\data\app.so
必须成功才能继续。如果 Support 不是 OK,说明 Dart 版本太新/太旧,需要换工具或等 aotopsy 更新。
Step 3:导出完整 Dart 源码(核心步骤)
aotopsy.exe export-dart `
-lib "c:\path\to\nthucc\data\app.so" ` # 输入文件
-out "decompile_output\lib_full" ` # 输出目录
-max 0 ` # 0 = 不限制数量(默认 500)
-app-only # 只导出业务代码(跳过 dart:* 和 package:flutter*)
参数详解:
输出结果:
[export-dart] Loaded app.so (Dart 3.10.7, x86_64)
[export-dart] Total code entries: 39594
[export-dart] Successfully exported 11621 methods across 1478 classes into 202 .dart files
Step 4:(可选)完整信号分析
如果磁盘空间足够(> 500 MB),可以跑完整分析:
aotopsy.exe app.so # 全管线分析
这会生成:
signal.html— 交互式信号分析页面signal_graph.json— 调用图aotopsy.sarif— 静态分析结果functions.jsonl— 所有函数列表classes.jsonl— 所有类列表call_edges.jsonl— 调用边string_refs.jsonl— 字符串引用disasm/— 反汇编文件- 等等...
Step 5:(可选)直接分析 JSONL 数据
如果导出的 Dart 源码混淆严重,可以直接分析原始 JSONL:
# 找所有业务类
Select-String -Path "classes.jsonl" -Pattern "atlas|service|proxy" -CaseSensitive:$false
# 找所有 API 字符串
Select-String -Path "string_refs.jsonl" -Pattern "https?://"
# 找方法调用关系
Get-Content "call_edges.jsonl" | ConvertFrom-Json | Where-Object { $_.caller -match "MihomeCore" }
五、反编译结果解读
5.1 混淆现象
反编译后的类名变成了 3 字母混淆名:
恢复策略:搜索 [ServiceName] 格式的日志字符串 —— 这是 Dart 开发者常写的 print("[ServiceName] xxx") 调试输出,而 aotopsy 会完整保留字符串常量。
5.2 反编译出的 Dart 代码结构
// 原始 Dart 代码(猜测)
class MihomeCoreService {
Future<void> start() async {
print("[MihomeCoreService] 启动核心...");
final exitCode = await process.exitCode;
print("[MihomeCoreService] 进程退出,退出码: $exitCode");
if (consecutiveCrashes >= 5) {
print("[MihomeCoreService] ⛔ 核心已连续崩溃 $consecutiveCrashes 次");
}
}
}
// aotopsy 反编译出的代码
class WB { // ← 混淆后的类名
dynamic _anonymous_closure__750708() {
push(framePointer);
while (true) {
block_1:;
final t1 = sub_71eb1c(arg0, arg1, local_16, local_24.f39);
// ... SSA 寄存器分配 ...
t5.f23 = "[MihomeCoreService] 进程退出,退出码: ";
// ... 控制流恢复 ...
if (unresolved_cond) {
t10.f23 = "[MihomeCoreService] ⚠️ 核心崩溃 (连续第 ";
}
}
}
}
差异说明:
5.3 能恢复的完整程度
✅ 完整恢复:
- 所有函数体的控制流(if/else/for/while/try/catch)
- 所有字符串常量
- 所有类名、方法名(但被混淆)
- 所有字段访问偏移
- 异常处理逻辑
- 闭包结构
⚠️ 部分恢复:
- 变量类型(需要从类型推断恢复)
- 泛型参数(会变成
<dynamic>) - async/await 结构(编译成状态机,但控制流完整)
❌ 无法恢复:
- 原始变量名(编译器会做寄存器分配)
- 注释
- 完整 Widget 树(需要 Flutter Inspector 配合)
5.4 项目原始路径
从 dart_plugin_registrant.dart 中恢复出开发者本地路径:
// Library: file:///D:/a/atlas-app/atlas-app/.dart_tool/flutter_build/dart_plugin_registrant.dart
→ 项目名叫 atlas-app,在 D:\a\atlas-app\atlas-app\ 下开发
六、aotopsy 工作原理(深入)
6.1 处理管线
app.so (ELF)
│
▼
┌─────────────────────────────────────┐
│ 1. ELF Parser │
│ 解析 .rodata / .text section │
└──────────────┬──────────────────────┘
▼
┌─────────────────────────────────────┐
│ 2. Snapshot Extractor │
│ 找到 _kDartIsolateSnapshotData │
│ 和 _kDartIsolateSnapshotInstr │
└──────────────┬──────────────────────┘
▼
┌─────────────────────────────────────┐
│ 3. Cluster Allocator │
│ 解析对象池(字符串、类、常量) │
│ 重建 CID → 对象映射表 │
└──────────────┬──────────────────────┘
▼
┌─────────────────────────────────────┐
│ 4. Instructions Table │
│ 解析代码段,提取所有函数范围 │
│ 恢复 method name / offset 映射 │
└──────────────┬──────────────────────┘
▼
┌─────────────────────────────────────┐
│ 5. x86_64 Disassembler │
│ 反汇编每个函数的机器码 │
└──────────────┬──────────────────────┘
▼
┌─────────────────────────────────────┐
│ 6. Control Flow Graph (CFG) │
│ 恢复分支/跳转/异常处理结构 │
└──────────────┬──────────────────────┘
▼
┌─────────────────────────────────────┐
│ 7. SSA Register Value Graph │
│ 寄存器分配恢复为 SSA 形式 │
│ 每个物理寄存器 → 唯一 SSA 值槽 │
└──────────────┬──────────────────────┘
▼
┌─────────────────────────────────────┐
│ 8. Type Inference │
│ 从常量实例推断字段类型 │
│ 解析间接调用目标 │
└──────────────┬──────────────────────┘
▼
┌─────────────────────────────────────┐
│ 9. Dart Synthesizer │
│ SSA → Dart 伪代码 │
│ 恢复 async/await / closure 结构 │
│ 尝试恢复惯用模式 │
└──────────────┬──────────────────────┘
▼
*.dart 文件
6.2 Snapshot 版本哈希的作用
每个 Dart SDK 版本的 Snapshot 格式略有不同,版本哈希用于识别格式:
Dart 3.10.7 → hash 1ce86630892e2dca9a8543fdb8ed8e22
aotopsy 对照这个哈希选择正确的解析器版本
6.3 为什么 x86_64 反汇编效果好于 ARM64
Dart AOT 在 ARM64 上使用自定义寄存器约定:
- R15 = Dart VM 栈指针
- R27 = 对象池指针
- R21 = 分发表寄存器
而 x86_64 上 Dart AOT 的寄存器使用更接近标准调用约定,反汇编分析器更容易理解。
七、完整命令速查表
# === 诊断 ===
aotopsy.exe doctor app.so
# === 完整分析(生成 HTML/JSONL/反汇编) ===
aotopsy.exe app.so --quiet
# === 导出 Dart 源码 ===
aotopsy.exe export-dart -lib app.so -out ./lib_full -max 0 -app-only
# === 导出所有(含 Flutter 框架) ===
aotopsy.exe export-dart -lib app.so -out ./lib_all -max 0
# === 按关键字过滤 ===
aotopsy.exe export-dart -lib app.so -out ./lib_filtered -filter "Mihome" -max 0
# === Ghidra/IDA 集成 ===
aotopsy.exe ghidra app.so
aotopsy.exe ida app.so
# === 生成 Flutter meta ===
aotopsy.exe meta app.so
八、常见问题
九、输出文件清单
完整反编译结果在 decompile_output/:
decompile_output/
├── lib_full/ ← ✅ 反编译出的 Dart 源码(完整)
│ ├── app/app.dart ← 核心业务(MihomeCoreService, UserProvider 等)
│ ├── D/a/atlas-app/atlas-app/... ← 插件注册(含项目路径)
│ ├── idm.dart ← MihomeCoreService
│ ├── jcm.dart ← AppConfigService + SSPanelConfig
│ ├── tam.dart ← UserProvider
│ ├── Xcm.dart ← *****ConfigService
│ ├── LZl.dart ← Main 主入口
│ ├── Rem.dart ← WindowsTitleBar
│ ├── mam.dart ← NodeSelection
│ ├── Ocm.dart ← PpanelSubscriptionState
│ ├── Rcm.dart ← PreProxyService
│ ├── Uam.dart ← IPv6
│ └── ~195 个其他 .dart 文件 ← 页面/Widget/模型
│
├── app.aotopsy/ ← 完整分析输出(因磁盘空间只生成了部分)
│ ├── signal.html ← 交互式信号分析
│ ├── signal_graph.json ← 调用图
│ ├── functions.jsonl ← 39,594 个函数列表
│ ├── classes.jsonl ← 5,739 个类定义
│ ├── call_edges.jsonl ← 调用边
│ ├── string_refs.jsonl ← 字符串引用
│ ├── network_endpoints.jsonl ← 网络端点
│ ├── plugins.jsonl ← Flutter 插件
│ ├── deobfuscation.jsonl ← 混淆映射
│ ├── dispatch_table.jsonl ← 分发表
│ └── asm/ ← 反汇编(每个函数一个文件)
│
└── 早期提取的辅助文件
├── all_strings.txt ← 34,773 个可读字符串
├── all_urls.txt ← URL/API 端点
├── business_classes.txt ← 1,079 个业务类名
└── packages.txt ← Dart package 引用
为什么其他反编译工具需要 Dart VM
这是一个很好的架构问题。答案分两层:格式问题和工程选择。
1. Dart AOT 快照格式没有公开规范
Dart 的 AOT 快照(libapp.so 里的 snapshot region)不是一个文档化的二进制格式。它本质上是 Dart VM 堆的序列化内存镜像:
snapshot_region
├── cluster alloc phase ← 每个 Cluster 对应一种 VM 内部 CID(Class ID)
│ └── 每个 CID 的对象数量、字段布局...
└── cluster fill phase ← 字段值、字符串、引用关系...
这个格式:
- 随每个 Dart 版本变化(有时每个小版本都变)
- 没有版本号字段,只有一个 git-hash 派生的 magic,需要逆向对应
- 字段布局依赖 VM 内部
RawObject结构体,这些结构体在 C++ 头文件里,不是公开 API
Blutter 的思路是:既然格式由 VM 定义,就直接用 VM 来解析。编译匹配版本的 Dart SDK,把 VM 嵌进去,让 VM 自己反序列化快照。这样格式变化自动跟进,不需要手动建模。
Blutter 的路径:
libapp.so → 找到 Dart 版本 → 编译对应 SDK → 嵌入 VM → VM.ReadSnapshot() → 完美还原
代价很明显:
- 每个目标版本都要编译一次 Dart SDK(几十分钟)
- 只支持 ARM64(VM 只在 ARM64 模式下能跑对应快照)
- 需要本地有 Dart SDK 工具链
2. AOTopsy 的选择:直接建模格式
AOTopsy 选择不依赖 VM,而是把格式作为"确定性二进制语法"来逆向建模:
// 例:cluster alloc 阶段,按 CID 分派到对应的结构体
func (r *Reader) allocCluster(cid ClassID) (Cluster, error) {
switch cid {
case CIDFunction:
return r.allocFunctionCluster()
case CIDClass:
return r.allocClassCluster()
// ... 每个 CID 单独建模
}
}
每个 Dart 版本需要维护:
- CID 表(哪个数字对应哪个 VM 内部类)
- THR 字段偏移(
internal/vmtables/) - 字段布局变更(
internal/snapshot/版本 profile)
这样的好处:
- 纯静态,零依赖,不需要编译任何 SDK
- 支持 x86_64(VM 路径做不到)
- 速度快,整个分析是一次 pass
代价就是 README 里诚实说明的:每次 Dart 格式变化都要手动建模,没有 VM 兜底。
3. 为什么 VM 路径是"正确但昂贵"的
| Blutter(VM 路径) | AOTopsy(直接解析) | |
|---|---|---|
| 格式保真度 | 100%(VM 自己解析) | ~90%(建模覆盖范围内) |
| 新版本适配 | 编译新 SDK 即可 | 需要手动更新模型 |
| 架构支持 | ARM64 only | ARM64 + x86_64 |
| 运行依赖 | Dart SDK 工具链 | 无 |
| 分析速度 | 慢(SDK 编译) | 快 |
| 反编译输出 | 无(依赖 Ghidra) | 原生 Dart 伪代码 |
一句话总结
其他工具用 VM,是因为 Dart AOT 快照格式本质上就是 VM 的内部序列化格式,没有独立规范。用 VM 解析是最省力的正确做法。AOTopsy 选择直接建模这个格式,换取零依赖和跨架构能力,代价是必须跟踪每个版本的格式变化。
浙公网安备 33010602011771号