flutterdec is a static Flutter AOT decompiler research tool for Android ARM64 binaries.
It takes an APK or libapp.so and emits readable pseudo-Dart plus optional IR, asm, diff, startup, and symbol-reporting artifacts.
flutterdec is meant for people reversing Flutter apps who want a practical first pass that is more readable than raw disassembly, while still being easy to verify against lower-level artifacts.
If you are new to the project, the fastest way to think about it is:
infotells you what the target looks likedecompilegives you pseudocode plus reportsdiffcompares two buildsmap-symbolsimproves naming when you have matched engine binaries
- Android Flutter AOT
- ARM64
- static analysis
- input as APK or
libapp.so
If you just want to try the tool, start with one of these paths:
- no install:
nix run - persistent Nix install:
nix profile install - persistent install: release binaries
Run directly from GitHub:
nix run github:caverav/flutterdec -- --help
nix run github:caverav/flutterdec -- info ./sample.apk --json
nix run github:caverav/flutterdec -- decompile ./sample.apk -o ./outRun from a local checkout:
nix run . -- --help
nix run . -- info ./sample.apk --json
nix run . -- decompile ./sample.apk -o ./outInstall from GitHub:
nix profile install github:caverav/flutterdec
flutterdec --helpInstall from a local checkout:
nix profile install .
flutterdec --helpUpdate later:
nix profile upgrade flutterdecCurrent prerelease: v0.1.0-alpha.4
The archive is a prefix, not a lone binary: it holds bin/flutterdec plus the compatibility
registry, the runtime profiles and the packaged producer under share/flutterdec. The CLI finds
that data relative to its own executable, so copy bin and share to the same prefix and keep
them together. A binary moved out on its own reports which directories it looked in and stops.
Linux x64:
curl -fLO https://github.com/caverav/flutterdec/releases/download/v0.1.0-alpha.4/flutterdec-v0.1.0-alpha.4-Linux-X64.tar.gz
mkdir -p flutterdec-v0.1.0-alpha.4
tar -xzf flutterdec-v0.1.0-alpha.4-Linux-X64.tar.gz -C flutterdec-v0.1.0-alpha.4
sudo cp -R flutterdec-v0.1.0-alpha.4/bin flutterdec-v0.1.0-alpha.4/share /usr/local/
flutterdec --helpmacOS arm64:
curl -fLO https://github.com/caverav/flutterdec/releases/download/v0.1.0-alpha.4/flutterdec-v0.1.0-alpha.4-macOS-ARM64.tar.gz
mkdir -p flutterdec-v0.1.0-alpha.4
tar -xzf flutterdec-v0.1.0-alpha.4-macOS-ARM64.tar.gz -C flutterdec-v0.1.0-alpha.4
sudo cp -R flutterdec-v0.1.0-alpha.4/bin flutterdec-v0.1.0-alpha.4/share /usr/local/
flutterdec --helpOther platforms and future tags:
Install into the user Cargo bin:
nix develop -c cargo install --path crates/flutterdec-cli
~/.cargo/bin/flutterdec --helpRun from source without installing:
nix develop -c cargo run -p flutterdec-cli -- --help
nix develop -c cargo run -p flutterdec-cli -- info ./sample.apk --json
nix develop -c cargo run -p flutterdec-cli -- decompile ./sample.apk -o ./outBuild a local release binary:
nix develop -c cargo build -p flutterdec-cli --release
./target/release/flutterdec --helpIf this is your first run, this is the shortest useful path.
- Inspect the target:
flutterdec info ./sample.apk --jsoninfo reads the snapshot identity out of the header with no adapter installed and no
disassembly: snapshot_hash, arch, snapshot_features, compressed_pointers, and
registry_record_present.
The Dart profile fields come from the host registry record that the identity matched, and appear only once that record's adapter is installed, because the profile is SHA-256 verified as part of authorizing the run:
dart_aliases: zero or more SDK labels, each with its ownecosystem,version, andprovenance. They are provenance only and never select a parser or a profile.dart_version: a display value, never an exact SDK claim. It isunverifiedwhen the record carries aliases andunavailablewhen it carries none.dart_tag_style(CID_INT32,CID_SHIFT1, orOBJECT_HEADER)
"dart_aliases": [
{"ecosystem": "dart", "version": "3.5.0", "provenance": "r2flutter-offsets"},
{"ecosystem": "flutter", "version": "3.24.0", "provenance": "sample-build-metadata"}
],
"dart_version": "unverified",
"dart_tag_style": "OBJECT_HEADER"All three are null when no registry record matches the snapshot identity, and when a
matching record's adapter is not installed.
The registry record is the only authority for SDK labels, so a snapshot with no record reports no alias at all — including hashes that an earlier version labelled from the bundled profile table. That is the accepted cost of keeping one authority: there is no second, weaker path that names a version from a hash.
For APK inputs, info reports Android startup summary fields such as:
android_startup_presentandroid_startup_confidenceandroid_startup_entrypoint_countandroid_startup_flutter_activity_count
If adapter metadata is available, info also reports package and compatibility signals such as:
app_package_counts_toprequested_backend,resolved_backend,backend_fallback_reasonproducer_id,producer_trust,compatibility_record_sha256snapshot_identity_is_exactidentity_rejection(why no adapter could be selected, when none could)model_capabilitiescompatibility_warningsprovider(one block: requested and resolved backend, whether an adapter was executed at all and why not, host and target architectures, the producer and its artifact digest, the parser family, profile and artifact the registry named, and the containment the child reported)
- Install the adapter for the detected Dart hash:
flutterdec adapter install --dart-hash <HASH>- Decompile:
flutterdec decompile ./sample.apk -o ./outExpect a non-zero exit on a real app, and expect your artifacts anyway. The strict quality gate is on by
default with --max-placeholder-ifs 0, and every real Flutter app has placeholder ifs. So the command above
prints reasons: placeholder if-count exceeded threshold, exits 1, and still writes every artifact
listed in step 4. Nothing is missing - the gate is reporting a quality measurement, not a failure to
decompile. Measured on LocalSend 1.17: 501 at the default scope, 5,800 pseudocode files written, exit 1.
Read your own number rather than guessing one. It is placeholder_ifs in out/quality.json, and it grows
with scope - the same app reports 1,178 under --function-scope all --split-records:
flutterdec decompile ./sample.apk -o ./out # exits 1, writes everything
python3 -c "import json;print(json.load(open('out/quality.json'))['placeholder_ifs'])"
flutterdec decompile ./sample.apk -o ./out --max-placeholder-ifs <that number>Setting the threshold from the measurement is the point: a huge round number silences the gate permanently,
whereas a real one still fails when the count rises, which is the only thing it is useful for. Keep the
strict default in CI. See docs/cli-reference.md for the other gate flags.
- Open the main outputs first:
out/pseudocode/*.dartpseudoout/report.jsonout/quality.json
That is enough to start working on most APKs.
Inspect target metadata:
flutterdec info ./sample.apk --jsonInstall and list adapters:
flutterdec adapter install --dart-hash <HASH>
flutterdec adapter listAdapters install into a writable store outside any checkout: FLUTTERDEC_ADAPTER_STORE when set,
otherwise $XDG_DATA_HOME/flutterdec/adapters or $HOME/.local/share/flutterdec/adapters. The
compatibility registry and the runtime profiles are read-only package data, found next to the binary at
share/flutterdec (or wherever FLUTTERDEC_DATA_DIR points). Neither location depends on your current
directory, so a release binary behaves the same everywhere. adapter list reports verified,
missing, corrupt, incompatible, or unavailable per record and exits 2 if the store holds an
install it cannot back. See docs/user-guide.md for the full rules.
Decompile with the default app-focused scope:
flutterdec decompile ./sample.apk -o ./outEmit asm and IR too:
flutterdec decompile ./sample.apk -o ./out --emit-asm --emit-irCompare two builds:
flutterdec diff --old ./old.apk --new ./new.apk -o ./out-diff --jsonGenerate Ghidra and IDA import scripts:
flutterdec decompile ./sample.apk -o ./out \
--emit-ghidra-script \
--emit-ida-scriptBy default, decompile focuses app reversing with --function-scope app-unknown and excludes known Flutter/Dart framework internals.
Available scopes:
app-unknown(default): app (package:*) plus unknown ownership functionsapp: only app (package:*) functionsall: app plus Flutter, Dart runtime, and framework internals
Include everything:
flutterdec decompile ./sample.apk -o ./out --function-scope allFocus only specific Dart packages (repeatable):
flutterdec decompile ./sample.apk -o ./out \
--function-scope app-unknown \
--app-package my_appIf package names are unknown, inspect report.json under function_scope.app_package_counts_top.
When --app-package is not provided, capped prioritization also uses manifest-derived package hints under function_scope.priority_package_hints to favor app-owned code.
Target a specific function by id:
flutterdec decompile ./sample.apk -o ./out \
--target id:42 \
--emit-asmTarget by entry address:
flutterdec decompile ./sample.apk -o ./out \
--target va:0x613468 \
--emit-asm--target accepts:
id:<N>va:0x<ADDR>0x<ADDR><N>
If <N> is ambiguous, flutterdec requires explicit id: or va:. Selection details are emitted in report.json.target_selection.
If you have a stripped/unstripped libflutter.so pair, generate a symbol map:
flutterdec map-symbols \
--stripped ./libflutter.stripped.so \
--unstripped ./libflutter.unstripped.so \
-o ./out/symbol-map \
--register-local-cacheThen use that in later decompile runs:
flutterdec decompile ./sample.apk -o ./out \
--extra-symbol-elf ./libflutter.unstripped.soIf the cached engine build id matches the APK's embedded libflutter.so, decompile auto-loads the cached symbol_target_summary.json and reports it under report.json.engine_symbol_ingestion.
flutterdec diff --old ./old.apk --new ./new.apk -o ./out-diff --jsondiff_report.json includes added, removed, and common function summaries plus added_packages_top and removed_packages_top churn summaries.
| If you want to... | Start with... | Why |
|---|---|---|
| Read recovered logic | pseudocode/*.dartpseudo |
Best first pass for branches, loops, returns, and named callsites |
| Validate the decompiler | asm/*.s and ir/*.json |
Lets you confirm control flow and pool-backed calls |
| Understand startup | report.json.android_startup |
Shows manifest anchors, startup stages, and DartEntrypoint evidence |
| Check analysis health | quality.json and report.json |
Shows coverage, compatibility, target selection, and symbol-ingestion diagnostics |
| Review version-to-version changes | diff_report.json |
Shows recovered function churn and package-level change summaries |
decompile exposes analysis-engine profiles so you can trade detail for speed.
Default profile:
balanced(recommended)
Available profiles:
balanced: full semantic naming, hints, and reportinglight: lower-overhead analysis for faster large-scale runs
Example:
flutterdec decompile ./sample.apk -o ./out --analysis-profile lightAdapter backend selection:
--adapter-backend auto(default): try r2flutter, then Blutter, then the producer's internal path--adapter-backend internal: recover in core; select nothing, read no registry, execute nothing--adapter-backend blutter: require the Blutter backend and fail if unavailable--adapter-backend r2-flutter: require the r2flutter backend and fail if unavailable--adapter-timeout <SECONDS>: bound one adapter invocation--require-snapshot-hash-match: fail unless the snapshot identity came from a real header
A snapshot nothing is authorized to parse does not end the run. Core recovers
ARM64 code candidates from the instruction bytes, marks every one of them
heuristic and unnamed, and leaves libraries, classes, function names, the
original entry function and the ObjectPool unavailable with a diagnostic each.
core_fallback_reason says which condition it was. A pinned external backend is
refused by name instead, and an adapter that was authorized, ran, and failed is
still a failure. See Core recovery.
What the backends actually recover:
| Backend | Function names | Classes | ObjectPool |
|---|---|---|---|
| core recovery | none at all; code ranges are unnamed | none | unavailable |
internal |
none at all; code ranges are unnamed | none | carved strings, ordinal index space |
blutter |
scraped from Blutter's rendered source, heuristic | yes | Blutter pp.txt entries, ordinal index space |
r2flutter |
exact, from the AOT instruction table | yes, library attribution unavailable | real slots, resolvable from x27 displacements |
The model says which of these it did: every domain carries a capability level
(complete / partial / unavailable) and every recovered fact carries a provenance
(exact / derived / heuristic). A function whose name was not recovered has no
name rather than a sub_<addr> placeholder, and flutterdec labels it from its entry
address at emit time.
Only a backend that recovers the real ObjectPool layout claims a hardware index
space. Without one, flutterdec leaves pool references unresolved rather than
attaching a value from an unrelated index space, and says so in
report.json.pool_metadata.hints_suppressed_reason.
r2flutter backend environment knobs:
FLUTTERDEC_R2FLUTTER_BIN: path to ther2flutterbinaryFLUTTERDEC_R2FLUTTER_CMD: full command to launch it, when a wrapper is neededFLUTTERDEC_R2FLUTTER_TIMEOUT: per-invocation timeout in seconds (default 900)- otherwise
r2flutteris taken fromPATH
r2flutter is an external MIT tool (radareorg/r2flutter)
that parses Dart AOT snapshots directly. It needs radare2 available at build time.
Blutter backend environment knobs:
FLUTTERDEC_BLUTTER_CMD: full command to launch Blutter, for examplepython3 /path/to/blutter.pyFLUTTERDEC_BLUTTER_PY: path toblutter.pywhen you want the current Python interpreter to run it
Nix integration:
nix developprovidesflutterdec-blutterand auto-exportsFLUTTERDEC_BLUTTER_CMDto that wrapper- you can also run the wrapper directly via
nix run .#blutter-bridge -- --help
Per-feature engine toggles:
--with-canonical-model-symbols/--no-canonical-model-symbols--with-pool-value-hints/--no-pool-value-hints--with-pool-semantic-hints/--no-pool-semantic-hints--with-semantic-reporting/--no-semantic-reporting--with-bootflow-category-seeds/--no-bootflow-category-seeds--with-apk-startup-analysis/--no-apk-startup-analysis
Main outputs under -o <OUT_DIR>:
pseudocode/*.dartpseudoquality.jsonreport.jsondiff_report.json(ifflutterdec diff)asm/*.s(if--emit-asm)- opcode-prefixed asm lines (if
--emit-asm --emit-asm-opcodes) ghidra_apply_symbols.py(if--emit-ghidra-script)ida_apply_symbols.py(if--emit-ida-script)ir/*.json(if--emit-ir)
report.json also includes:
compatibilityfor schema, hash, and manifest alignment diagnosticsandroid_manifestfor manifest-derived launcher, deeplink, and activity signalsandroid_startupfor APK bytecode startup evidence such as embedding calls, JNI bootstrap stages, and recoveredDartEntrypointcallsites when presentandroid_startup.dart_entrypointsentries can carryfunction_name,library_uri, andapp_bundle_pathwhen those values are directly recoverable from APK bytecode or simple helper return propagationandroid_startup.bootstrap_chainsummarizes observed Android embedder startup stages per source method, including ownership, stage ordering, completeness, and missing stepsengine_symbol_ingestionfor auto-loaded local engine symbol cache matches keyed bylibflutter.sobuild idbootflow_discoveryentries tagged bysource(android_manifest,apk_startup,model_name_pattern) and byprovenance(derivedorheuristic, never exact)
The goal of these examples is simple: show original public source first, then show what flutterdec recovers from the shipped APK.
Original 1: Android Startup Surface
Original 2: App-Side Flutter / Dart Surface
Compare 1: Startup Source -> Recovered Startup Path
Source app: hiVPN v1.0.0 released on October 29, 2025. MainActivity and the manifest launcher are public in the repository:
- Source repo: https://github.com/Mr-Dark-debug/hivpn
- Release APK: https://github.com/Mr-Dark-debug/hivpn/releases/tag/release
Original
The app enters Flutter from MainActivity.onCreate. The second source card shows the app-side Flutter bridge that exposes MethodChannel('com.example.vpn/VpnChannel') to Dart code.
Recovered 1: APK Startup Report
Recovered
flutterdec parsed the APK manifest, recovered com.example.hivpn.MainActivity as the launcher, and correlated the startup chain from MainActivity.onCreate into Flutter JNI bootstrap calls such as attachToNative and nativeAttach.
Compare 2: App Source Using Flutter APIs -> Recovered Named Selectors
Source app: ZedSecure v1.2.0
- Source repo: https://github.com/CluvexStudio/ZedSecure
- Release APK: https://github.com/CluvexStudio/ZedSecure/releases/tag/v1.2.0
This is the comparison you asked for: public app source that uses Flutter APIs, then recovered APK artifacts where flutterdec keeps recognizable Dart and Flutter selector names instead of only anonymous control flow.
Original Source
This source is ordinary app UI code. It builds a ping badge with BoxConstraints(minWidth: 50) and other Flutter widget APIs inside the shipped app.
Recovered 2: From The ZedSecure APK With flutterdec
↓ ARM64
At the machine-code layer, the APK still looks like indirect selector dispatch through pool-loaded metadata and call targets.
↓ Function IR
The IR stage makes the selector-bearing pool values explicit before readability passes.
↓ Pseudocode
The important part is not the anonymous function name. The important part is that flutterdec surfaced readable Flutter selector names from the AOT payload, including dispatch.minWidth(...), dispatch.messageMap(...), and the framework-side flutter.foundation.invoke(...).
This capture is from a target whose adapter recovered class and selector metadata. Selector naming is gated on that metadata: on a target where the adapter recovers only strings, the same run emits no selector names at all (measured zero on two release APKs). Function names, control flow, and expressions do not depend on it.
This gives the README both views the tool is meant to show publicly:
- Android startup recovery from the APK surface
- app-side Flutter selector recovery from the AOT payload
What this proves
flutterdeccan preserve recognizable Flutter and Dart utility selectors inside app-owned recovered code- selector-bearing pool metadata survives from asm to IR to pseudocode
- the pipeline is inspectable at every stage: asm, IR, and pseudocode
Case 3: Original Release A -> Original Release B -> Recovered Diff
Source app: LocalSend
v1.16.1released on November 5, 2024v1.17.0released on February 20, 2025- Releases: https://github.com/localsend/localsend/releases
Original
Two public release APKs from the same app line.
Recovered
flutterdec diff compared the two arm64 APKs directly and emitted added, removed, and common function summaries plus package-level change counts. This is useful when you care more about what changed between versions than about reconstructing a single function in isolation.
Recover readable behavior from Flutter AOT ARM64 binaries with enough semantic structure that reverse engineering decisions can be made from pseudocode and reports.
- robust semantic extraction from snapshots and metadata such as libraries, classes, functions, selectors, and pool semantics
- stable reverse-engineering-oriented pseudocode for Android ARM64 release builds
- version-aware adapter behavior that can be updated without rewriting the core and decompiler logic
- perfect reconstruction of the original Dart source
- broad multi-arch support at the same maturity level such as x86, iOS, or JIT modes
- dynamic runtime emulation as the default analysis path
- User guide: docs/user-guide.md
- CLI reference: docs/cli-reference.md
- Development guide: docs/development.md
- Architecture: docs/architecture.md
- Internals walkthrough: docs/how-it-works.md
- Research decisions: docs/research-decisions.md
- Contributing: CONTRIBUTING.md
- Context and project history: context.md
data/dart-profiles.json: Dart AOT snapshot layout profiles imported from radareorg/r2flutter (MIT). Rationale in docs/research-decisions.md.--adapter-backend r2-flutterdrives the same project as an external tool; it is not bundled or linked.--adapter-backend blutterdrives worawit/blutter as an external tool.
- Bug report: new bug issue
- Feature request: new feature issue
- Research finding: new research issue
