rea-reverse

Flutter APK Reverse Engineering: libapp.so and Dart with REA

Written for REA 6.7.0Published by rea-reverseUpdated 9 min read
On this page

Short answer: in a Flutter release APK, almost none of the app’s logic is in classes.dex. It is Dart code compiled ahead of time into lib/<abi>/libapp.so, and the engine sits next to it in libflutter.so. REA 6.7.0, the open-source project by morluto, has two commands for this layout. rea identify-flutter-build reads the Dart snapshot hash and the engine’s GNU build ID for every ABI. rea inspect-dart-aot lists the four snapshot sections and pulls package: and dart: URIs out of the snapshot data. Both commands only parse the file. They don’t run the app, start an engine or use the network.

We tested this on an app we built ourselves, on an obfuscated build of that app, and on the open-source Flutter Gallery 2.9.2 release APK. We checked every number against unzip, llvm-readelf and strings. REA got the hash right every time. It also missed some things, and those cases are covered below.

Only analyze apps you built, open-source releases, or apps you are authorized to assess. Read REA’s DISCLAIMER.md before pointing it at anything you didn’t build.

Find Dart Inside the Flutter APKSilent walkthrough with English captions.

What we used

Component Version
Host macOS 27.0.1, Apple silicon (arm64)
Node.js 22.22.3 (REA’s engines field asks for ^22.19.0 || ^24.11.0 || >=26.0.0)
REA [email protected] from npm
Flutter / Dart Flutter 3.41.4 stable, engine revision e4b8dca3f1, Dart 3.11.1
Cross-check llvm-readelf from LLVM 21.0.0 (Android NDK 29.0.14206865), macOS strings

We installed REA into an empty directory, so nothing touched a global install:

mkdir rea-sandbox && cd rea-sandbox
npm init -y
npm install [email protected]
npx rea --version
6.7.0

The install took about 9 seconds. Before trusting the command list, we unpacked the published tarball (npm pack [email protected]) and confirmed that identify-flutter-build and inspect-dart-aot are registered in dist/cliCommandNames.js. One thing we noticed: the README’s link to docs/flutter-analysis.md points to a file that isn’t in the npm package. It is only on GitHub, so treat that page as documentation for main, not necessarily for 6.7.0.

Build a sample you are allowed to take apart

We wanted a target where we knew the answer in advance, so we built one. It is the stock counter app with two pub.dev dependencies added, and a URL constant we can search for later:

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())}';
}

The counter button calls fetchStatus(), so the compiler can’t tree-shake either package away. Then:

flutter build apk --release

The first build failed, and the error message didn’t help:

FAILURE: Build failed with an exception.

* What went wrong:
25.0.2

The “25.0.2” is a Java version. Flutter had picked the JDK bundled with Android Studio, which on this machine is OpenJDK 25.0.2, and the template’s Gradle 8.14 failed on it. The system JDK was Temurin 17. We didn’t want to change Flutter’s global configuration, so we pinned the JDK for this one project:

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)

flutter config --jdk-dir <path> fixes the same problem for every project. The cold build took 68 seconds. Later builds took 10 to 15 seconds.

First look: where the code went

Before running any reverse engineering tool, list the archive:

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

The DEX file is 514 KiB of Android embedding and plugin glue. Our Dart code, http, intl and the Flutter framework are in the 3.3 MiB libapp.so. That is why opening a Flutter APK in JADX shows you MainActivity and very little else.

This site’s APK analyzer reaches the same verdict. Its detector flags Flutter when it sees libflutter.so or assets/flutter_assets/. We ran its detectFrameworks() function from src/tools/apk/detect.ts against this APK, and it reported Flutter with the three libflutter.so paths as evidence. Since 2026-10-20 the analyzer also shows a “Flutter build details” block. It reads the snapshot hash and VM features from the start of each libapp.so and the Dart version string from libflutter.so, and it tells release, profile and debug builds apart. On this APK it showed release, 3.11.1 (stable) and 78da37fed6bf1489361a312568249f3f for all three ABIs, the same values as the REA and strings runs below. It doesn’t parse the snapshot, so section offsets, build IDs and package URIs are still REA’s job.

Step 1: identify the build

npx rea identify-flutter-build /abs/path/app-release.apk --format json

It finished in 0.94 seconds on the 43.7 MB APK. Here is the arm64 entry, trimmed. We cut the other two ABIs, which have the same shape, and the evidence envelope:

{
  "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 means REA read the hash from both the VM and the isolate snapshot headers, and the two agreed. If they disagreed, REA would keep both candidates and wouldn’t pick one. All three ABIs reported 78da37fed6bf1489361a312568249f3f. The engine build IDs differ per ABI, as they should, because each libflutter.so is a separate binary.

Checking it without REA

The build IDs are standard ELF notes, so llvm-readelf can confirm them:

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

All three build IDs matched REA’s output. The strings line shows something REA leaves out: the snapshot hash is followed directly by the VM feature string (product, arm64, compressed-pointers and so on). There is also a fifth symbol, _kDartSnapshotBuildId. libapp.so has its own 16-byte GNU build ID (7a3ef930e25ac69521a2e1cc22c4cada here), and REA doesn’t report it.

From hash to Dart version

REA deliberately does no hash-to-SDK lookup. Its output says the hash “identifies a Dart SDK build only against an external mapping dataset”. Because we built the app, we could close the loop locally. The hash string appears in Flutter’s cached gen_snapshot:

strings -a ~/flutter/bin/cache/artifacts/engine/android-arm64-release/darwin-x64/gen_snapshot \
  | grep -c 78da37fed6bf1489361a312568249f3f
2

So 78da37fe… is the hash for Dart 3.11.1 as shipped in Flutter 3.41.4. For an APK you didn’t build, you would compare it against gen_snapshot from candidate Flutter releases, or against an external hash table. REA’s GitHub guide names reFlutter’s as one example.

This is where we found the first discrepancy. REA reported dart_version: null. Its limitation text says release engines “often carry no labeled Dart version string”. But the release engine does carry a version string. It just isn’t labeled:

strings -a lib/arm64-v8a/libflutter.so | grep '(stable)'
3.11.1 (stable) (Tue Feb 24 00:03:07 2026 -0800) on "android_arm64"

REA’s scanner in dist/flutter/FlutterPayloadScan.js only accepts runs that start with Dart VM version:. Neither engine we tested has that prefix. The Flutter Gallery engine below has the same kind of bare string, 2.14.0-151.0.dev (dev). On both, a one-line grep gives you the Dart version that REA reports as missing.

Step 2: look inside the snapshot

npx rea inspect-dart-aot /abs/path/app-release.apk

It took 0.79 seconds and wrote 61 KB of JSON. With no --abi, REA chose arm64-v8a. The sections, trimmed:

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

The offsets and sizes match the --dyn-syms output above (0x140000 = 1310720). The instruction sections have no snapshot magic, so magic_valid: false is correct there. REA’s own limitation text calls kind 3 “the Dart 2.14 FullAOT layout”. We expected Dart 3.11 to come out as partial coverage. It didn’t: the headers are still kind 3, and libapp.coverage came back complete.

The string pool is the useful part:

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

The top-level coverage: partial comes from the token list, which REA caps at 2,000 entries. Grouped by package, the 333 URIs break down as follows:

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

Compare that with what the build actually depended on:

flutter pub deps --no-dev --style=compact

That command lists 19 runtime packages, counting sky_engine. Only flutter, http, intl, material_color_utilities, vector_math and collection appear in the snapshot. Packages such as async, path, http_parser, typed_data and web are in the dependency graph but left no URI string behind. Meanwhile package:stack_trace appears although it isn’t a dependency at all. It comes from a string literal on line 1083 of Flutter’s foundation/assertions.dart, a stack-frame filter table. Read this list as “at least these packages”, not as a software bill of materials.

Stray bytes at the end of URIs

The URI list is a byte-pattern scan, and the output shows it. On arm64, 6 of the 333 entries end in an extra character: package:http/src/response.dartH, .../hct_solver.dart@, one with a trailing space, and the bare package:stack_trace. On armeabi-v7a it was 48 of 333, mostly ...dart followed by 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 finds the same junk, so the extra bytes really are in the file. REA reports the raw bytes and doesn’t trim them. If you diff these lists between builds, normalize first, for example with sed -E 's/\.dart.$/.dart/'. Asking for an ABI that the APK doesn’t ship fails cleanly:

code: unsupported_target
reason: "No libapp.so was found for ABI x86; available: arm64-v8a, armeabi-v7a, x86_64"

Step 3: page through results from an agent

An MCP client usually asks for detail: "summary" first and then pages through the strings. We drove REA’s stdio server (npx rea --mcp, 207 tools) from a small Node script. The summary call took 100 ms. Then we requested a page:

{ "name": "inspect_analysis_view",
  "arguments": {
    "source": { "kind": "retained-evidence", "evidence_id": "ev_fab95030…" },
    "view": { "kind": "dart-aot", "collection": "package_uris", "offset": 0, "limit": 5 } } }

We got this wrong the first time. The summary result is itself an analysis view, so its top-level evidence_id belongs to the view, not to the scan. Passing that ID back returned:

Parent Evidence operation "inspect_analysis_view" does not support analysis views.

The ID to pass is normalized_result.parent_evidence_id. With that ID, the page returned five URIs and coverage.next_offset. From the CLI you can pass the saved JSON inline as {"source":{"kind":"inline","evidence":<inspect-dart-aot JSON>},"view":{...}} to npx rea inspect-analysis-view view.json. That ran in 0.68 seconds.

What obfuscation changes

Release builds are often shipped with obfuscation turned on, so we rebuilt with it:

flutter build apk --release --split-per-abi --obfuscate --split-debug-info=build/symbols
arm64-v8a Plain build --obfuscate
libapp.so size 3,474,352 bytes 2,884,528 bytes
Snapshot hash 78da37fe… 78da37fe… (unchanged)
package_uris 333 3
dart_uris 199 7
Class-like tokens ≥ 3,699 1,043
coverage partial complete

The hash and the engine build ID survive obfuscation, so step 1 works the same on an obfuscated APK. The dependency picture does not. Only two package:flutter/src/services/... URIs and the stack_trace literal remained. Names like MyHomePage and DateFormat, which were among the tokens in the plain build, were gone. The https://example.com/hello-rea/status literal was still visible to strings, because obfuscation renames symbols and leaves string constants alone.

To test REA beyond a toy app, we downloaded the archived Flutter Gallery release (BSD-3-Clause, flutter_gallery_Android.apk, 117,817,550 bytes):

abi: arm64-v8a     snapshot_hash: 65817c30a78bb44c3dc3771876b6010a
abi: armeabi-v7a   snapshot_hash: 65817c30a78bb44c3dc3771876b6010a
abi: x86_64        snapshot_hash: 65817c30a78bb44c3dc3771876b6010a
coverage: complete

That hash is the reference value in REA’s Flutter guide on GitHub (docs/flutter-analysis.md), so 6.7.0 reproduces the project’s own result. The identify step took 1.16 seconds. inspect-dart-aot reported package_uris reports 500 of at least 756 distinct URIs, and this is the second discrepancy that matters in practice. The 500 URIs it returns are sorted alphabetically before the cap is applied, so the list stops at package:flutter_gen/gen_l10n/gallery_localizations_tr.dart. Every package that sorts after flutter_gen is missing, including gallery (the app itself), google_fonts, http, intl, provider and url_launcher. Paging with inspect_analysis_view doesn’t help, because it pages through the same 500 entries. strings found 37 distinct package names in the same libapp.so:

strings -a lib/arm64-v8a/libapp.so | grep -oE 'package:[a-z_0-9]+/' | sort | uniq -c | sort -rn

For an app with more than about 500 Dart source files, use REA for the hash, the headers and the evidence trail, and use strings for the package list.

One more trap: AAB files

flutter build appbundle produced a 39.2 MB .aab with base/lib/arm64-v8a/libapp.so inside. REA’s verdict on it was confidently wrong:

flutter_detected: false
abis: []
coverage: complete

inspect-dart-aot refused the file with “No lib//libapp.so was found”. REA only looks for the APK path layout. The site’s APK analyzer maps base/ paths and does detect Flutter in the same bundle. Until REA handles AABs, run it on the APK from flutter build apk. For a bundle you only have as a .aab, generate an APK with bundletool first, as shown in the AAB unpack guide.

Limits and where to go next

What REA 6.7.0 gives you for a Flutter APK: a per-ABI snapshot hash checked against two headers, engine build IDs, section offsets you can verify with llvm-readelf, and a bounded string inventory tied to an evidence ID. What it doesn’t do: map the hash to an SDK version, read the bare Dart version string, recover classes or functions, trim stray bytes from URIs, list more than the first 500 URIs alphabetically, or accept an AAB. We didn’t test an iOS build or a standalone libapp.so. The commands take an APK path.

To go further into the native code, load libapp.so into Ghidra as shown in REA + Ghidra native binary analysis. Keep in mind that without an SDK-specific snapshot parser you will see machine code, not Dart. For the Java and Kotlin side of the same APK, see Android APK analysis with REA. To keep the evidence IDs from this run and compare a later release against it, see REA evidence snapshots and comparison. If REA isn’t set up in your agent yet, start with installing REA MCP. For a quick check without installing anything, drop the APK into the APK analyzer to confirm the framework, the ABIs and 16 KB alignment, or open libapp.so in the ELF viewer to see the snapshot symbols.