zboralski/unflutter
GitHub: zboralski/unflutter
Flutter/Dart AOT 快照的纯静态分析器,无需 Dart VM 即可从 libapp.so 恢复函数名、类层次、调用图和行为信号。
Stars: 58 | Forks: 8
# unflutter
Flutter/Dart AOT 快照的静态分析器。从 `libapp.so` 中恢复函数名、类层次结构、调用图和行为信号,无需嵌入或执行 Dart VM。
## 为什么不选 Blutter
[Blutter](https://github.com/aspect-sec/blutter) 通过嵌入 Dart VM 本身来解决 Flutter 逆向工程问题。它调用 `Dart_Initialize`,从快照创建一个 isolate 组,并使用内部 VM API 遍历反序列化的堆。快照中的任何 Dart 代码都不会被执行。VM 纯粹用于自省。但这仍然意味着 Blutter 必须为每个目标版本编译一个匹配的 Dart SDK 并链接到 VM 内部。
unflutter 采取了不同的路径。没有 VM。无需编译 SDK。快照是一个具有已知语法的字节流。我们直接解析它。
权衡在于:Blutter 因为通过 VM 自身的代码路径进行反序列化而获得了完美的保真度。unflutter 则获得了可移植性、速度,以及无需构建任何特定版本的组件即可分析来自任何 Dart 版本的快照的能力。代价是必须在我们解析器中显式处理跨 Dart 版本的每个格式更改。没有可依赖的运行时。
## 设计
约束消除。我们将快照视为确定性二进制语法。
```
Omega = all possible interpretations of the byte stream
C = {
ELF invariants,
snapshot magic (0xf5f5dcdc),
version hash (32-byte ASCII),
CID table (class ID -> cluster handler),
cluster grammar (alloc counts, fill encoding),
instruction layout (stubs + code regions)
}
R = Omega reduced by C
```
每个约束都缩小了范围。ELF 验证排除了非 ARM64 的二进制文件。快照魔术字节排除了非 Dart 数据。版本哈希精确选择一个 CID 表和标签编码。集群分配计数固定了对象总数。填充解析恢复该固定总数内的字段值。经过所有约束后幸存下来的就是分析结果。
```
if |R| == 0 → HALT: overconstrained (bug in our model)
if |R| > 1 → HALT: underdetermined (missing constraint)
if |R| == 1 → COMMIT: the answer
```
没有启发式方法。没有运行时回退。没有超出约束的推断。
## 工作原理
### 快照重构
Dart AOT 快照 = 两阶段序列化:**alloc**(分配)然后 **fill**(填充)。
**Alloc** 按 CID 顺序遍历集群。每个集群声明存在多少该类的对象。这会为每个对象分配顺序引用 ID。目前只读取数量,不读取数据。
**Fill** 再次遍历相同的集群。这一次它读取实际的字段值:字符串字节、指向其他对象的引用 ID、整数标量。填充编码因对象类型和 Dart 版本而异。
我们从原始字节重放这两个阶段。alloc 阶段为我们提供对象普查。fill 阶段为我们提供名称、字符串和交叉引用。结合指令表(它将代码对象映射到其机器码偏移量),我们恢复了完整的函数名到地址的映射,这正是 Blutter 通过 VM API 所获得的。
### 代码恢复
isolate 指令映像包含两个区域:
**Stubs**(索引 0 到 `FirstEntryWithCode-1`):放置在用户代码之前的运行时跳板(类型检查处理器、分配 stub、分派辅助程序)。
**Code**(索引 `FirstEntryWithCode` 及以上):用户函数和框架代码。每个 Code 对象通过指令表映射到一个 PC 偏移量。
我们解析这两个区域,生成一个覆盖整个可执行范围的完整函数映射。
### ARM64 反汇编和调用边
每个函数的代码字节都使用 `arm64asm.Decode` 逐指令解码。分支检测处理原始 32 位编码的 B、B.cond、CBZ、CBNZ、TBZ、TBNZ、RET。
**CFG 构建** 遵循 3 阶段算法:
1. 收集块首指令:指令 0、分支目标、终结符之后的指令
2. 排序并划分为基本块
3. 遍历块,根据终结指令计算后继边
**调用边提取** 区分两种类型:
- **BL (直接调用)**:从 imm26 字段解码目标地址,通过符号映射解析为函数名
- **BLR (间接调用)**:通过 `RegTracker`(滑动窗口 W=8)解析目标寄存器来源
寄存器跟踪器追踪 BLR 目标寄存器如何获取其值:
| 来源 | 模式 | 描述 |
|------------|---------|-------------|
| PP (对象池) | `LDR Xt, [X27, #imm]` | X27 是池指针。池索引 = byte_offset / 8 |
| THR (线程) | `LDR Xt, [X26, #imm]` | X26 是线程指针。通过特定版本的偏移映射解析 |
| 窥孔优化 PP | `ADD Xd, X27, #hi; LDR Xt, [Xd, #lo]` | 用于大池索引的双指令 PP |
| 分派表 | `LDR Xn, [X21, Xm, LSL #3]` | X21 是分派表寄存器 |
每个 BLR 都会注释其来源(例如,`PP[42] Widget.build`、`THR.AllocateArray_ep`、`dispatch_table`)。
### 图构建
调用边和 CFG 被转换为 [lattice](https://github.com/zboralski/lattice) 类型,这是一种与 SpiderMonkey-dumper(用于 JS 字节码分析)共享的、与架构无关的图 IR。lattice 库提供 DOT 渲染。
### 反编译 (Ghidra + IDA)
两个反编译器共享一个通用的元数据管道。`flutter-meta` 生成包含函数名、类结构布局、THR 字段、字符串引用和指针大小元数据的 `flutter_meta.json`。每个反编译器的脚本都会使用此文件。
**Ghidra** (`unflutter decompile`) 运行无头管道:
1. 预脚本通过 `SpecExtension` 注册 `__dartcall` 调用约定(将 X15/X26-X28 标记为不受影响,清除临时寄存器)
2. 后脚本应用所有元数据:
- 在所有已知函数地址处进行反汇编
- 创建/重命名函数
- 创建具有正确字段大小(压缩指针为 4 字节,否则为 8 字节)的 Dart 类结构类型
- 为 THR (X26) 访问创建 `DartThread` 结构(200 个字段)
- 应用类型化函数签名(`this` 指针、参数计数、返回类型)
- 为 THR 字段、PP 池引用和字符串字面量设置 EOL 注释
- **寄存器类型重设**:重命名 Dart 特定寄存器的反编译器变量,并将 X26 类型化为 `DartThread*`,从而启用结构字段解析:
| 寄存器 | 变量 | 用途 |
| -------- | ------------------- | ----------------------------------------------- |
| X15 | `SHADOW_SP` | Dart 影子调用栈 |
| X21 | `DT` | 分派表指针 |
| X22 | `DART_NULL` | Dart null 对象 |
| X26 | `THR` (DartThread*) | 线程指针,字段访问解析为名称 |
| X27 | `PP` | 对象池指针 |
| X28 | `HEAP_BASE` | 压缩指针基址 |
| X29 | `FP` | 帧指针 |
| X30 | `LR` | 链接寄存器 |
**IDA** (`unflutter ida`) 通过 idalib 运行 (无头模式):
1. 生成包含所有结构类型的 C 头文件,通过 `idc_parse_types()` 一次性解析
2. 创建具有 Dart 已检查/未检查入口点拆分的函数(在元数据地址处拆分 IDA 合并的函数)
3. 通过 `apply_type()` 应用函数签名(IL2CppDumper 模式)
4. 设置重复注释(在 Hex-Rays 反编译器中可见)
5. Hex-Rays 寄存器类型重设(与 Ghidra 寄存器表相同)
**Ghidra 与 IDA 输出质量对比:**
Ghidra 在可读性方面胜出:结构字段解析(`THR->stack_limit` 对比 `THR + 72`),索引访问(`SHADOW_SP[-2]` 对比 `*(_QWORD*)(SHADOW_SP - 16)`),并且没有 `_QWORD`/`_DWORD` 转换。
IDA 在类型整洁性方面胜出:零 `undefined` 类型,零 `unaff_` 寄存器名,零警告。IDA 使用 `__int64` 和 `_QWORD` 转换,虽然冗长但类型正确。
THR 结构字段解析差距是 Hex-Rays 微码的一个限制。`set_lvar_type()` 不会重构反编译器的 AST 以使用结构成员语法。
### 版本处理
| Dart | 标签样式 | 指针 | 关键变化 |
|------|-----------|----------|------------|
| 2.10.0 | CID-Int32 | 未压缩 | 4 个头字段,规范拆分前 |
| 2.13.0 | CID-Int32 | 未压缩 | 5 个头字段,规范拆分 |
| 2.14.0 | CID-Shift1 | 未压缩 | CID 移入 uint64 标签 |
| 2.15.0 | CID-Shift1 | 未压缩 | 插入 NativePointer CID |
| 2.16.0 | CID-Shift1 | 未压缩 | 添加 ConstMap/ConstSet |
| 2.17.6 | CID-Shift1 | 未压缩 | 最后一个无符号引用版本 |
| 2.18.0 | CID-Shift1 | 压缩 | 有符号引用,压缩指针 |
| 2.19.0 | CID-Shift1 | 压缩 | 64 字节对齐 |
| 3.0.5-3.3.0 | CID-Shift1 | 压缩 | 渐进式 CID 表更改 |
| 3.4.3-3.10.7 | ObjectHeader | 压缩 | 新标签编码,记录类型 |
没有版本条件化的架构。版本哈希选择一个约束集。运行相同的管道。
## 构建和安装
需要 Go 1.24+。一个外部依赖:`golang.org/x/arch`(ARM64 指令解码)。
```
make build # build ./unflutter binary
make install # install binary to /usr/local/bin, scripts to ~/.unflutter/
make test # run tests
```
Ghidra 集成需要带有 Jython 支持的 Ghidra 11.x。从 `GHIDRA_HOME`、`PATH` 或通用 brew 位置自动检测。
## 用法
### 完整管道 (默认)
```
unflutter libapp.so
```
一次性运行 ELF 解析、反汇编、信号分析和元数据生成:
```
elf Dart SDK 3.10.7
code 284352 bytes at VA 0x569a8
instructions: 1465 entries (0 stubs + 1465 code)
ranges: 1465 (0 stubs + 1465 code)
classes: 402 layouts
disasm 1465 functions, pool 1511 entries (1318 resolved)
functions: 1465 -> samples/evil-patched.unflutter/asm
call edges: 5937 (822 BLR: 757 annotated, 65 unannotated)
string refs: 620
BLR annotation: 92.1%
signal 71 signal + 1076 context, 4178 edges
net: 40
url: 4
base64: 1
cloaking: 1
asm snippets: 1142
-> signal_graph.json (900218 bytes)
-> signal.html (456296 bytes)
-> signal.dot (5809 bytes)
-> signal_cfg.dot (51 functions, 50855 bytes)
-> signal.svg (18136 bytes)
-> signal_cfg.svg (145979 bytes)
meta 1465 functions
focus: 71 signal functions (use --all for everything)
dart: 3.10.7 ptr_size: 4 thr_fields: 272
classes: 402 layouts
comments: 1363 from asm files
string refs: +461 comments
-> flutter_meta.json (577230 bytes)
summary
output: samples/evil-patched.unflutter
dart: 3.10.7
functions: 1465
classes: 402
signal: 71
next
open samples/evil-patched.unflutter/signal.html
unflutter ghidra libapp.so --from samples/evil-patched.unflutter
unflutter ida libapp.so --from samples/evil-patched.unflutter
```
使用 `--quiet` / `-q` 抑制详细输出。使用 `--out` 设置输出目录 (默认:`.unflutter/`)。
### 快速扫描
```
unflutter scan libapp.so # print snapshot info
```
### 仅信号 (跳过元数据)
默认管道已包含信号分析。使用 `unflutter signal` 运行相同的管道,但跳过元数据生成阶段:
```
unflutter signal libapp.so # default pipeline without meta
unflutter signal libapp.so -k 3 # custom context depth (default: 2)
unflutter signal libapp.so --from out/target # rerun signal from existing disasm
```
### Ghidra 反编译
```
unflutter ghidra libapp.so # full pipeline + Ghidra headless
unflutter ghidra libapp.so --from out/target # reuse existing disasm output
unflutter ghidra libapp.so --all # decompile ALL functions
```
### IDA 反编译
```
unflutter ida libapp.so # full pipeline + IDA idalib
unflutter ida libapp.so --from out/target # reuse existing disasm output
unflutter ida libapp.so --all # decompile ALL functions
```
### 仅元数据
```
unflutter meta libapp.so # full pipeline, produce flutter_meta.json
unflutter meta --from out/target # regenerate from existing disasm
```
### 输出产物
| 文件 | 描述 |
|------|-------------|
| `functions.jsonl` | 函数记录:名称、地址、大小、所有者、参数数量 |
| `call_edges.jsonl` | 调用边:带有已解析目标和来源的 BL/BLR |
| `classes.jsonl` | 类布局:字段、偏移量、实例大小 |
| `string_refs.jsonl` | 来自 PP 加载的字符串引用 |
| `dart_meta.json` | 快照元数据:Dart 版本、指针大小、THR 字段 |
| `flutter_meta.json` | Ghidra/IDA 统一元数据:函数、类、THR 字段、注释 |
| `asm/*.txt` | 每个函数带注释的 ARM64 反汇编 |
| `cfg/*.dot` | 每个函数的控制流图 (使用 `--graph`) |
| `callgraph.dot` | 完整调用图 (使用 `--graph`) |
| `signal.html` | 行为信号报告 |
| `decompiled/*.c` | Ghidra 反编译的 C 输出 |
## 架构
```
internal/
elfx/ ELF validation, ARM64 symbol extraction
snapshot/ Region extraction, header parsing, version profiles
dartfmt/ Dart VM stream encoding (variable-length integers)
cluster/ Two-phase snapshot deserialization (alloc + fill)
disasm/ ARM64 decode, CFG, call edge provenance, register tracking
callgraph/ Lattice graph builders (call graph + CFG)
signal/ Behavioral string classification
render/ HTML/DOT visualization
output/ JSONL serialization
```
### 管道
```
libapp.so
→ ELF parse (elfx)
→ snapshot region extraction (snapshot)
→ header + version detection (snapshot)
→ cluster alloc scan (cluster)
→ cluster fill parse (cluster)
→ instructions table: stubs + code (cluster)
→ ARM64 disassembly + CFG (disasm)
→ call edge extraction with register tracking (disasm)
→ lattice graph construction (callgraph)
→ signal classification (signal)
→ Ghidra metadata + decompilation (ghidra-meta / decompile)
→ JSON / DOT / HTML artifacts
```
每个阶段都是一个从字节到结构化数据的纯函数。没有可变的全局状态。没有 VM 运行时。相同的输入,相同的输出。
## 已知限制
- **仅支持 AOT。** 不支持 JIT 模式。
- **仅支持 ARM64。** 不支持 x86 或 RISC-V。
- **无源码重构。** 输出是函数名、调用边、结构体、字符串,而不是 Dart 源码。
- **BLR 跟踪窗口。** 寄存器来源使用滑动窗口 (W=8)。超出窗口的复杂寄存器链未被解析。
- **Dart 2.12.x 未验证。** 无可用样本。
- **大型信号图。** 超过 1 MB 的 DOT 文件将跳过 SVG 渲染。Graphviz `dot` 使用 O(n²) 层次布局,在包含数千个节点的图上会挂起。对大型图请使用 `sfdp -Tsvg`。
- **必须为每个格式更改建模。** 没有运行时可以自动处理它。
标签:Android安全, AOT快照, ARM64, Call Graph, Dart, DAST, ELF解析, EVTX分析, Flutter, iOS安全, libapp.so, URL提取, 二进制分析, 云安全监控, 云安全运维, 云资产清单, 反编译, 可配置连接, 快照恢复, 恶意软件分析, 文件解析, 无VM分析, 日志审计, 目录枚举, 移动安全, 程序分析, 类层次结构, 跨版本分析, 逆向工程, 静态分析, 静态逆向