Flutter APK 逆向分析:用 REA 读 libapp.so 的 Dart 快照哈希与依赖
本页目录
先说结论: Flutter 发布版 APK 里,业务逻辑基本不在 classes.dex,而是被 AOT 编译进了 lib/<abi>/libapp.so,旁边的 libflutter.so 是引擎。morluto 的开源项目 REA 在 6.7.0 里提供了两条命令:rea identify-flutter-build 按 ABI 读出 Dart 快照哈希和引擎的 GNU Build ID;rea inspect-dart-aot 列出四个快照段,并从快照数据里抽出 package:、dart: URI。两条命令都只解析文件,不运行 App,不启动引擎,也不联网。
我们拿三个目标做了实测:自己编译的小 App、同一个 App 的混淆版,以及开源的 Flutter Gallery 2.9.2 发布包。每个数字都用 unzip、llvm-readelf、strings 对过。快照哈希 REA 每次都读对了,但也有几处它漏掉或报错的地方,下文逐一写出来。
只分析自己构建的 App、开源发布包,或已获授权评估的应用。分析来路不明的文件前,先读一下 REA 的 DISCLAIMER.md。
实验环境
| 组件 | 版本 |
|---|---|
| 主机 | macOS 27.0.1,Apple silicon(arm64) |
| Node.js | 22.22.3(REA 的 engines 要求 ^22.19.0 || ^24.11.0 || >=26.0.0) |
| REA | npm 上的 [email protected] |
| Flutter / Dart | Flutter 3.41.4 stable,引擎 revision e4b8dca3f1,Dart 3.11.1 |
| 对照工具 | Android NDK 29.0.14206865 自带的 llvm-readelf(LLVM 21.0.0)、macOS strings |
REA 装在一个空目录里,不碰全局环境:
mkdir rea-sandbox && cd rea-sandbox
npm init -y
npm install [email protected]
npx rea --version
6.7.0
安装大约 9 秒。动手前我们先 npm pack [email protected] 解开发布包,在 dist/cliCommandNames.js 里确认 identify-flutter-build 和 inspect-dart-aot 确实已经注册。顺带发现一件事:README 链接的 docs/flutter-analysis.md 并不在 npm 包里,只在 GitHub 上。所以那篇文档对应的是 main 分支,和 6.7.0 不一定完全一致。
先编一个自己能拆的样本
想验证工具,最好用一个事先知道答案的目标,所以我们自己编了一个。在默认的计数器模板上加两个 pub.dev 依赖,再放一个后面方便搜索的 URL 常量:
flutter create --org dev.example --project-name hello_rea --platforms android hello_rea
cd hello_rea
flutter pub add http intl
import 'package:http/http.dart' as http;
import 'package:intl/intl.dart';
const kStatusUrl = 'https://example.com/hello-rea/status';
Future<String> fetchStatus() async {
final r = await http.get(Uri.parse(kStatusUrl));
return '${r.statusCode} at ${DateFormat.yMMMd().format(DateTime.now())}';
}
计数按钮里调用了 fetchStatus(),这样编译器不会把这两个包整个摇树摇掉。然后构建:
flutter build apk --release
第一次构建直接失败,报错信息也没给多少线索:
FAILURE: Build failed with an exception.
* What went wrong:
25.0.2
这里的 “25.0.2” 是 Java 版本号。Flutter 默认用了 Android Studio 自带的 JDK,这台机器上是 OpenJDK 25.0.2,而模板里的 Gradle 8.14 在它上面跑不起来。系统里另有一个 Temurin 17。我们不想改 Flutter 的全局配置,就只给这个工程指定了 JDK:
echo "org.gradle.java.home=/Library/Java/JavaVirtualMachines/temurin-17.jdk/Contents/Home" \
>> android/gradle.properties
flutter build apk --release
✓ Built build/app/outputs/flutter-apk/app-release.apk (43.7MB)
如果想让所有工程都用这个 JDK,用 flutter config --jdk-dir <path>。首次冷构建 68 秒,之后再构建只要 10 到 15 秒。
第一眼:代码去哪了
先别急着上逆向工具,列一下压缩包里的内容:
unzip -l app-release.apk | grep -E '\.so$|classes\.dex'
526008 classes.dex
3474352 lib/arm64-v8a/libapp.so
11317584 lib/arm64-v8a/libflutter.so
3867228 lib/armeabi-v7a/libapp.so
8276340 lib/armeabi-v7a/libflutter.so
3605424 lib/x86_64/libapp.so
12564400 lib/x86_64/libflutter.so
DEX 只有 514 KiB,装的是 Android 嵌入层和插件的胶水代码。我们写的 Dart 代码、http、intl 和整个 Flutter 框架都在 3.3 MiB 的 libapp.so 里。所以用 JADX 打开 Flutter APK,基本只能看到一个 MainActivity。
本站的 APK 分析工具也能得出同样的结论。它的检测规则是:出现 libflutter.so 或 assets/flutter_assets/ 就判定为 Flutter。我们把 src/tools/apk/detect.ts 里的 detectFrameworks() 直接跑在这个 APK 上,结果判定为 Flutter,证据是三个 libflutter.so 路径。从 2026-10-20 起,它还会显示“Flutter 构建信息”:从每个 libapp.so 开头读出快照哈希和 VM 特性,从 libflutter.so 读出 Dart 版本字符串,并区分 release、profile 和 debug 构建。在这个 APK 上,它显示 release、3.11.1 (stable),三个 ABI 的哈希都是 78da37fed6bf1489361a312568249f3f,和下文 REA、strings 的结果一致。它不解析快照本身,所以段偏移、Build ID 和依赖包 URI 还是要靠 REA。
第一步:识别构建信息
npx rea identify-flutter-build /abs/path/app-release.apk --format json
43.7 MB 的 APK 跑了 0.94 秒。下面是 arm64 那一项,做了裁剪。另外两个 ABI 结构相同,和外层的 evidence 信封一起删掉了:
{
"flutter_detected": true,
"abis": [
{
"abi": "arm64-v8a",
"libapp": {
"bytes": 3474352,
"elf_header_valid": true,
"snapshot_hash_sources": 2,
"snapshot_hash_candidates": ["78da37fed6bf1489361a312568249f3f"],
"snapshot_hash": "78da37fed6bf1489361a312568249f3f"
},
"libflutter": {
"bytes": 11317584,
"build_id": "aedb3fce43ebcec6141e4b41866bde6983b41b8a",
"dart_version": null,
"toolchain_lines": ["Android (13624864, +pgo, +bolt, +lto, +mlgo, based on r530567e) clang version 19.0.1 (...)"]
}
}
// ... armeabi-v7a and x86_64 omitted, same snapshot hash ...
],
"coverage": "complete"
}
snapshot_hash_sources: 2 表示哈希是分别从 VM 快照头和 isolate 快照头读出来的,两处一致。如果两处不一致,REA 会把两个候选都保留下来,不替你挑。三个 ABI 的哈希都是 78da37fed6bf1489361a312568249f3f。引擎 Build ID 每个 ABI 各不相同,这是正常的,因为每个 libflutter.so 是单独编译出来的二进制。
不靠 REA 再核一遍
Build ID 是标准的 ELF note,llvm-readelf 就能读:
llvm-readelf -n lib/arm64-v8a/libflutter.so | grep 'Build ID'
llvm-readelf --dyn-syms lib/arm64-v8a/libapp.so | grep Snapshot
strings -a lib/arm64-v8a/libapp.so | grep -m1 78da37
Build ID: aedb3fce43ebcec6141e4b41866bde6983b41b8a
1: 0000000000140000 92704 OBJECT GLOBAL DEFAULT 7 _kDartVmSnapshotInstructions
2: 0000000000156a40 2042112 OBJECT GLOBAL DEFAULT 7 _kDartIsolateSnapshotInstructions
3: 0000000000000340 18384 OBJECT GLOBAL DEFAULT 5 _kDartVmSnapshotData
4: 0000000000004b40 1260736 OBJECT GLOBAL DEFAULT 5 _kDartIsolateSnapshotData
5: 00000000000001c8 32 OBJECT GLOBAL DEFAULT 1 _kDartSnapshotBuildId
78da37fed6bf1489361a312568249f3fproduct no-code_comments no-dwarf_stack_traces_mode dedup_instructions no-asan no-msan no-tsan no-shared_data arm64 android compressed-pointers
三个 Build ID 和 REA 的输出完全一致。strings 这一行还多给了一样 REA 没输出的东西:快照哈希后面紧跟着 VM 特性串(product、arm64、compressed-pointers 等)。另外符号表里还有第五个符号 _kDartSnapshotBuildId。libapp.so 自己也有一个 16 字节的 GNU Build ID(这里是 7a3ef930e25ac69521a2e1cc22c4cada),REA 没有报告它。
从哈希到 Dart 版本
REA 故意不做“哈希到 SDK 版本”的查表。它的输出里写明,哈希“只能对照外部映射数据集来确定 Dart SDK 构建”。好在 App 是我们自己编的,可以在本地闭环验证:Flutter 缓存里的 gen_snapshot 就带着这个哈希字符串。
strings -a ~/flutter/bin/cache/artifacts/engine/android-arm64-release/darwin-x64/gen_snapshot \
| grep -c 78da37fed6bf1489361a312568249f3f
2
由此可以确定,78da37fe… 就是 Flutter 3.41.4 自带的 Dart 3.11.1 对应的哈希。如果 APK 不是你编的,就拿候选 Flutter 版本的 gen_snapshot 逐个比对,或者查外部的哈希表。REA 在 GitHub 上的指南举的例子是 reFlutter 维护的那份。
第一处出入就在这里。 REA 给出的是 dart_version: null,限制说明里写的是发布版引擎“通常不带带标签的 Dart 版本字符串”。可这个发布版引擎其实带着版本字符串,只是前面没有标签:
strings -a lib/arm64-v8a/libflutter.so | grep '(stable)'
3.11.1 (stable) (Tue Feb 24 00:03:07 2026 -0800) on "android_arm64"
翻 dist/flutter/FlutterPayloadScan.js 可以看到,REA 只认以 Dart VM version: 开头的字符串,而我们测的两个引擎都没有这个前缀。后面的 Flutter Gallery 也一样,里面是一条裸的 2.14.0-151.0.dev (dev)。也就是说,REA 报告缺失的 Dart 版本,两个 APK 上一条 grep 就能拿到。
第二步:看快照内部
npx rea inspect-dart-aot /abs/path/app-release.apk
耗时 0.79 秒,输出 61 KB 的 JSON。没传 --abi 时,REA 默认选了 arm64-v8a。段信息(已裁剪):
sections[4]{name,offset,size,magic_valid,kind,header_length}:
_kDartVmSnapshotInstructions,1310720,92704,false,null,null
_kDartIsolateSnapshotInstructions,1403456,2042112,false,null,null
_kDartVmSnapshotData,832,18384,true,3,16844
_kDartIsolateSnapshotData,19264,1260736,true,3,1032468
偏移和大小与上面 --dyn-syms 的结果一一对应(0x140000 = 1310720)。指令段本来就没有快照 magic,所以那里的 magic_valid: false 是对的。REA 的限制说明把 kind 3 称为“Dart 2.14 FullAOT 布局”,我们原本以为 Dart 3.11 会被判成部分覆盖,结果并没有:快照头仍然是 kind 3,libapp.coverage 返回的是 complete。
真正有用的是字符串池这部分:
string_pool:
coverage: partial
package_uri_count_at_least: 333
dart_uri_count_at_least: 199
class_like_token_count_at_least: 3699
printable_run_count: 9617
顶层的 coverage: partial 来自标识符 token 列表,REA 最多只返回 2000 条。把 333 个 URI 按包名归一下类:
package:flutter 285
package:material_color_utilities 16
package:http 14
package:intl 8
package:vector_math 6
package:collection 2
package:hello_rea 1
package:stack_trace 1
再和构建时真实的依赖对比:
flutter pub deps --no-dev --style=compact
这条命令列出了 19 个运行时包(算上 sky_engine),快照里只出现了 flutter、http、intl、material_color_utilities、vector_math、collection 这六个。async、path、http_parser、typed_data、web 等包虽然在依赖图里,却没有留下任何 URI 字符串。反过来,package:stack_trace 根本不是依赖,却出现在了列表里。它其实来自 Flutter foundation/assertions.dart 第 1083 行的一个字符串字面量,是一张栈帧过滤表。所以这份清单只能理解为“至少用到了这些包”,不能当成软件物料清单(SBOM)。
URI 末尾的多余字节
URI 列表是按字节模式扫出来的,这一点在输出里看得很清楚。arm64 上 333 条里有 6 条末尾多了一个字符,比如 package:http/src/response.dartH、.../hct_solver.dart@,还有一条以空格结尾,再加上那条光秃秃的 package:stack_trace。到了 armeabi-v7a,333 条里有 48 条,大多是 ...dart 后面跟一个 r:
npx rea inspect-dart-aot /abs/path/app-release.apk --abi armeabi-v7a
package_uris[333]: "package:collection/src/equality.dartr", ... "package:flutter/src/animation/animation.dartr", ...
strings 也能看到同样的多余字符,说明这些字节确实在文件里。REA 原样输出,没有做裁剪。如果要在不同版本之间 diff 这份列表,记得先规整一下,比如 sed -E 's/\.dart.$/.dart/'。指定一个 APK 里没有的 ABI,会得到一个清楚的报错:
code: unsupported_target
reason: "No libapp.so was found for ABI x86; available: arm64-v8a, armeabi-v7a, x86_64"
第三步:在 agent 里分页取数据
MCP 客户端的常见用法是先要 detail: "summary",再分页拉字符串。我们写了个小 Node 脚本,直接驱动 REA 的 stdio 服务(npx rea --mcp,共 207 个工具)。summary 调用耗时 100 ms,然后请求一页数据:
{ "name": "inspect_analysis_view",
"arguments": {
"source": { "kind": "retained-evidence", "evidence_id": "ev_fab95030…" },
"view": { "kind": "dart-aot", "collection": "package_uris", "offset": 0, "limit": 5 } } }
我们第一次传错了 ID。 summary 的返回本身就是一个 analysis view,所以它顶层的 evidence_id 属于这个视图,而不是原始扫描。把这个 ID 传回去,得到的是:
Parent Evidence operation "inspect_analysis_view" does not support analysis views.
正确的做法是传 normalized_result.parent_evidence_id。换成这个 ID 后,返回了 5 条 URI 和 coverage.next_offset。命令行下也能做同样的事:把保存好的 JSON 内联成 {"source":{"kind":"inline","evidence":<inspect-dart-aot JSON>},"view":{...}},交给 npx rea inspect-analysis-view view.json,耗时 0.68 秒。
混淆之后还剩什么
很多发布版会打开混淆,所以我们也重新编了一个混淆版:
flutter build apk --release --split-per-abi --obfuscate --split-debug-info=build/symbols
| arm64-v8a | 普通构建 | --obfuscate |
|---|---|---|
libapp.so 大小 |
3,474,352 字节 | 2,884,528 字节 |
| 快照哈希 | 78da37fe… |
78da37fe…(不变) |
package_uris |
333 | 3 |
dart_uris |
199 | 7 |
| 类名样 token | ≥ 3,699 | 1,043 |
coverage |
partial | complete |
快照哈希和引擎 Build ID 不受混淆影响,第一步在混淆包上照样能用。依赖信息就没这么幸运了:只剩两条 package:flutter/src/services/... 和那个 stack_trace 字面量。普通构建里能看到的 MyHomePage、DateFormat 这类名字全没了。但 https://example.com/hello-rea/status 这个字面量用 strings 照样能看到,因为混淆只改符号名,不动字符串常量。
换个大一点的 App:Flutter Gallery 2.9.2
只测一个玩具 App 说服力不够,我们又下载了已归档的 Flutter Gallery 发布包(BSD-3-Clause,flutter_gallery_Android.apk,117,817,550 字节):
abi: arm64-v8a snapshot_hash: 65817c30a78bb44c3dc3771876b6010a
abi: armeabi-v7a snapshot_hash: 65817c30a78bb44c3dc3771876b6010a
abi: x86_64 snapshot_hash: 65817c30a78bb44c3dc3771876b6010a
coverage: complete
这个哈希和 REA 在 GitHub 上的 Flutter 指南(docs/flutter-analysis.md)给出的参考值一致,说明 6.7.0 能复现项目自己的结果。识别耗时 1.16 秒。inspect-dart-aot 报告了 package_uris reports 500 of at least 756 distinct URIs,这就是第二处对实际使用影响很大的出入:REA 先按字母序排序再截断,所以返回的 500 条停在了 package:flutter_gen/gen_l10n/gallery_localizations_tr.dart。字母序排在 flutter_gen 之后的包全部丢失,包括 App 本身的 gallery,还有 google_fonts、http、intl、provider、url_launcher。用 inspect_analysis_view 分页也没用,翻来翻去还是这 500 条。同一个 libapp.so,strings 能找出 37 个不同的包名:
strings -a lib/arm64-v8a/libapp.so | grep -oE 'package:[a-z_0-9]+/' | sort | uniq -c | sort -rn
所以如果 App 的 Dart 源文件超过 500 个左右,建议分工:快照哈希、快照头和证据链用 REA 拿,包名清单用 strings 拿。
还有一个坑:AAB
flutter build appbundle 生成了一个 39.2 MB 的 .aab,里面有 base/lib/arm64-v8a/libapp.so。REA 对它的结论很笃定,但是错的:
flutter_detected: false
abis: []
coverage: complete
inspect-dart-aot 则直接拒绝,提示 “No lib/base/ 前缀,对同一个 bundle 能正确识别出 Flutter。在 REA 支持 AAB 之前,请拿 flutter build apk 产出的 APK 去跑。如果手上只有 .aab,先用 bundletool 生成 APK,做法见 AAB 解包指南。
局限与下一步
对 Flutter APK,REA 6.7.0 能给你这些:按 ABI 输出、经两处快照头互相印证的快照哈希;引擎 Build ID;能用 llvm-readelf 复核的段偏移;以及一份有上限、绑定了 evidence ID 的字符串清单。它做不到的有:把哈希映射成 SDK 版本、读出不带标签的 Dart 版本字符串、恢复类和函数、裁掉 URI 末尾的多余字节、在字母序前 500 条之外列出更多 URI、识别 AAB。iOS 构建和单独的 libapp.so 我们没有测试,命令接受的是 APK 路径。
想继续深入原生代码,可以按 REA + Ghidra 原生二进制分析 的做法把 libapp.so 载入 Ghidra。不过没有针对具体 SDK 的快照解析器,你看到的只是机器码,而不是 Dart 代码。同一个 APK 的 Java/Kotlin 部分,见 用 REA 分析 Android APK。想保留这次的 evidence ID,下个版本发布时拿来对比,见 REA 的 Evidence、快照与版本对比。如果 agent 里还没装 REA,先看 安装 REA MCP。只想快速看一眼、不想装任何东西的话,把 APK 拖进 APK 分析工具,就能确认框架、ABI 和 16 KB 对齐;也可以用 ELF 查看器 打开 libapp.so,查看快照符号。