rea-reverse

Flutter APK 逆向分析:用 REA 读 libapp.so 的 Dart 快照哈希与依赖

基于 REA 6.7.0发布者 rea-reverse更新于 约 9 分钟读完
本页目录

先说结论: 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 发布包(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//libapp.so was found”。原因是 REA 只按 APK 的路径布局去找文件。本站的 APK 分析工具会处理 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,查看快照符号。