Skip to content

Latest commit

 

History

History
473 lines (338 loc) · 15.8 KB

user-guide.md

File metadata and controls

473 lines (338 loc) · 15.8 KB

User Guide

Scope

Current supported target scope:

  • Flutter AOT Android ARM64 binaries
  • static analysis only

Inputs:

  • APK
  • libapp.so

Install And Run

If you want the shortest path:

  • run without installing: nix run
  • install persistently with Nix: nix profile install
  • install a release binary

Run Without Installing

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 ./out

From a local checkout:

nix run . -- --help
nix run . -- info ./sample.apk --json
nix run . -- decompile ./sample.apk -o ./out

Install Persistently With Nix

Install from GitHub:

nix profile install github:caverav/flutterdec
flutterdec --help

Install from a local checkout:

nix profile install .
flutterdec --help

Update later:

nix profile upgrade flutterdec

Install From A Release Binary

Current 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 --help

macOS 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 --help

Other platforms and future tags:

Releases page

Other Ways To Run It

Install into the user Cargo bin:

nix develop -c cargo install --path crates/flutterdec-cli
~/.cargo/bin/flutterdec --help

Run 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 ./out

Build a local release binary:

nix develop -c cargo build -p flutterdec-cli --release
./target/release/flutterdec --help

Where Adapters Live

flutterdec uses two directories, and neither one depends on your current directory.

Read-only package data holds the compatibility registry (adapters/registry.json), the runtime profiles (data/*.json), and the checked-in reference producer. It is resolved from the location of the binary itself, first match wins:

  1. FLUTTERDEC_DATA_DIR, if set. An explicit override never silently falls back: if it holds no adapters/registry.json, the command fails and says so.
  2. <binary>/../share/flutterdec, which is where the Nix package and any normal prefix install put it.
  3. <binary>, for a flat unpacked distribution.
  4. <binary>/../.., which is where cargo build leaves a binary inside a checkout.

The writable adapter store holds installed adapters and the store's own state file. It is:

  1. FLUTTERDEC_ADAPTER_STORE, if set.
  2. otherwise $XDG_DATA_HOME/flutterdec/adapters, or $HOME/.local/share/flutterdec/adapters when XDG_DATA_HOME is unset.

The local symbol cache used by map-symbols --register-local-cache follows the same rule under FLUTTERDEC_SYMBOL_CACHE or <data home>/flutterdec/symbols.

So a release binary works from any directory, installing an adapter never dirties a checkout, and the same install is visible to info, decompile, diff and adapter list in the same environment. If you want a throwaway store, point FLUTTERDEC_ADAPTER_STORE at a temporary directory.

Install once per snapshot hash, from anywhere:

flutterdec info ./sample.apk --json      # read snapshot_hash
flutterdec adapter install --dart-hash <HASH>
flutterdec adapter list

The compatibility registry is the only install authority. adapter install refuses a hash it has no record for, a record that does not serve this host, and any artifact whose bytes do not match the digest and size the record declares. It publishes one file into the store atomically, is safe to run concurrently, and reports already-installed when the store already holds exactly that install. A failed install leaves nothing behind.

adapter list reports a verified state per record rather than whether a file exists:

state meaning
verified installed, present, and byte-for-byte what the record declares
missing the store records the install but the artifact file is gone
corrupt the artifact is present but is not what the record declares
incompatible no artifact variant, or no supported protocol/model major, for this host
unavailable authorized by a record, not installed

adapter list exits 2 when any entry is missing or corrupt, so a broken store is an error rather than a line of output. Both commands take --json.

First Use

If this is your first run, this is the shortest useful path.

  1. Inspect the target:
flutterdec info ./sample.apk --json

For APK inputs, info also reports Android startup summary fields:

  • android_startup_present
  • android_startup_confidence
  • android_startup_entrypoint_count
  • android_startup_flutter_activity_count

If adapter metadata is available, info also reports:

  • app_package_counts_top
  • requested_backend, resolved_backend, backend_fallback_reason
  • producer_id, producer_trust, compatibility_record_sha256
  • dart_aliases (provenance labels, not selectors)
  • registry_record_present, snapshot_identity_is_exact, identity_rejection
  • model_capabilities (per-domain complete / partial / unavailable)
  • compatibility_warnings
  • adapter_containment (which execution controls were established for the adapter process, each applied with its bound or unavailable with the reason)
  1. Install the adapter for the detected Dart hash:
flutterdec adapter install --dart-hash <HASH>
flutterdec adapter list
  1. Decompile:
flutterdec decompile ./sample.apk -o ./out
  1. Start with:
  • out/pseudocode/*.dartpseudo
  • out/report.json
  • out/quality.json

Basic Commands

Inspect target metadata:

flutterdec info ./sample.apk --json

Decompile with the default app-focused scope:

flutterdec decompile ./sample.apk -o ./out

Decompile with extra artifacts:

flutterdec decompile ./sample.apk -o ./out --emit-asm --emit-ir

Compare two builds at recovered-function level:

flutterdec diff --old ./old.apk --new ./new.apk -o ./out-diff --json

Generate a Ghidra import script with recovered symbols:

flutterdec decompile ./sample.apk -o ./out --emit-ghidra-script

Generate an IDA import script with recovered symbols:

flutterdec decompile ./sample.apk -o ./out --emit-ida-script

Function Scope

Default scope is app-focused:

  • app-unknown (default): app (package:*) plus unknown ownership functions
  • app: only app (package:*) functions
  • all: include Flutter, Dart runtime, and framework internals too

Include everything:

flutterdec decompile ./sample.apk -o ./out --function-scope all

Limit output to selected app packages:

flutterdec decompile ./sample.apk -o ./out \
  --function-scope app-unknown \
  --app-package my_app

Tip: if you are not sure about package names, check report.json under function_scope.app_package_counts_top.

Single-Function Targeting

Decompile and disassemble one specific function:

flutterdec decompile ./sample.apk -o ./out \
  --target id:42 \
  --emit-asm

Target by entry address:

flutterdec decompile ./sample.apk -o ./out \
  --target va:0x613468 \
  --emit-asm

--target accepts id:<N>, va:0x<ADDR>, 0x<ADDR>, or <N>. When <N> matches multiple functions, the command fails and asks for explicit id: or va:. Target mode emits only the matched function artifacts and reports selection details in report.json.target_selection. If the match is outside current scope filters, target mode can override scope automatically for that function.

Analysis Engine Profiles

decompile profile controls analysis depth vs throughput.

Profile options:

  • balanced (default): best readability and semantic recovery
  • light: reduced analysis for faster large-scale runs

Examples:

flutterdec decompile ./sample.apk -o ./out --analysis-profile balanced
flutterdec decompile ./sample.apk -o ./out --analysis-profile light

Adapter backend options:

  • --adapter-backend auto (default): try r2flutter, then the Blutter bridge, then the internal adapter
  • --adapter-backend internal: force internal adapter only
  • --adapter-backend blutter: require Blutter bridge backend with no fallback
  • --adapter-backend r2-flutter: require the r2flutter backend with no fallback
  • --require-snapshot-hash-match: fail unless the snapshot identity came from a real header

Requested and resolved backend are separate facts. auto may fall back, and the result says which backend answered and why it differed; a named backend either runs or fails, never silently substituted. Both appear in info output and in report.json.adapter_selection.

An adapter runs as a bounded one-shot job: a private working directory with read-only inputs, an allowlisted environment, an overall deadline, capped output, its own process group, and CPU, file size, address space, process count and descriptor limits, plus network isolation where the host permits it. adapter_containment in info --json and adapter_selection.containment in report.json say which of those were actually established; a control the host could not establish is reported unavailable with a reason rather than claimed.

Backend choice decides how much is actually recovered. The internal adapter carves strings and scans prologues: it recovers code ranges with no names at all, and its object_pool is a list of carved strings in an ordinal index space, not real pool slots. Its model_capabilities say so: function_names: unavailable, pool_index_space: unavailable. r2flutter and blutter parse the snapshot, so they return real Dart names, and r2flutter can return a hardware ObjectPool.

Only a hardware pool index space lets flutterdec turn a pool[N] reference in the disassembly into a value. With an ordinal pool, references are left unresolved on purpose, and report.json.pool_metadata.hints_suppressed_reason explains why. Check pool_metadata.index_space_authoritative if pseudocode has fewer string literals than you expected.

r2flutter backend environment variables:

  • FLUTTERDEC_R2FLUTTER_BIN: path to the r2flutter binary
  • FLUTTERDEC_R2FLUTTER_CMD: full command to launch it, when a wrapper is needed
  • FLUTTERDEC_R2FLUTTER_TIMEOUT: per-invocation timeout in seconds (default 900)
  • otherwise r2flutter is resolved from PATH

Blutter bridge environment variables:

  • FLUTTERDEC_BLUTTER_CMD: full command used to launch Blutter, for example python3 /opt/blutter/blutter.py
  • FLUTTERDEC_BLUTTER_PY: direct path to blutter.py

Nix setup note:

  • in nix develop, FLUTTERDEC_BLUTTER_CMD is exported automatically to a Nix-managed flutterdec-blutter wrapper
  • direct wrapper invocation is available with nix run .#blutter-bridge -- --help

Per-feature 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

Quality Gates

Decompile quality checks are controlled by:

  • --max-placeholder-ifs
  • --max-unresolved-cf
  • --max-indirect-call-ratio
  • --min-disassembly-ratio

Example for exploratory runs:

flutterdec decompile ./sample.apk -o ./out \
  --max-functions 250 \
  --max-placeholder-ifs 999999 \
  --max-unresolved-cf 999999 \
  --max-indirect-call-ratio 1.0 \
  --min-disassembly-ratio 0.0

Name Recovery With Engine Symbols

Generate direct-call target mapping from a stripped/unstripped engine pair:

flutterdec map-symbols \
  --stripped ./libflutter.stripped.so \
  --unstripped ./libflutter.unstripped.so \
  -o ./out/symbol-map \
  --register-local-cache

Use the cached mapping in later APK decompile runs:

flutterdec decompile ./sample.apk -o ./out \
  --extra-symbol-elf ./libflutter.unstripped.so

When the cached build id matches the APK's embedded libflutter.so, decompile auto-loads the registered target summary and reports the match under report.json.engine_symbol_ingestion.

Outputs

Main output artifacts:

  • pseudocode/*.dartpseudo
  • report.json
  • quality.json
  • asm/*.s if --emit-asm
  • ir/*.json if --emit-ir
  • ghidra_apply_symbols.py if --emit-ghidra-script
  • ida_apply_symbols.py if --emit-ida-script
  • diff_report.json if you run flutterdec diff

Most users should start by reading:

  • pseudocode/*.dartpseudo
  • report.json.android_startup
  • quality.json

Reading Value Annotations In The Pseudocode

A register whose binding the decompiler could not carry through a control-flow merge renders as a bare regN. Where the value it held is known, flutterdec appends it as a comment rather than substituting it, so the comment never changes what the code says. Four forms appear, and the distinction between the first two is the one worth learning:

form meaning
regN /* = a | b */ exhaustive. Every path reaching this merge contributed a value, and they are all listed. regN is one of these.
regN /* possible (non-exhaustive): a | b */ incomplete. At least one incoming path contributed no usable value, so the real value may be something not listed.
regN /* loop-entry value: a | b */ the value held on entry to the loop, one per entry arm - several when the loop has more than one entry arm and they disagree. The value on the back edge is never shown, and is usually different.
regN /* value before this call: a */ the value held immediately before the adjacent call, which clobbered the register. Not the value after it. Always a single value.

Expect the incomplete form to dominate, and expect the call form to be rare. Measured on LocalSend 1.17 at default scope: 917 non-exhaustive, 246 exhaustive, 148 loop-entry, 3 pre-call. Values are listed in ascending predecessor order, so the order is stable across runs.

Two properties are worth relying on:

  • The annotations are comments and nothing else. Strip every one and you get byte-identical output to a build without them. They do not participate in naming, aliasing or type inference, so they cannot make the surrounding code wrong.
  • A listed value was actually observed on the path it is attributed to, not guessed from context. Values the analysis could not attribute are omitted rather than approximated, which is why the non-exhaustive form exists and is the more common of the two.

Absence of an annotation means the value was not recovered, not that the register is unimportant. Most bare regN occurrences carry no annotation: see docs/research-pseudocode-quality.md R29 for the measured coverage per site and its limits.