diff --git a/Cargo.lock b/Cargo.lock index 049dde7..0a685b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -357,6 +357,8 @@ dependencies = [ "anyhow", "goblin", "regex", + "serde", + "serde_json", "tempfile", "zip", ] diff --git a/README.md b/README.md index f99fe28..23c6293 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,14 @@ If this is your first run, this is the shortest useful path. flutterdec info ./sample.apk --json ``` +`info` resolves the Dart SDK version straight from the snapshot hash, with no adapter +installed and no disassembly: + +- `dart_version` (for example `3.9.2`) +- `dart_tag_style` (`CID_INT32`, `CID_SHIFT1`, or `OBJECT_HEADER`) + +Both are `null` for snapshot hashes not in the bundled table (`data/dart-profiles.json`). + For APK inputs, `info` reports Android startup summary fields such as: - `android_startup_present` @@ -328,11 +336,34 @@ flutterdec decompile ./sample.apk -o ./out --analysis-profile light Adapter backend selection: -- `--adapter-backend auto` (default): try Blutter backend if configured, otherwise fall back to the internal adapter +- `--adapter-backend auto` (default): try r2flutter, then Blutter, then fall back to the internal adapter - `--adapter-backend internal`: force the internal adapter only - `--adapter-backend blutter`: require the Blutter backend and fail if unavailable +- `--adapter-backend r2-flutter`: require the r2flutter backend and fail if unavailable - `--require-snapshot-hash-match`: fail when the adapter-reported snapshot hash does not match the loader snapshot hash +What the backends actually recover: + +| Backend | Function names | Classes | ObjectPool | +| --- | --- | --- | --- | +| `internal` | none (`sub_` placeholders) | none | carved strings, no real index space | +| `blutter` | exact, from Blutter dumps | yes | Blutter `pp.txt` entries | +| `r2flutter` | exact, from the AOT instruction table | yes, with fields and methods | real slots, resolvable from `x27` displacements | + +Only backends that recover the real `ObjectPool` layout report `pool_geometry`. Without +it `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 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 taken from `PATH` + +`r2flutter` is an external MIT tool ([radareorg/r2flutter](https://github.com/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 example `python3 /path/to/blutter.py` @@ -515,6 +546,16 @@ Recover readable behavior from Flutter AOT ARM64 binaries with enough semantic s - Contributing: [CONTRIBUTING.md](CONTRIBUTING.md) - Context and project history: [context.md](context.md) +## Third-Party Credits + +- `data/dart-profiles.json`: Dart snapshot hash-to-version and layout table imported from + [radareorg/r2flutter](https://github.com/radareorg/r2flutter) (MIT). Rationale in + [docs/research-decisions.md](docs/research-decisions.md). +- `--adapter-backend r2-flutter` drives the same project as an external tool; it is not + bundled or linked. +- `--adapter-backend blutter` drives [worawit/blutter](https://github.com/worawit/blutter) + as an external tool. + ## Issue Types - Bug report: [new bug issue](https://github.com/caverav/flutterdec/issues/new?template=bug_report.md) diff --git a/adapters/python/adapter_template.py b/adapters/python/adapter_template.py index 5a5d88f..cd20620 100644 --- a/adapters/python/adapter_template.py +++ b/adapters/python/adapter_template.py @@ -698,13 +698,264 @@ def _build_blutter_model( } +def _resolve_r2flutter_runner() -> Optional[List[str]]: + env_cmd = os.getenv("FLUTTERDEC_R2FLUTTER_CMD", "").strip() + if env_cmd: + return shlex.split(env_cmd) + env_bin = os.getenv("FLUTTERDEC_R2FLUTTER_BIN", "").strip() + if env_bin: + return [env_bin] + found = shutil.which("r2flutter") + if found: + return [found] + return None + + +def _r2flutter_timeout() -> int: + raw = os.getenv("FLUTTERDEC_R2FLUTTER_TIMEOUT", "").strip() + try: + return max(1, int(raw)) if raw else 900 + except ValueError: + return 900 + + +def _r2flutter_json(runner: List[str], target: str, flag: str): + """Run one r2flutter action and parse its JSON. + + r2flutter emits one action per invocation and writes radare2 loader warnings to + stderr, so stdout is parsed on its own. Six invocations happen per model build and + each one loads the whole binary, so a wedged radare2 would otherwise hang the + adapter, and the core waiting on it, indefinitely. + """ + try: + proc = subprocess.run( + [*runner, flag, target], + capture_output=True, + text=True, + shell=False, + check=False, + timeout=_r2flutter_timeout(), + ) + except OSError as exc: + raise RuntimeError(f"could not launch r2flutter ({' '.join(runner)}): {exc}") from exc + except subprocess.TimeoutExpired as exc: + raise RuntimeError(f"r2flutter {flag} timed out after {exc.timeout}s") from exc + if proc.returncode != 0: + raise RuntimeError( + f"r2flutter {flag} failed ({proc.returncode}): {proc.stderr.strip()[:400]}" + ) + try: + return json.loads(proc.stdout) + except json.JSONDecodeError as exc: + raise RuntimeError(f"r2flutter {flag} did not emit JSON: {exc}") from exc + + +_R2F_POOL_ENTRY_RE = re.compile(r"\bentry=(\d+)\b") +_R2F_IT_METHOD_RE = re.compile(r"^method\.(?:(?P.+)\.)?(?P[^.]+)$") + + +def _r2flutter_functions(instruction_table: dict, isolate_instr_va: int) -> List[dict]: + """Map the AOT instruction table onto ProgramModel functions. + + Entry addresses and names come straight out of the snapshot, so every name is + `exact`. Sizes are not serialized; the gap to the next entry is the usual + approximation and is what the disassembler needs. + """ + entries = sorted( + (e for e in instruction_table.get("entries", []) if e.get("address")), + key=lambda e: e["address"], + ) + out: List[dict] = [] + for i, e in enumerate(entries): + start = int(e["address"]) + nxt = int(entries[i + 1]["address"]) if i + 1 < len(entries) else start + 0x40 + raw_name = (e.get("name") or "").strip() or f"sub_{start:x}" + owner = "Global" + m = _R2F_IT_METHOD_RE.match(raw_name) + if m and m.group("owner"): + owner = m.group("owner") + out.append( + { + "id": i, + "name": raw_name, + "owner_class": owner, + "entry_va": start, + "size": max(4, min(nxt - start, 0x8000)), + "code_section_va": int(isolate_instr_va or 0), + "name_kind": "exact", + } + ) + return out + + +def _r2flutter_pool(strings: List[dict]) -> List[dict]: + """Build ObjectPool entries keyed by the real entry index. + + r2flutter reports, per string, the pool slots that reference it as + `pool= index= entry= pp_off=`. `entry` is the authoritative + index a `ldr xN, [x27, #pp_off]` resolves to, which is exactly the key the + decompiler joins on. Nothing here is positional or guessed. + """ + out: List[dict] = [] + for s in strings: + value = s.get("value") + if not isinstance(value, str) or not value: + continue + selector = _selector_from_string(value) + library_uri = _normalize_library_uri(value) + if library_uri is not None: + decoded_kind = "LibraryUri" + elif selector is not None: + decoded_kind = "SelectorString" + else: + decoded_kind = "String" + for ref in s.get("refs", []): + if ref.get("kind") != "object_pool.entry": + continue + m = _R2F_POOL_ENTRY_RE.search(ref.get("name") or "") + if not m: + continue + out.append( + { + "index": int(m.group(1)), + "kind": "TwoByteString" if s.get("two_byte") else "OneByteString", + "value": value, + "decoded_kind": decoded_kind, + "selector": selector, + "target_va": None, + "owner_class": None, + "library_uri": library_uri, + "confidence": 1.0, + "source": "vm", + } + ) + out.sort(key=lambda e: e["index"]) + return out + + +def _r2flutter_classes(classes: List[dict]) -> List[dict]: + """Project r2flutter classes onto ProgramModel classes. + + r2flutter does not attribute classes to libraries, so library URIs are recovered + from pool strings instead and classes stay library-less rather than being given + an invented owner. + """ + out: List[dict] = [] + for i, c in enumerate(classes): + name = (c.get("name") or "").strip() + if not name: + continue + out.append( + { + "id": i, + "name": name, + "super": (c.get("super") or "Object"), + "lib": "", + } + ) + return out + + +def _build_r2flutter_model(default_snapshot_hash: str, default_version: str, + input_path: Optional[str], libapp_path: Optional[str], + isolate_instr_va: int) -> dict: + runner = _resolve_r2flutter_runner() + if runner is None: + raise RuntimeError( + "r2flutter not found; set FLUTTERDEC_R2FLUTTER_CMD or FLUTTERDEC_R2FLUTTER_BIN, " + "or put r2flutter on PATH" + ) + target = libapp_path or input_path + if not target: + raise RuntimeError("r2flutter backend needs --libapp-path or --input-path") + + header = _r2flutter_json(runner, target, "-jH") + instruction_table = _r2flutter_json(runner, target, "-ji") + functions = _r2flutter_functions(instruction_table, isolate_instr_va) + if not functions: + raise RuntimeError("r2flutter recovered no instruction-table entries") + + # `-jxz` is the reliable pool-referenced string set with its slot back-references; + # that is what the pool index space needs. + pool_strings = _r2flutter_json(runner, target, "-jxz") + object_pool = _r2flutter_pool(pool_strings) + classes = _r2flutter_classes(_r2flutter_json(runner, target, "-jc")) + + # Library URIs mostly live in the data image rather than the pool, so the wider + # carved set is the only place to find them. They drive `--function-scope` and + # package prioritisation, not naming, so the looser extraction is acceptable here. + try: + all_strings = _r2flutter_json(runner, target, "-jzz") + except RuntimeError: + all_strings = pool_strings + libs = _collect_libraries( + [s.get("value", "") for s in all_strings if isinstance(s.get("value"), str)] + ) + libraries = [{"id": i, "uri": lib, "name_display": lib} for i, lib in enumerate(libs)] + if not classes: + classes = [{"id": 0, "name": "Global", "super": "Object", "lib": libs[0]}] + + # Only claim an authoritative pool index space when the ObjectPool image was + # actually reconstructed. r2flutter reports `error` for snapshots whose pool fill + # payload it cannot decode, and a guessed geometry there would silently + # mis-resolve every pool reference. + pool_geometry = None + try: + pp = _r2flutter_json(runner, target, "-jp") + if isinstance(pp, dict) and "entries_offset" in pp and "word_size" in pp: + pool_geometry = { + "entries_offset": int(pp["entries_offset"]), + "word_size": int(pp["word_size"]), + } + except RuntimeError: + pool_geometry = None + if pool_geometry is None: + object_pool = [] + + model = { + "schema_version": 3, + "adapter_kind": "r2flutter_snapshot_v1", + "dart_version": header.get("dart_version") or default_version, + "snapshot_hash": header.get("hash") or default_snapshot_hash, + "arch": "arm64", + "libraries": libraries, + "classes": classes, + "functions": functions, + "object_pool": object_pool, + } + # The schema types `pool_geometry` as an object, so omit the key rather than + # emitting null: absence is how an adapter declines to claim a real index space. + if pool_geometry is not None: + model["pool_geometry"] = pool_geometry + return model + + def _normalize_backend(raw: str) -> str: t = (raw or "auto").strip().lower() - if t in ("auto", "internal", "blutter"): + if t in ("auto", "internal", "blutter", "r2flutter"): return t return "auto" +def _drop_nulls(value): + """Strip keys whose value is null, recursively. + + Optional model fields are typed concretely in schemas/adapter.schema.json, so an + explicit null fails validation where an absent key passes. The Rust side treats + both as `None`, so omitting is free and makes the schema mean something. + """ + if isinstance(value, dict): + return {k: _drop_nulls(v) for k, v in value.items() if v is not None} + if isinstance(value, list): + return [_drop_nulls(v) for v in value] + return value + + +def _write_model(path: str, payload: dict) -> None: + with open(path, "w", encoding="utf-8") as f: + json.dump(_drop_nulls(payload), f, indent=2) + + def entrypoint(default_snapshot_hash: str = "unknown", default_version: str = "unknown") -> int: p = argparse.ArgumentParser() p.add_argument("--vm-data", required=True) @@ -723,6 +974,27 @@ def entrypoint(default_snapshot_hash: str = "unknown", default_version: str = "u iso_instr = _read_bytes(args.isolate_instr) backend = _normalize_backend(os.getenv("FLUTTERDEC_ADAPTER_BACKEND", "auto")) + + # `auto` prefers r2flutter: it deserializes the snapshot, so it is the only + # backend that yields exact names plus a real ObjectPool index space. Each + # backend falls through to the next when its tooling is absent. + if backend in ("auto", "r2flutter"): + try: + payload = _build_r2flutter_model( + default_snapshot_hash, + default_version, + args.input_path, + args.libapp_path, + args.isolate_instr_va, + ) + _write_model(args.out, payload) + return 0 + except Exception as exc: + if backend == "r2flutter": + print(f"[adapter] r2flutter backend required but failed: {exc}", file=sys.stderr) + return 1 + print(f"[adapter] r2flutter backend unavailable: {exc}", file=sys.stderr) + if backend in ("auto", "blutter"): try: payload = _build_blutter_model( @@ -733,8 +1005,7 @@ def entrypoint(default_snapshot_hash: str = "unknown", default_version: str = "u args.input_path, args.libapp_path, ) - with open(args.out, "w", encoding="utf-8") as f: - json.dump(payload, f, indent=2) + _write_model(args.out, payload) return 0 except Exception as exc: if backend == "blutter": @@ -771,11 +1042,13 @@ def entrypoint(default_snapshot_hash: str = "unknown", default_version: str = "u "libraries": [{"id": i, "uri": lib, "name_display": lib} for i, lib in enumerate(libs)], "classes": [{"id": 0, "name": "Global", "super": "Object", "lib": libs[0]}], "functions": funcs, + # Carved strings, indexed by carve order. This is NOT the ObjectPool index + # space, which is why `pool_geometry` is omitted entirely: the core then + # declines to resolve `pool[N]` rather than attaching an unrelated string. "object_pool": _pool_entries(strings), } - with open(args.out, "w", encoding="utf-8") as f: - json.dump(payload, f, indent=2) + _write_model(args.out, payload) return 0 diff --git a/context.md b/context.md index 2d68a75..fe6ffda 100644 --- a/context.md +++ b/context.md @@ -97,10 +97,25 @@ The decompiler expects a normalized model from the adapter layer. That model inc - functions and entry addresses - classes and library metadata when available - object pool entries +- `pool_geometry`, when the adapter recovered the real `ObjectPool` layout - architecture and snapshot metadata This keeps the rest of the system independent from any single parser implementation. +### The pool index space is part of the contract + +`object_pool[].index` means one thing: the entry index a `ldr xN, [x27, #disp]` +resolves to. An adapter claims that meaning by emitting `pool_geometry` +(`entries_offset`, `word_size`), and core converts displacements with +`index = (disp - entries_offset) / word_size`. + +An adapter that cannot recover the real pool must omit `pool_geometry`, and core then +refuses to resolve pool references at all. This is deliberate: the failure mode of +joining two unrelated index spaces is not a missing value, it is a *plausible wrong* +value: a real string from the binary, attached to a slot that never referenced it, +rendered in pseudocode with no marker distinguishing it from a correct one. For a +reverse-engineering tool that is worse than saying nothing. + ## Output philosophy The target output is pseudo Dart that helps humans understand behavior quickly. It is not intended to compile back into the original program. @@ -162,6 +177,30 @@ Current scope: - IR and pseudo Dart generation with iterative readability passes - readability passes now prune dead statements after terminal control flow and unwrap non-retry `while (true)` wrappers when the body already terminates - optional stripped vs unstripped ELF symbol mapping to recover readable direct-call targets +- object-pool references are now resolved in the pool's own index space: the disassembler + converts a PP-relative displacement with `(disp - entries_offset) / word_size` and emits + `pool[]`, and it now also recognises the page form (`add xD, x27, #K, lsl #S` + followed by `ldr xN, [xD, #off]`), which on a real Dart 3.9.2 sample is 13903 of the + 19604 pool loads in the sampled functions and was previously not annotated at all +- before this, the direct form was annotated with the raw displacement and the page form + was reconstructed textually as `displacement / 8`; both were joined against adapter pool + indices, so pool-backed string literals in pseudocode could be silently wrong. Measured + example: `ldr x1, [x27, #0xef8]` was rendered as `"_workoutWorkoutDeserialize"` when slot + 477 actually holds a `type_arguments` object, and `pp+0x23a90` resolved two slots late to + `...WebChromeClient.onShowFileChooser` instead of `...onProgressChanged` +- pool value/semantic hints are now gated on `pool_geometry`, so an adapter without a real + pool (the internal one) produces no pool literals instead of plausible wrong ones; + `report.json.pool_metadata` reports `index_space_authoritative`, the geometry, and + `hints_suppressed_reason` +- known follow-up: the README pipeline showcase assets (`docs/assets/readme/zedsecure-*`) + were produced before this fix and show pool mappings from the old index space; they need + regenerating against the ZedSecure APK before they can be trusted as evidence +- artifact file names cap the sanitized function-name stem at 160 bytes; recovered Dart + names reach 305 bytes on real binaries and previously aborted the whole run with + `File name too long (os error 36)` as soon as an adapter returned real names +- `info` and `report.json` now name the Dart SDK version and object-header tag style + straight from the snapshot hash, with no adapter installed, using the vendored + `data/dart-profiles.json` (61 hashes, 19 layout profiles, imported from r2flutter, MIT) - decompile can now ingest `map-symbols` target JSON directly to inject mapped call names into pseudocode - external symbol names are normalized (including C++ demangle and runtime/native prefixes) before pseudocode emission - pseudocode call sites now include semantic intent comments for recognized stdlib/runtime/native targets @@ -185,8 +224,9 @@ Current scope: - repeated pool-mapped selector literals now hoist into local `String` aliases (for example `poolStr42`) so repeated callsites stay compact and readable - adapter object-pool metadata fields (`decoded_kind`, `selector`, `target_va`, `owner_class`, `library_uri`) are now consumed by decompile for deterministic owner-qualified selector rewrites - adapter model contract now accepts schema versions `2` and `3`; v3 adds optional per-function `name_kind` and optional object-pool provenance fields (`confidence`, `source`) while preserving v2 compatibility defaults -- adapter execution now supports backend selection (`auto`, `internal`, `blutter`) so deterministic parser backends can be introduced without changing decompiler core contracts -- default adapter backend mode is `auto`: it attempts Blutter bridge parsing when configured (`FLUTTERDEC_BLUTTER_CMD` or `FLUTTERDEC_BLUTTER_PY`) and falls back to internal parsing for resilience +- adapter execution now supports backend selection (`auto`, `internal`, `blutter`, `r2flutter`) so deterministic parser backends can be introduced without changing decompiler core contracts +- default adapter backend mode is `auto`: it tries r2flutter, then the Blutter bridge when configured (`FLUTTERDEC_BLUTTER_CMD` or `FLUTTERDEC_BLUTTER_PY`), and falls back to internal parsing for resilience +- r2flutter backend (`--adapter-backend r2-flutter`, `FLUTTERDEC_R2FLUTTER_BIN`/`FLUTTERDEC_R2FLUTTER_CMD`) shells out to the MIT tool [radareorg/r2flutter](https://github.com/radareorg/r2flutter) and maps `-ji` (AOT instruction table), `-jc` (classes), `-jxz` (pool-referenced strings with their slot indices), `-jzz` (library URIs), and `-jp` (pool geometry) onto `ProgramModel`; on a Dart 3.9.2 sample it returns 37258 exactly-named functions and 8986 classes where the internal adapter returns 7458 `sub_*` placeholders and 1 synthetic class - Blutter bridge parsing currently normalizes `asm/*.dart` and `pp.txt` output into `ProgramModel` (`libraries`, `classes`, `functions`, and best-effort `object_pool` target metadata), synthesizes deterministic `EntryPointCandidate` pool entries for `main`/`runApp`-like functions when present, and serializes blutter invocations with a cache lock to avoid concurrent runner races - owner-only metadata (selector + owner_class without library URI) can still rewrite indirect selector calls to deterministic owner-qualified call paths - if pool entries miss selector/owner/library metadata, core now backfills semantic hints from function ownership metadata keyed by `target_va` diff --git a/crates/flutterdec-adapter/src/lib.rs b/crates/flutterdec-adapter/src/lib.rs index 76f1e63..6b7d492 100644 --- a/crates/flutterdec-adapter/src/lib.rs +++ b/crates/flutterdec-adapter/src/lib.rs @@ -57,6 +57,38 @@ pub struct ObjectPoolEntry { pub source: Option, } +/// Hardware layout of the Dart `ObjectPool` object that `x27`/PP points at. +/// +/// Presence of this record is the adapter's assertion that `ObjectPoolEntry::index` +/// values live in the *hardware* index space, i.e. that a `ldr xN, [x27, #disp]` +/// resolves to `(disp - entries_offset) / word_size`. Adapters that only carve +/// strings out of the snapshot must leave it unset; without it the core refuses to +/// map pool references onto values instead of guessing. +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub struct PoolGeometry { + /// Byte offset of entry 0 from the PP base (0x10 on ARM64 AOT). + pub entries_offset: u64, + /// Stride between entries in bytes (8 on ARM64 AOT, even with compressed pointers). + pub word_size: u64, +} + +impl PoolGeometry { + /// Convert a PP-relative byte displacement into a pool entry index. + /// + /// Returns `None` for displacements below the first entry or not on a stride + /// boundary; those are pool-object header accesses, not entry loads. + pub fn index_for_displacement(&self, displacement: u64) -> Option { + if self.word_size == 0 { + return None; + } + let rel = displacement.checked_sub(self.entries_offset)?; + if !rel.is_multiple_of(self.word_size) { + return None; + } + Some(rel / self.word_size) + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProgramModel { pub schema_version: u32, @@ -68,6 +100,9 @@ pub struct ProgramModel { pub classes: Vec, pub functions: Vec, pub object_pool: Vec, + /// Set only by adapters that recover the real `ObjectPool`; see [`PoolGeometry`]. + #[serde(default)] + pub pool_geometry: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] diff --git a/crates/flutterdec-cli/src/main.rs b/crates/flutterdec-cli/src/main.rs index a8f906f..ffe2c95 100644 --- a/crates/flutterdec-cli/src/main.rs +++ b/crates/flutterdec-cli/src/main.rs @@ -165,6 +165,10 @@ enum AdapterBackendArg { Auto, Internal, Blutter, + /// Spelled `r2-flutter` by clap's derive; accept the tool's own spelling too, since + /// that is what the docs, `report.json` and the env vars all call it. + #[value(alias = "r2flutter")] + R2Flutter, } impl AdapterBackendArg { @@ -173,6 +177,7 @@ impl AdapterBackendArg { Self::Auto => AdapterBackend::Auto, Self::Internal => AdapterBackend::Internal, Self::Blutter => AdapterBackend::Blutter, + Self::R2Flutter => AdapterBackend::R2Flutter, } } } @@ -266,6 +271,12 @@ fn handle_info(repo_root: &Path, cmd: InfoCmd) -> Result<()> { println!("libapp: {}", out.libapp_path); println!("arch: {}", out.arch); println!("snapshot hash: {}", out.snapshot_hash); + if let Some(version) = out.dart_version.as_deref() { + println!("dart version: {}", version); + } + if let Some(tag_style) = out.dart_tag_style.as_deref() { + println!("dart tag style: {}", tag_style); + } println!("adapter installed: {}", out.adapter_installed); if let Some(kind) = out.adapter_kind.as_deref() { println!("adapter kind: {}", kind); @@ -745,13 +756,12 @@ mod tests { }; assert!(matches!(cmd.adapter_backend, AdapterBackendArg::Auto)); } - #[test] fn decompile_adapter_backend_accepts_blutter() { let cli = Cli::try_parse_from([ "flutterdec", "decompile", - "sample.apk", + "in.apk", "-o", "out", "--adapter-backend", @@ -764,6 +774,25 @@ mod tests { assert!(matches!(cmd.adapter_backend, AdapterBackendArg::Blutter)); } + #[test] + fn decompile_adapter_backend_accepts_r2flutter() { + let cli = Cli::try_parse_from([ + "flutterdec", + "decompile", + "in.apk", + "-o", + "out", + "--adapter-backend", + "r2-flutter", + ]) + .expect("parse"); + let Command::Decompile(cmd) = cli.command else { + panic!("expected decompile command"); + }; + assert!(matches!(cmd.adapter_backend, AdapterBackendArg::R2Flutter)); + assert_eq!(cmd.adapter_backend.to_core().as_str(), "r2flutter"); + } + #[test] fn decompile_accepts_require_snapshot_hash_match() { let cli = Cli::try_parse_from([ diff --git a/crates/flutterdec-core/src/lib.rs b/crates/flutterdec-core/src/lib.rs index bd1c52b..c13a29f 100644 --- a/crates/flutterdec-core/src/lib.rs +++ b/crates/flutterdec-core/src/lib.rs @@ -113,9 +113,14 @@ impl DecompileAnalysisProfile { #[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] pub enum AdapterBackend { + /// Try each snapshot-aware backend in turn, then fall back to the internal one. Auto, + /// String carving plus prologue scanning. No real names, no real ObjectPool. Internal, Blutter, + /// `r2flutter` (MIT, radareorg): deserializes the AOT snapshot, so it is the only + /// backend that supplies exact names and an authoritative ObjectPool index space. + R2Flutter, } impl AdapterBackend { @@ -124,6 +129,7 @@ impl AdapterBackend { Self::Auto => "auto", Self::Internal => "internal", Self::Blutter => "blutter", + Self::R2Flutter => "r2flutter", } } } @@ -199,6 +205,11 @@ pub struct InfoOutput { pub libapp_path: String, pub arch: String, pub snapshot_hash: String, + /// Dart SDK version behind `snapshot_hash`, when the hash is tabulated. + pub dart_version: Option, + /// Object-header tag encoding for that version (`CID_INT32`, `CID_SHIFT1`, + /// `OBJECT_HEADER`); the layout dimension most likely to break a parser. + pub dart_tag_style: Option, pub adapter_installed: bool, pub adapter_kind: Option, pub manifest_entry_present: Option, diff --git a/crates/flutterdec-core/src/pipeline/apk_startup.rs b/crates/flutterdec-core/src/pipeline/apk_startup.rs index 90e1899..001d915 100644 --- a/crates/flutterdec-core/src/pipeline/apk_startup.rs +++ b/crates/flutterdec-core/src/pipeline/apk_startup.rs @@ -2689,6 +2689,7 @@ mod apk_startup_tests { name_kind: None, }, ], + pool_geometry: None, object_pool: Vec::new(), }; let startup = AndroidStartupEvidence { diff --git a/crates/flutterdec-core/src/pipeline/helpers.rs b/crates/flutterdec-core/src/pipeline/helpers.rs index df82774..b196a41 100644 --- a/crates/flutterdec-core/src/pipeline/helpers.rs +++ b/crates/flutterdec-core/src/pipeline/helpers.rs @@ -1,6 +1,17 @@ +/// Longest sanitized function name we put in an artifact file name. +/// +/// Recovered Dart names routinely exceed the 255-byte `NAME_MAX` on their own +/// (mangled generics, `@`-suffixed private names, deep owner chains), which used to +/// abort a whole run with `File name too long`. Artifact names are already prefixed +/// with the unique function id, so truncating the stem cannot collide. +const MAX_FILE_NAME_STEM: usize = 160; + fn normalize_file_name(name: &str) -> String { let mut out = String::new(); for c in name.chars() { + if out.len() >= MAX_FILE_NAME_STEM { + break; + } if c.is_ascii_alphanumeric() || c == '_' || c == '-' { out.push(c); } else { @@ -56,4 +67,20 @@ mod helpers_tests { let hay = r#"dynamic x = local; final s = "Možete"; local = 2;"#; assert_eq!(count_ident_token(hay, "local"), 2); } + + #[test] + fn normalize_file_name_caps_long_recovered_dart_names() { + let long = format!("method_{}_deserialize", "Isar_CollectionSchema".repeat(40)); + let out = normalize_file_name(&long); + assert_eq!(out.len(), MAX_FILE_NAME_STEM); + assert!(out.starts_with("method_Isar_CollectionSchema")); + // Leaves room for the `{id:05}_` prefix and the longest extension we emit. + assert!("00000_".len() + out.len() + ".dartpseudo".len() < 255); + } + + #[test] + fn normalize_file_name_keeps_short_names_intact() { + assert_eq!(normalize_file_name("sub_652b98"), "sub_652b98"); + assert_eq!(normalize_file_name("method.Duration.dyn:_"), "method_Duration_dyn__"); + } } diff --git a/crates/flutterdec-core/src/pipeline/runners.rs b/crates/flutterdec-core/src/pipeline/runners.rs index 003408c..919c811 100644 --- a/crates/flutterdec-core/src/pipeline/runners.rs +++ b/crates/flutterdec-core/src/pipeline/runners.rs @@ -127,6 +127,9 @@ struct EngineSymbolIngestion { fn resolved_backend_from_adapter_kind(adapter_kind: &str) -> Option { let lowered = adapter_kind.trim().to_ascii_lowercase(); + if lowered.contains("r2flutter") { + return Some(AdapterBackend::R2Flutter); + } if lowered.contains("blutter") { return Some(AdapterBackend::Blutter); } @@ -138,9 +141,7 @@ fn resolved_backend_from_adapter_kind(adapter_kind: &str) -> Option) -> &'static str { match value { - Some(AdapterBackend::Auto) => "auto", - Some(AdapterBackend::Internal) => "internal", - Some(AdapterBackend::Blutter) => "blutter", + Some(backend) => backend.as_str(), None => "unknown", } } @@ -992,6 +993,14 @@ pub fn run_info(repo_root: &Path, input_path: &Path) -> Result { libapp_path: bundle.libapp_path.display().to_string(), arch: bundle.arch.clone(), snapshot_hash: bundle.snapshot_hash.clone(), + dart_version: bundle + .dart_profile + .as_ref() + .map(|p| p.dart_version.clone()), + dart_tag_style: bundle + .dart_profile + .as_ref() + .map(|p| p.profile.tag_style.as_str().to_string()), adapter_installed, adapter_kind: None, manifest_entry_present: None, @@ -1293,8 +1302,13 @@ pub fn run_decompile( } else { HashMap::new() }; - let pool_value_hints = if opt.engine_options.pool_value_hints - || opt.engine_options.pool_semantic_hints + // `pool[N]` in the disassembly is a real ObjectPool entry index only when the + // adapter recovered the pool layout. Without geometry the adapter's own indices + // are in some private space (string ordinals, for instance), so joining the two + // would attach arbitrary values to unrelated slots. Refuse rather than invent. + let pool_index_space_authoritative = model.pool_geometry.is_some(); + let pool_value_hints = if pool_index_space_authoritative + && (opt.engine_options.pool_value_hints || opt.engine_options.pool_semantic_hints) { build_pool_value_hints(&model) } else { @@ -1317,7 +1331,9 @@ pub fn run_decompile( .is_some_and(|v| !v.is_empty()) }) .count(); - let pool_semantic_hints = if opt.engine_options.pool_semantic_hints { + let pool_semantic_hints = if pool_index_space_authoritative + && opt.engine_options.pool_semantic_hints + { build_pool_semantic_hints(&model, &class_to_library) } else { HashMap::new() @@ -1760,6 +1776,14 @@ pub fn run_decompile( "libapp": bundle.libapp_path, "arch": bundle.arch, "snapshot_hash": bundle_snapshot_hash.clone(), + "dart_profile": bundle.dart_profile.as_ref().map(|p| json!({ + "dart_version": p.dart_version, + "profile_version": p.profile_version, + "tag_style": p.profile.tag_style.as_str(), + "compressed_word_size": p.profile.compressed_word_size, + "header_fields": p.profile.header_fields, + "max_alignment": p.profile.max_alignment + })), "analysis": { "profile": opt.analysis_profile.as_str(), "engine": &opt.engine_options @@ -1983,7 +2007,21 @@ pub fn run_decompile( "with_target_va": pool_metadata.with_target_va, "with_selector": pool_metadata.with_selector, "with_owner_class": pool_metadata.with_owner_class, - "with_library_uri": pool_metadata.with_library_uri + "with_library_uri": pool_metadata.with_library_uri, + "index_space_authoritative": pool_index_space_authoritative, + "geometry": model.pool_geometry.map(|g| serde_json::json!({ + "entries_offset": g.entries_offset, + "word_size": g.word_size + })), + "hints_suppressed_reason": if pool_index_space_authoritative { + serde_json::Value::Null + } else { + serde_json::Value::String( + "adapter reported no pool_geometry; pool entry indices are not in the \ + hardware index space, so pool value/semantic hints were not applied" + .to_string(), + ) + } }, "semantic_rewrite": { "total": semantic_total, diff --git a/crates/flutterdec-core/src/pipeline/runners/tests.rs b/crates/flutterdec-core/src/pipeline/runners/tests.rs index c2c42ef..cb51e7a 100644 --- a/crates/flutterdec-core/src/pipeline/runners/tests.rs +++ b/crates/flutterdec-core/src/pipeline/runners/tests.rs @@ -426,6 +426,7 @@ name_kind: Some("placeholder".to_string()), }, ], + pool_geometry: None, object_pool: Vec::new(), }; @@ -466,6 +467,7 @@ code_section_va: 0x3000, name_kind: Some("heuristic".to_string()), }], + pool_geometry: None, object_pool: Vec::new(), }; @@ -1050,6 +1052,7 @@ name_kind: None, }, ], + pool_geometry: None, object_pool: Vec::new(), }; @@ -1100,6 +1103,7 @@ name_kind: None, }, ], + pool_geometry: None, object_pool: Vec::new(), }; @@ -1178,6 +1182,7 @@ name_kind: None, }, ], + pool_geometry: None, object_pool: Vec::new(), }; @@ -1259,6 +1264,7 @@ name_kind: None, }, ], + pool_geometry: None, object_pool: Vec::new(), }; @@ -1531,6 +1537,7 @@ libraries: Vec::new(), classes: Vec::new(), functions: Vec::new(), + pool_geometry: None, object_pool: vec![ flutterdec_adapter::ObjectPoolEntry { index: 1, @@ -1618,6 +1625,7 @@ libraries: Vec::new(), classes: Vec::new(), functions: Vec::new(), + pool_geometry: None, object_pool: vec![ flutterdec_adapter::ObjectPoolEntry { index: 1, @@ -1663,6 +1671,7 @@ libraries: Vec::new(), classes: Vec::new(), functions: Vec::new(), + pool_geometry: None, object_pool: vec![ flutterdec_adapter::ObjectPoolEntry { index: 1, @@ -1777,6 +1786,7 @@ name_kind: None, }, ], + pool_geometry: None, object_pool: Vec::new(), }; let signals = AndroidManifestSignals { @@ -1853,6 +1863,7 @@ libraries: Vec::new(), classes: Vec::new(), functions: Vec::new(), + pool_geometry: None, object_pool: vec![ flutterdec_adapter::ObjectPoolEntry { index: 7, @@ -1905,6 +1916,7 @@ libraries: Vec::new(), classes: Vec::new(), functions: Vec::new(), + pool_geometry: None, object_pool: vec![ flutterdec_adapter::ObjectPoolEntry { index: 1, @@ -1952,6 +1964,7 @@ libraries: Vec::new(), classes: Vec::new(), functions: Vec::new(), + pool_geometry: None, object_pool: vec![ flutterdec_adapter::ObjectPoolEntry { index: 7, @@ -2034,6 +2047,7 @@ code_section_va: 0x4000, name_kind: None, }], + pool_geometry: None, object_pool: vec![flutterdec_adapter::ObjectPoolEntry { index: 21, kind: "Closure".to_string(), @@ -2084,6 +2098,7 @@ code_section_va: 0x1000, name_kind: Some("heuristic".to_string()), }], + pool_geometry: None, object_pool: Vec::new(), }; let scoped_model = full_model.clone(); @@ -2144,6 +2159,7 @@ name_kind: Some("heuristic".to_string()), }, ], + pool_geometry: None, object_pool: Vec::new(), }; let (scoped_model, _) = apply_function_scope_filter(&full_model, FunctionScope::App, &[]); @@ -2195,6 +2211,7 @@ name_kind: Some("heuristic".to_string()), }, ], + pool_geometry: None, object_pool: Vec::new(), }; let scoped_model = full_model.clone(); diff --git a/crates/flutterdec-decompiler/src/control_flow/emit.rs b/crates/flutterdec-decompiler/src/control_flow/emit.rs index ba27ad6..fd23bab 100644 --- a/crates/flutterdec-decompiler/src/control_flow/emit.rs +++ b/crates/flutterdec-decompiler/src/control_flow/emit.rs @@ -197,6 +197,13 @@ impl<'a> FuncEmitter<'a> { } fn annotate_pool_refs(&self, expr: &str) -> String { + // Pool slots are resolved once, when the load lands in a register. Callers + // downstream re-annotate their operands, so bail out on text that already + // carries a resolved hint instead of nesting a second comment inside the first. + if expr.contains("/* pool[") || expr.contains("/* \"") { + return expr.to_string(); + } + let normalized = normalize_pool_page_field_exprs(expr); let exact = self.render_pool_value_hint(&normalized); if exact != normalized { diff --git a/crates/flutterdec-decompiler/src/control_flow/expression_lift.rs b/crates/flutterdec-decompiler/src/control_flow/expression_lift.rs index b581d17..4d175b1 100644 --- a/crates/flutterdec-decompiler/src/control_flow/expression_lift.rs +++ b/crates/flutterdec-decompiler/src/control_flow/expression_lift.rs @@ -1,12 +1,24 @@ use super::*; impl<'a> FuncEmitter<'a> { + /// Resolve a register read that is consumed as a whole value. + /// + /// A register holding a pool slot becomes the slot's string here, so assignments, + /// comparisons and returns read like Dart instead of like a pool index. The + /// dereference paths below deliberately do not call this: `pool[40].f7` is a field + /// read on the pooled object, and rendering the literal there would claim a field + /// access on a string. Those keep `pool[ /* "value" */]` instead. + fn resolved_reg_value(&self, reg: &str) -> String { + let raw = Self::clean_expr(self.state.reg_values.get(reg).cloned().unwrap_or_else(|| reg.to_string())); + self.annotate_pool_refs(&raw) + } + pub(super) fn lookup_reg(&self, token: &str) -> String { if is_zero_reg(token) { return "0".to_string(); } if let Some(reg) = canonical_reg(token) { - return Self::clean_expr(self.state.reg_values.get(®).cloned().unwrap_or(reg)); + return self.resolved_reg_value(®); } Self::clean_expr(token.trim().trim_start_matches('#').to_string()) } @@ -16,15 +28,15 @@ impl<'a> FuncEmitter<'a> { return "0".to_string(); } if let Some(reg) = canonical_reg(token) { - return Self::clean_expr(self.state.reg_values.get(®).cloned().unwrap_or(reg)); + return self.resolved_reg_value(®); } if let Some((base, off)) = parse_mem_operand(token) { if base == "x29" { if let Some(name) = self.locals.get(&off) { - return Self::clean_expr(name.clone()); + return name.clone(); } - return Self::clean_expr(local_name(off)); + return local_name(off); } let base_expr = self.state.reg_values.get(&base).cloned().unwrap_or(base); diff --git a/crates/flutterdec-decompiler/src/helpers/expr.rs b/crates/flutterdec-decompiler/src/helpers/expr.rs index 943089f..2a9c94d 100644 --- a/crates/flutterdec-decompiler/src/helpers/expr.rs +++ b/crates/flutterdec-decompiler/src/helpers/expr.rs @@ -193,6 +193,12 @@ fn parse_non_negative_i64_token(token: &str) -> Option { (parsed >= 0).then_some(parsed as u64) } +/// Recognise `((pool + /* lsl # */)).f` and return the +/// PP-relative byte displacement it reads. +/// +/// This is the residual path for page-based pool loads the disassembler's register +/// tracker could not follow. It has no pool geometry, so it can only report the +/// displacement, never an entry index. fn try_parse_shifted_pool_field(bytes: &[u8], start: usize) -> Option<(usize, u64)> { if start + 2 >= bytes.len() || bytes[start] != b'(' || bytes[start + 1] != b'(' { return None; @@ -284,11 +290,11 @@ fn try_parse_shifted_pool_field(bytes: &[u8], start: usize) -> Option<(usize, u6 let page_bytes = page.checked_shl(shift as u32)?; let total = page_bytes.checked_add(offset)?; - if total % 8 != 0 { + if !total.is_multiple_of(8) { return None; } - Some((i, total / 8)) + Some((i, total)) } pub(super) fn normalize_pool_page_field_exprs(input: &str) -> String { @@ -296,8 +302,8 @@ pub(super) fn normalize_pool_page_field_exprs(input: &str) -> String { let mut out = String::with_capacity(input.len()); let mut i = 0usize; while i < bytes.len() { - if let Some((end, idx)) = try_parse_shifted_pool_field(bytes, i) { - out.push_str(&format!("pool[{idx}]")); + if let Some((end, displacement)) = try_parse_shifted_pool_field(bytes, i) { + out.push_str(&format!("poolOff[{displacement}]")); i = end; continue; } diff --git a/crates/flutterdec-decompiler/src/passes/expr_cleanup.rs b/crates/flutterdec-decompiler/src/passes/expr_cleanup.rs index 01f1af7..73b7aa9 100644 --- a/crates/flutterdec-decompiler/src/passes/expr_cleanup.rs +++ b/crates/flutterdec-decompiler/src/passes/expr_cleanup.rs @@ -14,11 +14,37 @@ impl<'a> FuncEmitter<'a> { }) } + /// Length of the string literal starting at `i`, including both quotes. + /// + /// Recovered pool strings are real program data and frequently contain the same + /// punctuation these rewrites look for. `"... collected (nullptr). This is ..."` reads + /// as a parenthesised member access to a byte scanner, and simplifying it silently + /// edits a string that came out of the binary. Scanners copy literals verbatim. + fn string_literal_len(bytes: &[u8], i: usize) -> Option { + if bytes.get(i) != Some(&b'"') { + return None; + } + let mut j = i + 1; + while j < bytes.len() { + match bytes[j] { + b'\\' => j += 2, + b'"' => return Some(j + 1 - i), + _ => j += 1, + } + } + None + } + fn simplify_wrapped_member_access_once(input: &str) -> String { let bytes = input.as_bytes(); let mut out: Vec = Vec::with_capacity(bytes.len()); let mut i = 0usize; while i < bytes.len() { + if let Some(len) = Self::string_literal_len(bytes, i) { + out.extend_from_slice(&bytes[i..i + len]); + i += len; + continue; + } if i + 3 < bytes.len() && bytes[i] == b'(' && bytes[i + 1] == b'(' { let mut depth = 0i32; let mut j = i; @@ -62,6 +88,11 @@ impl<'a> FuncEmitter<'a> { let mut out: Vec = Vec::with_capacity(bytes.len()); let mut i = 0usize; while i < bytes.len() { + if let Some(len) = Self::string_literal_len(bytes, i) { + out.extend_from_slice(&bytes[i..i + len]); + i += len; + continue; + } if bytes[i] == b'(' { let mut depth = 0i32; let mut j = i; @@ -314,13 +345,15 @@ mod expr_cleanup_utf8_tests { fn clean_expr_normalizes_shifted_pool_field_access() { let input = "((pool + 8 /* lsl #12 */)).f3640".to_string(); let out = FuncEmitter::clean_expr(input); - assert_eq!(out, "pool[4551]"); + // (8 << 12) + 3640 == 36408 bytes from PP. Converting that to an entry index + // needs the pool's entries_offset/word_size, which this layer does not have. + assert_eq!(out, "poolOff[36408]"); } #[test] fn clean_expr_normalizes_nested_shifted_pool_field_access() { let input = "((((pool + 8 /* lsl #12 */)).f816).f7)".to_string(); let out = FuncEmitter::clean_expr(input); - assert_eq!(out, "(pool[4198].f7)"); + assert_eq!(out, "(poolOff[33584].f7)"); } } diff --git a/crates/flutterdec-decompiler/src/tests/compaction_and_aliasing/alias_and_expr_cleanup.rs b/crates/flutterdec-decompiler/src/tests/compaction_and_aliasing/alias_and_expr_cleanup.rs index 1948a59..ff0e136 100644 --- a/crates/flutterdec-decompiler/src/tests/compaction_and_aliasing/alias_and_expr_cleanup.rs +++ b/crates/flutterdec-decompiler/src/tests/compaction_and_aliasing/alias_and_expr_cleanup.rs @@ -139,3 +139,27 @@ fn keeps_parentheses_for_non_member_field_base() { let got = FuncEmitter::clean_expr(line); assert_eq!(got, "((arg0 + 1)).f7"); } +/// Recovered pool strings are program data and routinely contain the punctuation the +/// expression rewrites look for. A real one from a Flutter engine build reads +/// "... has been collected (nullptr). This is ...", which the member-access +/// simplifier used to shorten to "collected nullptr." inside the quotes. +#[test] +fn clean_expr_does_not_rewrite_inside_string_literals() { + let line = + "\"a native peer has been collected (nullptr). This is usually a bug\"".to_string(); + assert_eq!(FuncEmitter::clean_expr(line.clone()), line); +} + +#[test] +fn clean_expr_still_simplifies_around_a_string_literal() { + let line = "f(\"(x).y\", ((arg0)).f7)".to_string(); + let got = FuncEmitter::clean_expr(line); + assert!( + got.contains("\"(x).y\""), + "literal must survive verbatim: {got}" + ); + assert!( + got.contains("arg0.f7"), + "code outside the literal should still simplify: {got}" + ); +} diff --git a/crates/flutterdec-decompiler/src/tests/emit_and_helpers/readability_and_naming.rs b/crates/flutterdec-decompiler/src/tests/emit_and_helpers/readability_and_naming.rs index a030256..b04e280 100644 --- a/crates/flutterdec-decompiler/src/tests/emit_and_helpers/readability_and_naming.rs +++ b/crates/flutterdec-decompiler/src/tests/emit_and_helpers/readability_and_naming.rs @@ -734,8 +734,13 @@ fn aliases_dispatch_target_slot_callable_calls() { ); } +/// Page-based pool loads that the disassembler's register tracker could not follow +/// still reach the decompiler as raw `((pool + /* lsl #N */)).f` text. +/// That text carries a byte displacement, not an entry index, and the decompiler has +/// no pool geometry to convert it, so it must surface the displacement and decline +/// to resolve, rather than divide by the stride and land on a neighbouring slot. #[test] -fn resolves_shifted_pool_target_to_symbol_call_name() { +fn residual_shifted_pool_syntax_reports_displacement_and_does_not_resolve() { let ir = FunctionIr { function_id: 215, name: "shiftedPoolTarget".to_string(), @@ -780,21 +785,21 @@ fn resolves_shifted_pool_target_to_symbol_call_name() { ); let artifact = emit_pseudocode_with_pool_context(&ir, &symbols, &pool, &semantic); + // (8 << 12) + 3640 == 36408 bytes from PP. assert!( - artifact - .source - .contains("dart.core.print(receiver, param1, param2, param3);"), - "shifted pool target should resolve to readable symbol call:\n{}", + artifact.source.contains("poolOff[36408]"), + "residual shifted pool access should surface its byte displacement:\n{}", artifact.source ); assert!( - artifact.source.contains("target: pool[4551]"), - "shifted pool target should normalize to pool index in comments:\n{}", + !artifact.source.contains("pool[4551]"), + "displacement 36408 must not be reported as entry index 4551; the real entry \ + index depends on pool geometry the decompiler does not have:\n{}", artifact.source ); assert!( - artifact.source.contains("target_va: 0x9100"), - "resolved call should report target_va from pool semantic hint:\n{}", + !artifact.source.contains("dart.core.print"), + "an unresolvable pool displacement must not pick up a semantic hint:\n{}", artifact.source ); assert!( @@ -1230,6 +1235,133 @@ fn annotates_framework_from_pool_selector_when_call_name_is_generic() { artifact.source ); } +/// A resolved pool slot is a known string wherever it is used, not only where it +/// happens to land in a call argument. Assignments, comparisons and returns used to +/// print the bare `pool[N]` even with the value in hand. +#[test] +fn pool_values_render_as_literals_outside_call_arguments() { + let ir = FunctionIr { + function_id: 900, + name: "poolValueUses".to_string(), + entry_va: 0x11000, + blocks: vec![BasicBlock { + id: 0, + start_va: 0x11000, + instrs: vec![ + LlirInstr { + va: 0x11000, + op: IROp::LoadPool, + src: "x1".to_string(), + target: "pool[40]".to_string(), + }, + // store to a frame local + LlirInstr { + va: 0x11004, + op: IROp::Other, + src: "stur x1, [x29, #-8]".to_string(), + target: String::new(), + }, + // compare against another register + LlirInstr { + va: 0x11008, + op: IROp::Other, + src: "cmp x2, x1".to_string(), + target: String::new(), + }, + LlirInstr { + va: 0x1100c, + op: IROp::Other, + src: "mov x0, x1".to_string(), + target: String::new(), + }, + LlirInstr { + va: 0x11010, + op: IROp::Return, + src: "ret".to_string(), + target: String::new(), + }, + ], + succs: Vec::new(), + preds: Vec::new(), + }], + }; + + let symbols = HashMap::new(); + let mut pool = HashMap::new(); + pool.insert(40u64, "onError".to_string()); + let out = emit_pseudocode_with_pool_hints(&ir, &symbols, &pool).source; + + assert!( + out.contains("= \"onError\" /* pool[40] */;"), + "pool value assigned to a local should read as the string:\n{out}" + ); + assert!( + out.contains("return \"onError\" /* pool[40] */;"), + "returned pool value should read as the string:\n{out}" + ); + assert!( + !out.contains("= pool[40];"), + "no use should be left as a bare slot when the value is known:\n{out}" + ); +} + +/// Dereferencing a pooled object is not the same as using its value. `pool[40].f7` +/// reads a field of the object in slot 40, so rendering the string there would claim a +/// field access on a literal; the slot keeps its inline mapping instead. +#[test] +fn pool_field_access_keeps_the_slot_rather_than_the_literal() { + let ir = FunctionIr { + function_id: 901, + name: "poolFieldUse".to_string(), + entry_va: 0x12000, + blocks: vec![BasicBlock { + id: 0, + start_va: 0x12000, + instrs: vec![ + LlirInstr { + va: 0x12000, + op: IROp::LoadPool, + src: "x1".to_string(), + target: "pool[40]".to_string(), + }, + LlirInstr { + va: 0x12004, + op: IROp::Other, + src: "ldur x2, [x1, #7]".to_string(), + target: String::new(), + }, + LlirInstr { + va: 0x12008, + op: IROp::Other, + src: "stur x2, [x29, #-8]".to_string(), + target: String::new(), + }, + LlirInstr { + va: 0x1200c, + op: IROp::Return, + src: "ret".to_string(), + target: String::new(), + }, + ], + succs: Vec::new(), + preds: Vec::new(), + }], + }; + + let symbols = HashMap::new(); + let mut pool = HashMap::new(); + pool.insert(40u64, "onError".to_string()); + let out = emit_pseudocode_with_pool_hints(&ir, &symbols, &pool).source; + + assert!( + !out.contains("\"onError\" /* pool[40] */.f"), + "a field read must not be rendered as a field of a string literal:\n{out}" + ); + assert!( + out.contains("pool[40 /* \"onError\" */].f7"), + "the field base should stay a slot and carry its inline mapping:\n{out}" + ); +} #[test] fn annotates_package_call_intents_from_machine_symbol_names() { diff --git a/crates/flutterdec-disasm-arm64/src/lib.rs b/crates/flutterdec-disasm-arm64/src/lib.rs index b4b4990..c570dc4 100644 --- a/crates/flutterdec-disasm-arm64/src/lib.rs +++ b/crates/flutterdec-disasm-arm64/src/lib.rs @@ -1,9 +1,10 @@ use capstone::arch::arm64::ArchMode; use capstone::prelude::*; -use flutterdec_adapter::{FunctionInfo, ProgramModel}; +use flutterdec_adapter::{FunctionInfo, PoolGeometry, ProgramModel}; use regex::Regex; use serde::Serialize; use std::collections::{HashMap, HashSet, VecDeque}; +use std::sync::LazyLock; #[derive(Debug, Clone, Serialize)] pub struct AsmInstruction { @@ -50,33 +51,149 @@ fn build_capstone() -> Option { .ok() } -fn maybe_pool_annotation(mnemonic: &str, op_str: &str) -> Option { - if mnemonic != "ldr" { - return None; +/// `ldr xN, [x27, #imm]`: a pool load off the object-pool register directly. +static POOL_DIRECT_LOAD_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"^[wx]\d+,\s*\[(x\d+)(?:,\s*#?(0x[0-9a-fA-F]+|[0-9]+))?\]$").unwrap() +}); + +/// `add xD, x27, #K` / `add xD, x27, #K, lsl #S`: materialise a pool "page" base. +/// Dart emits this pair whenever the entry displacement exceeds the `ldr` immediate range. +static POOL_PAGE_BASE_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"^(x\d+),\s*x27,\s*#?(0x[0-9a-fA-F]+|[0-9]+)(?:,\s*lsl\s*#?(\d+))?$").unwrap() +}); + +/// Leading register operands, used to invalidate stale pool bases on redefinition. +/// Two are matched because load-pair forms write two destinations. +static FIRST_OPERAND_REG_RE: LazyLock = + LazyLock::new(|| Regex::new(r"^[wx](\d+)\s*,\s*(?:[wx](\d+)\s*,)?").unwrap()); + +fn parse_u64_literal(raw: &str) -> Option { + match raw.strip_prefix("0x") { + Some(hex) => u64::from_str_radix(hex, 16).ok(), + None => raw.parse::().ok(), } - let lower = op_str.to_ascii_lowercase(); - if !lower.contains("[x27") { - return None; +} + +/// Resolves object-pool references while walking one function's instructions. +/// +/// Two forms reach the same slot, and both must be recognised: the direct +/// `ldr xN, [x27, #disp]`, and the page pair `add xD, x27, #K, lsl #S` followed by +/// `ldr xN, [xD, #off]` for displacements past the load-immediate range. On real +/// binaries the page form is the *majority* of pool traffic, so ignoring it loses +/// most of the pool. +/// +/// Bases are tracked conservatively: any write to a register drops its base, and +/// control flow drops all of them, so a stale base can never fabricate a slot. +struct PoolRefResolver { + geometry: Option, + bases: HashMap, +} + +impl PoolRefResolver { + fn new(geometry: Option) -> Self { + Self { + geometry, + bases: HashMap::new(), + } + } + + fn reset(&mut self) { + self.bases.clear(); + } + + /// Render a PP-relative displacement. + /// + /// With geometry we can name the actual entry, so callers get `pool[]` in + /// the pool's own index space. Without it the displacement is all we honestly + /// know, and it is emitted as `poolOff[...]` so it cannot be mistaken for an + /// index, or looked up as one. + fn render(&self, displacement: u64) -> String { + match self + .geometry + .and_then(|g| g.index_for_displacement(displacement)) + { + Some(index) => format!("pool[{index}]"), + None => format!("poolOff[{displacement}]"), + } + } + + /// Feed one instruction; returns the pool annotation when it is a pool load. + /// + /// A pool load reads its base register and writes its destination, and for + /// `ldr x0, [x0, ...]` those are the same register, so the base must be + /// resolved before the destination is invalidated. + fn observe(&mut self, mnemonic: &str, op_str: &str) -> Option { + if mnemonic == "add" { + if let Some(caps) = POOL_PAGE_BASE_RE.captures(op_str) { + let dst = caps[1][1..].parse::().ok()?; + let imm = parse_u64_literal(&caps[2])?; + let shift = caps + .get(3) + .map(|m| m.as_str().parse::().unwrap_or(0)) + .unwrap_or(0); + match imm.checked_shl(shift) { + Some(base) => self.bases.insert(dst, base), + None => self.bases.remove(&dst), + }; + return None; + } + self.invalidate_written_registers(op_str); + return None; + } + + if mnemonic != "ldr" { + self.invalidate_written_registers(op_str); + return None; + } + + let Some(caps) = POOL_DIRECT_LOAD_RE.captures(op_str) else { + self.invalidate_written_registers(op_str); + return None; + }; + let base_reg = caps[1][1..].parse::().ok(); + let off = caps + .get(2) + .and_then(|m| parse_u64_literal(m.as_str())) + .unwrap_or(0); + let displacement = match base_reg { + Some(27) => Some(off), + Some(reg) => self.bases.get(®).and_then(|b| b.checked_add(off)), + None => None, + }; + + self.invalidate_written_registers(op_str); + displacement.map(|d| self.render(d)) + } + + /// Drop the pool bases of the registers an instruction writes. + /// + /// Load-pair forms write two: `ldp x0, x1, [sp, #16]` must clear both, or a later + /// `ldr xN, [x1, #off]` resolves against a base `x1` no longer holds. Store-pair + /// forms name sources rather than destinations, so clearing there is merely + /// conservative. + fn invalidate_written_registers(&mut self, op_str: &str) { + let Some(caps) = FIRST_OPERAND_REG_RE.captures(op_str) else { + return; + }; + for group in [1, 2] { + if let Some(reg) = caps.get(group).and_then(|m| m.as_str().parse::().ok()) { + self.bases.remove(®); + } + } } - let re = Regex::new(r"\[x27,\s*#?(0x[0-9a-fA-F]+|[0-9]+)\]").ok()?; - let caps = re.captures(op_str)?; - let raw = caps.get(1)?.as_str(); - let imm = if let Some(hex) = raw.strip_prefix("0x") { - u64::from_str_radix(hex, 16).ok()? - } else { - raw.parse::().ok()? - }; - Some(format!("pool[{imm}]")) } -fn annotation_for(mnemonic: &str, op_str: &str) -> String { +fn annotation_for(mnemonic: &str, op_str: &str, pool: &mut PoolRefResolver) -> String { if mnemonic == "bl" || mnemonic == "blr" { + pool.reset(); return "call".to_string(); } if mnemonic == "ret" { + pool.reset(); return "return".to_string(); } if mnemonic == "b" { + pool.reset(); return "jump".to_string(); } if mnemonic.starts_with("b.") @@ -85,9 +202,10 @@ fn annotation_for(mnemonic: &str, op_str: &str) -> String { || mnemonic == "tbz" || mnemonic == "tbnz" { + pool.reset(); return "branch".to_string(); } - if let Some(pp) = maybe_pool_annotation(mnemonic, op_str) { + if let Some(pp) = pool.observe(mnemonic, op_str) { return pp; } String::new() @@ -98,6 +216,7 @@ fn decode_function( iso_instr: &[u8], iso_base_va: u64, cs: Option<&Capstone>, + pool_geometry: Option, ) -> Option { if func.entry_va < iso_base_va { return None; @@ -115,6 +234,7 @@ fn decode_function( let code = &iso_instr[rel..rel + size]; let mut instructions = Vec::new(); + let mut pool = PoolRefResolver::new(pool_geometry); if let Some(cs) = cs { if let Ok(insns) = cs.disasm_all(code, func.entry_va) { @@ -127,7 +247,7 @@ fn decode_function( }; let mnemonic = ins.mnemonic().unwrap_or("word").to_string(); let op_str = ins.op_str().unwrap_or("").to_string(); - let annotation = annotation_for(&mnemonic, &op_str); + let annotation = annotation_for(&mnemonic, &op_str, &mut pool); instructions.push(AsmInstruction { va: ins.address(), word, @@ -1252,6 +1372,7 @@ pub fn disassemble_program_with_priorities_and_package_hints( let mut out = Vec::new(); let mut priorities = Vec::new(); let cs = build_capstone(); + let pool_geometry = model.pool_geometry; let ranked = rank_candidates( model, iso_instr, @@ -1287,7 +1408,13 @@ pub fn disassemble_program_with_priorities_and_package_hints( else { continue; }; - if let Some(d) = decode_function(candidate.func, iso_instr, iso_base_va, cs.as_ref()) { + if let Some(d) = decode_function( + candidate.func, + iso_instr, + iso_base_va, + cs.as_ref(), + pool_geometry, + ) { out.push(d); priorities.push(to_breakdown(candidate)); selected_entry_vas.insert(seed_entry_va); @@ -1328,7 +1455,13 @@ pub fn disassemble_program_with_priorities_and_package_hints( deferred.push(candidate); continue; } - if let Some(d) = decode_function(candidate.func, iso_instr, iso_base_va, cs.as_ref()) { + if let Some(d) = decode_function( + candidate.func, + iso_instr, + iso_base_va, + cs.as_ref(), + pool_geometry, + ) { out.push(d); priorities.push(to_breakdown(&candidate)); selected_entry_vas.insert(candidate.func.entry_va); @@ -1346,7 +1479,13 @@ pub fn disassemble_program_with_priorities_and_package_hints( if selected_entry_vas.contains(&candidate.func.entry_va) { continue; } - if let Some(d) = decode_function(candidate.func, iso_instr, iso_base_va, cs.as_ref()) { + if let Some(d) = decode_function( + candidate.func, + iso_instr, + iso_base_va, + cs.as_ref(), + pool_geometry, + ) { out.push(d); priorities.push(to_breakdown(&candidate)); selected_entry_vas.insert(candidate.func.entry_va); @@ -1354,7 +1493,13 @@ pub fn disassemble_program_with_priorities_and_package_hints( } } else { for candidate in ranked { - if let Some(d) = decode_function(candidate.func, iso_instr, iso_base_va, cs.as_ref()) { + if let Some(d) = decode_function( + candidate.func, + iso_instr, + iso_base_va, + cs.as_ref(), + pool_geometry, + ) { out.push(d); priorities.push(to_breakdown(&candidate)); } @@ -1387,6 +1532,200 @@ mod tests { use super::*; use flutterdec_adapter::{ClassInfo, LibraryInfo, ObjectPoolEntry}; + /// Word encodings lifted from a real Dart 3.9.2 `libapp.so`; ground-truth pool + /// indices were cross-checked against an independent ObjectPool decoder. + mod pool_words { + /// `ldr x1, [x27, #0xef8]`: direct load, PP displacement 0xef8. + pub const LDR_X1_PP_0XEF8: u32 = 0xF947_7F61; + /// `add x0, x27, #0x23, lsl #12`: page base at PP + 0x23000. + pub const ADD_X0_PP_PAGE_0X23: u32 = 0x9140_8F60; + /// `ldr x0, [x0, #0xa90]`: completes the page pair, displacement 0x23a90. + pub const LDR_X0_X0_0XA90: u32 = 0xF945_4800; + pub const RET: u32 = 0xD65F_03C0; + } + + fn pool_probe_model(pool_geometry: Option) -> ProgramModel { + ProgramModel { + schema_version: 3, + adapter_kind: "test".to_string(), + dart_version: "3.9.2".to_string(), + snapshot_hash: "h".to_string(), + arch: "arm64".to_string(), + libraries: vec![LibraryInfo { + id: 0, + uri: "package:app/main.dart".to_string(), + name_display: "package:app/main.dart".to_string(), + }], + classes: vec![ClassInfo { + id: 0, + name: "Global".to_string(), + super_name: "Object".to_string(), + library_uri: "package:app/main.dart".to_string(), + }], + functions: vec![FunctionInfo { + id: 0, + name: "poolProbe".to_string(), + owner_class: "Global".to_string(), + entry_va: 0x1000, + size: 16, + code_section_va: 0x1000, + name_kind: None, + }], + object_pool: Vec::new(), + pool_geometry, + } + } + + fn annotations_for_words( + words: &[u32], + geometry: Option, + ) -> Vec<(String, String)> { + let mut model = pool_probe_model(geometry); + model.functions[0].size = (words.len() * 4) as u64; + let bytes: Vec = words.iter().flat_map(|w| w.to_le_bytes()).collect(); + let d = disassemble_program(&model, &bytes, 0x1000, None, None); + d.first() + .map(|f| { + f.instructions + .iter() + .map(|i| (format!("{} {}", i.mnemonic, i.op_str), i.annotation.clone())) + .collect() + }) + .unwrap_or_default() + } + + const ARM64_POOL_GEOMETRY: PoolGeometry = PoolGeometry { + entries_offset: 0x10, + word_size: 8, + }; + + #[test] + fn direct_pool_load_resolves_displacement_to_entry_index() { + let out = annotations_for_words( + &[pool_words::LDR_X1_PP_0XEF8, pool_words::RET], + Some(ARM64_POOL_GEOMETRY), + ); + assert_eq!( + out[0].0, "ldr x1, [x27, #0xef8]", + "encoding drifted: {out:?}" + ); + // 0xef8 is a byte displacement, not an index: (0xef8 - 0x10) / 8 == 477. + assert_eq!(out[0].1, "pool[477]"); + } + + #[test] + fn paged_pool_load_resolves_across_the_add_ldr_pair() { + let out = annotations_for_words( + &[ + pool_words::ADD_X0_PP_PAGE_0X23, + pool_words::LDR_X0_X0_0XA90, + pool_words::RET, + ], + Some(ARM64_POOL_GEOMETRY), + ); + assert_eq!( + out[0].0, "add x0, x27, #0x23, lsl #12", + "encoding drifted: {out:?}" + ); + assert_eq!( + out[1].0, "ldr x0, [x0, #0xa90]", + "encoding drifted: {out:?}" + ); + // (0x23 << 12) + 0xa90 == 0x23a90; (0x23a90 - 0x10) / 8 == 18256. + assert_eq!(out[1].1, "pool[18256]"); + } + + #[test] + fn pool_loads_report_raw_displacement_without_geometry() { + let out = annotations_for_words( + &[ + pool_words::LDR_X1_PP_0XEF8, + pool_words::ADD_X0_PP_PAGE_0X23, + pool_words::LDR_X0_X0_0XA90, + pool_words::RET, + ], + None, + ); + // No geometry means no index space; emitting `pool[N]` here would invite the + // hint layer to join on an index that does not exist. + assert_eq!(out[0].1, "poolOff[3832]"); + assert_eq!(out[2].1, "poolOff[146064]"); // 0x23a90 + } + + #[test] + fn control_flow_invalidates_a_pending_pool_page_base() { + let out = annotations_for_words( + &[ + pool_words::ADD_X0_PP_PAGE_0X23, + pool_words::RET, + pool_words::LDR_X0_X0_0XA90, + ], + Some(ARM64_POOL_GEOMETRY), + ); + assert_eq!( + out[2].1, "", + "a base that did not survive control flow must not annotate a slot" + ); + } + + #[test] + fn redefining_the_base_register_invalidates_it() { + // `add x0, x27, #0x23, lsl #12` then `add x0, x27, #1, lsl #12` must use the + // second base, not the first. + let second_page = 0x9140_0760u32; // add x0, x27, #1, lsl #12 + let out = annotations_for_words( + &[ + pool_words::ADD_X0_PP_PAGE_0X23, + second_page, + pool_words::LDR_X0_X0_0XA90, + pool_words::RET, + ], + Some(ARM64_POOL_GEOMETRY), + ); + assert_eq!( + out[1].0, "add x0, x27, #1, lsl #12", + "encoding drifted: {out:?}" + ); + // (1 << 12) + 0xa90 == 0x1a90; (0x1a90 - 0x10) / 8 == 848. + assert_eq!(out[2].1, "pool[848]"); + } + /// `ldp` writes two registers. Clearing only the first leaves the second holding a + /// base it no longer has, which a later load would turn into a fabricated slot. + #[test] + fn load_pair_invalidates_both_destination_registers() { + // add x1, x27, #0x23, lsl #12 (x1 gets a page base) + // ldp x0, x1, [sp, #16] (x1 is overwritten) + // ldr x0, [x1, #0xa90] (must not resolve) + let add_x1_page = 0x9140_8F61u32; + let ldp_x0_x1_sp16 = 0xA941_07E0u32; + let ldr_x0_x1_0xa90 = 0xF945_4820u32; + let out = annotations_for_words( + &[ + add_x1_page, + ldp_x0_x1_sp16, + ldr_x0_x1_0xa90, + pool_words::RET, + ], + Some(ARM64_POOL_GEOMETRY), + ); + assert_eq!( + out[0].0, "add x1, x27, #0x23, lsl #12", + "encoding drifted: {out:?}" + ); + assert_eq!( + out[1].0, "ldp x0, x1, [sp, #0x10]", + "encoding drifted: {out:?}" + ); + assert_eq!( + out[2].0, "ldr x0, [x1, #0xa90]", + "encoding drifted: {out:?}" + ); + assert_eq!( + out[2].1, "", + "x1 was overwritten by the load pair, so its stale base must not resolve" + ); + } + #[test] fn disassembles_simple_function() { let model = ProgramModel { @@ -1415,6 +1754,7 @@ mod tests { code_section_va: 0x1000, name_kind: None, }], + pool_geometry: None, object_pool: vec![ObjectPoolEntry { index: 0, kind: "String".to_string(), @@ -1488,6 +1828,7 @@ mod tests { name_kind: None, }, ], + pool_geometry: None, object_pool: vec![ObjectPoolEntry { index: 0, kind: "String".to_string(), @@ -1561,6 +1902,7 @@ mod tests { name_kind: None, }, ], + pool_geometry: None, object_pool: vec![ObjectPoolEntry { index: 0, kind: "String".to_string(), @@ -1619,6 +1961,7 @@ mod tests { name_kind: None, }, ], + pool_geometry: None, object_pool: vec![ObjectPoolEntry { index: 0, kind: "String".to_string(), @@ -1677,6 +2020,7 @@ mod tests { name_kind: None, }, ], + pool_geometry: None, object_pool: vec![ ObjectPoolEntry { index: 0, @@ -1749,6 +2093,7 @@ mod tests { name_kind: None, }, ], + pool_geometry: None, object_pool: vec![ObjectPoolEntry { index: 0, kind: "String".to_string(), @@ -1807,6 +2152,7 @@ mod tests { name_kind: None, }, ], + pool_geometry: None, object_pool: vec![ObjectPoolEntry { index: 0, kind: "String".to_string(), @@ -1865,6 +2211,7 @@ mod tests { name_kind: None, }, ], + pool_geometry: None, object_pool: vec![ObjectPoolEntry { index: 0, kind: "String".to_string(), @@ -1923,6 +2270,7 @@ mod tests { name_kind: None, }, ], + pool_geometry: None, object_pool: vec![ObjectPoolEntry { index: 0, kind: "String".to_string(), @@ -1996,6 +2344,7 @@ mod tests { name_kind: None, }, ], + pool_geometry: None, object_pool: vec![ ObjectPoolEntry { index: 0, @@ -2077,6 +2426,7 @@ mod tests { name_kind: None, }, ], + pool_geometry: None, object_pool: vec![ ObjectPoolEntry { index: 0, @@ -2180,6 +2530,7 @@ mod tests { name_kind: None, }, ], + pool_geometry: None, object_pool: vec![ ObjectPoolEntry { index: 0, @@ -2280,6 +2631,7 @@ mod tests { name_kind: None, }, ], + pool_geometry: None, object_pool: vec![ObjectPoolEntry { index: 0, kind: "String".to_string(), @@ -2416,6 +2768,7 @@ mod tests { name_kind: None, }, ], + pool_geometry: None, object_pool: vec![ObjectPoolEntry { index: 0, kind: "String".to_string(), @@ -2474,6 +2827,7 @@ mod tests { name_kind: None, }, ], + pool_geometry: None, object_pool: vec![ObjectPoolEntry { index: 0, kind: "String".to_string(), @@ -2541,6 +2895,7 @@ mod tests { name_kind: None, }, ], + pool_geometry: None, object_pool: vec![ObjectPoolEntry { index: 0, kind: "String".to_string(), @@ -2627,6 +2982,7 @@ mod tests { name_kind: None, }, ], + pool_geometry: None, object_pool: vec![ObjectPoolEntry { index: 0, kind: "String".to_string(), @@ -3031,6 +3387,7 @@ mod tests { libraries: Vec::new(), classes: Vec::new(), functions, + pool_geometry: None, object_pool: Vec::new(), }; let owner_library = HashMap::from([ @@ -3101,6 +3458,7 @@ mod tests { name_kind: None, }, ], + pool_geometry: None, object_pool: vec![ObjectPoolEntry { index: 0, kind: "String".to_string(), @@ -3173,6 +3531,7 @@ mod tests { name_kind: None, }, ], + pool_geometry: None, object_pool: vec![ObjectPoolEntry { index: 0, kind: "String".to_string(), @@ -3232,6 +3591,7 @@ mod tests { name_kind: None, }, ], + pool_geometry: None, object_pool: vec![ObjectPoolEntry { index: 0, kind: "String".to_string(), diff --git a/crates/flutterdec-ir/src/lib.rs b/crates/flutterdec-ir/src/lib.rs index 0048924..031bc60 100644 --- a/crates/flutterdec-ir/src/lib.rs +++ b/crates/flutterdec-ir/src/lib.rs @@ -96,7 +96,10 @@ fn llir_from_disasm(d: &FunctionDisassembly) -> Vec { "ret" => { op = IROp::Return; } - "ldr" if ins.annotation.starts_with("pool[") => { + "ldr" + if ins.annotation.starts_with("pool[") + || ins.annotation.starts_with("poolOff[") => + { op = IROp::LoadPool; src = ins.op_str.clone(); target = ins.annotation.clone(); diff --git a/crates/flutterdec-loader/Cargo.toml b/crates/flutterdec-loader/Cargo.toml index a836bcd..d839b2a 100644 --- a/crates/flutterdec-loader/Cargo.toml +++ b/crates/flutterdec-loader/Cargo.toml @@ -8,6 +8,8 @@ license.workspace = true anyhow.workspace = true goblin.workspace = true regex.workspace = true +serde.workspace = true +serde_json.workspace = true zip.workspace = true [dev-dependencies] diff --git a/crates/flutterdec-loader/src/dart_profile.rs b/crates/flutterdec-loader/src/dart_profile.rs new file mode 100644 index 0000000..5757de5 --- /dev/null +++ b/crates/flutterdec-loader/src/dart_profile.rs @@ -0,0 +1,194 @@ +//! Dart AOT snapshot layout profiles, resolved from the 32-byte snapshot hash. +//! +//! The snapshot hash is an MD5 over Dart VM serializer sources, so it pins the exact +//! snapshot layout a binary was built with. The mapping from hash to Dart version is +//! not derivable. It has to be tabulated by building each SDK, so the table is +//! vendored as data from `radareorg/r2flutter` (MIT); see `data/dart-profiles.json`. +//! +//! This module deliberately stops at *identification*. It reports which Dart version +//! and tag encoding a snapshot uses so `info`/`report.json` can say something true +//! instead of `unknown`, and so adapter output can be sanity-checked. It does not +//! deserialize snapshots. + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::LazyLock; + +/// How a Dart object header encodes its class id. Changes across SDK releases, and a +/// decoder that assumes the wrong one silently reads garbage class ids. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum TagStyle { + /// Dart 2.10 - 2.13: raw `int32` class id. + #[serde(rename = "CID_INT32")] + CidInt32, + /// Dart 2.14 - 3.3: `(cid << 1) | canonical`. + #[serde(rename = "CID_SHIFT1")] + CidShift1, + /// Dart 3.4.3+ (and the 2.18.2 outlier): packed `ObjectHeader` bitfield. + #[serde(rename = "OBJECT_HEADER")] + ObjectHeader, +} + +impl TagStyle { + pub fn as_str(self) -> &'static str { + match self { + TagStyle::CidInt32 => "CID_INT32", + TagStyle::CidShift1 => "CID_SHIFT1", + TagStyle::ObjectHeader => "OBJECT_HEADER", + } + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct DartProfile { + pub tag_style: TagStyle, + pub compressed_word_size: u32, + pub header_fields: u32, + pub max_alignment: u32, + pub heap_object_tag: u32, + /// Class ids for the handful of classes worth naming; they move between releases. + pub cids: HashMap, +} + +/// A profile plus the versions it was resolved through. +#[derive(Debug, Clone)] +pub struct ResolvedDartProfile { + /// Exact Dart version the snapshot hash was published under, e.g. `3.9.2`. + pub dart_version: String, + /// Profile bucket the layout was taken from, e.g. `3.9.0`. Layouts only change on + /// some releases, so buckets are sparse and resolved by floor. + pub profile_version: String, + pub profile: DartProfile, +} + +#[derive(Debug, Deserialize)] +struct ProfileTable { + hashes: HashMap, + profiles: HashMap, +} + +static TABLE: LazyLock = LazyLock::new(|| { + serde_json::from_str(include_str!("../../../data/dart-profiles.json")) + .expect("vendored data/dart-profiles.json is malformed") +}); + +/// Parse a dotted numeric version into comparable components. +fn parse_version(v: &str) -> Option<(u32, u32, u32)> { + let mut parts = v.split('.'); + let major = parts.next()?.parse().ok()?; + let minor = parts.next().unwrap_or("0").parse().ok()?; + let patch = parts.next().unwrap_or("0").parse().ok()?; + if parts.next().is_some() { + return None; + } + Some((major, minor, patch)) +} + +/// Greatest profile bucket that is `<= version`. +/// +/// Snapshot layouts only change on some SDK releases, so the table stores one entry +/// per change and every version in between inherits it. An exact-match lookup would +/// reject most real snapshots. +fn floor_profile(version: &str) -> Option<(&'static str, &'static DartProfile)> { + let want = parse_version(version)?; + TABLE + .profiles + .iter() + .filter_map(|(k, p)| parse_version(k).map(|parsed| (parsed, k.as_str(), p))) + .filter(|(parsed, _, _)| *parsed <= want) + .max_by_key(|(parsed, _, _)| *parsed) + .map(|(_, k, p)| (k, p)) +} + +/// Resolve a snapshot hash to its Dart version and layout profile. +/// +/// Returns `None` for hashes not in the table. Unknown is reported as unknown: a +/// guessed profile would be indistinguishable from a real one downstream. +pub fn profile_for_hash(snapshot_hash: &str) -> Option { + let key = snapshot_hash.trim().to_ascii_lowercase(); + let version = TABLE.hashes.get(&key)?; + let (profile_version, profile) = floor_profile(version)?; + Some(ResolvedDartProfile { + dart_version: version.clone(), + profile_version: profile_version.to_string(), + profile: profile.clone(), + }) +} + +/// Number of snapshot hashes the table can name. +pub fn known_hash_count() -> usize { + TABLE.hashes.len() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolves_a_known_modern_snapshot_hash() { + let r = profile_for_hash("97ff04a728735e6b6b098bdf983faaba") + .expect("hash present in vendored table"); + assert_eq!(r.dart_version, "3.9.2"); + assert_eq!( + r.profile_version, "3.9.0", + "3.9.2 inherits the 3.9.0 layout bucket" + ); + assert_eq!(r.profile.tag_style, TagStyle::ObjectHeader); + assert_eq!(r.profile.compressed_word_size, 4); + assert_eq!(r.profile.cids.get("object_pool"), Some(&23)); + } + + #[test] + fn resolves_a_known_legacy_snapshot_hash() { + // Dart 2.10 predates both the shift-1 and ObjectHeader tag encodings. + let r = profile_for_hash("8ee4ef7a67df9845fba331734198a953") + .expect("hash present in vendored table"); + assert_eq!(r.dart_version, "2.10.0"); + assert_eq!(r.profile.tag_style, TagStyle::CidInt32); + assert_eq!(r.profile.compressed_word_size, 8); + } + + #[test] + fn class_ids_move_between_releases() { + // The reason a single hardcoded CID table cannot serve every snapshot. + let old = profile_for_hash("8ee4ef7a67df9845fba331734198a953").unwrap(); + let new = profile_for_hash("97ff04a728735e6b6b098bdf983faaba").unwrap(); + assert_ne!(old.profile.cids["class"], new.profile.cids["class"]); + assert_ne!( + old.profile.cids["object_pool"], + new.profile.cids["object_pool"] + ); + } + + #[test] + fn unknown_hash_stays_unknown() { + assert!(profile_for_hash("ffffffffffffffffffffffffffffffff").is_none()); + assert!(profile_for_hash("unknown").is_none()); + } + + #[test] + fn floor_resolution_picks_the_greatest_bucket_at_or_below() { + // 3.7.x ships no layout change of its own and must inherit 3.6.0, not 3.9.0. + let (bucket, _) = floor_profile("3.7.4").expect("3.7.4 resolves"); + assert_eq!(bucket, "3.6.0"); + let (bucket, _) = floor_profile("2.18.1").expect("2.18.1 resolves"); + assert_eq!(bucket, "2.18.0"); + } + + #[test] + fn versions_below_the_oldest_bucket_do_not_resolve() { + assert!(floor_profile("2.9.0").is_none()); + assert!(floor_profile("not-a-version").is_none()); + } + + #[test] + fn every_tabulated_hash_resolves_to_a_profile() { + assert!(known_hash_count() >= 61); + for (hash, version) in &TABLE.hashes { + assert!( + floor_profile(version).is_some(), + "hash {hash} maps to version {version}, which resolves to no profile" + ); + } + } +} diff --git a/crates/flutterdec-loader/src/lib.rs b/crates/flutterdec-loader/src/lib.rs index afe1a93..035f4a3 100644 --- a/crates/flutterdec-loader/src/lib.rs +++ b/crates/flutterdec-loader/src/lib.rs @@ -8,6 +8,10 @@ use std::io::Read; use std::path::{Path, PathBuf}; use zip::ZipArchive; +pub mod dart_profile; + +use dart_profile::ResolvedDartProfile; + #[derive(Debug, Clone)] pub struct SnapshotBundle { pub input_path: PathBuf, @@ -20,6 +24,8 @@ pub struct SnapshotBundle { pub isolate_instr: Vec, pub vm_instr_va: u64, pub isolate_instr_va: u64, + /// Dart version and layout profile for `snapshot_hash`, when the hash is known. + pub dart_profile: Option, } #[derive(Debug, Clone)] @@ -234,6 +240,7 @@ fn from_elf(path: &Path, libapp_display: PathBuf, bytes: Vec) -> Result) -> Result`: APK or `libapp.so` - `--json`: print JSON output +Resolved from the snapshot hash alone, with or without an adapter, in both JSON and +plain output: + +- `dart_version` +- `dart_tag_style` (`CID_INT32`, `CID_SHIFT1`, or `OBJECT_HEADER`) + +Both are null for snapshot hashes outside `data/dart-profiles.json`. + If adapter metadata is available, JSON output also includes app-package hints: - `app_package_count_total` @@ -47,7 +55,7 @@ General options: - `--max-functions ` - `--function-scope ` (default `app-unknown`) - `--app-package ` (repeatable; restricts to selected `package:/...` libraries) -- `--adapter-backend ` (default `auto`) +- `--adapter-backend ` (default `auto`; `auto` tries r2flutter, then blutter, then internal) - `--require-snapshot-hash-match` (fail if adapter-reported snapshot hash differs from loader hash) Symbol ingestion: @@ -94,6 +102,9 @@ Target selection behavior: Adapter backend environment: +- `FLUTTERDEC_R2FLUTTER_BIN`: path to the `r2flutter` binary +- `FLUTTERDEC_R2FLUTTER_CMD`: full command to execute the r2flutter backend +- `FLUTTERDEC_R2FLUTTER_TIMEOUT`: per-invocation timeout in seconds (default 900) - `FLUTTERDEC_BLUTTER_CMD`: full command to execute Blutter bridge backend - `FLUTTERDEC_BLUTTER_PY`: path to `blutter.py` (uses current Python interpreter) @@ -115,7 +126,7 @@ Options: - `--function-scope ` (default `app-unknown`) - `--app-package ` (repeatable; limit compare set to selected app packages) -- `--adapter-backend ` (default `auto`) +- `--adapter-backend ` (default `auto`) - `--require-snapshot-hash-match` (fail if either side has adapter/loader snapshot hash mismatch) - `--json` diff --git a/docs/development.md b/docs/development.md index 81b1c63..1e2160f 100644 --- a/docs/development.md +++ b/docs/development.md @@ -11,6 +11,19 @@ nix develop `nix develop` also exports `FLUTTERDEC_BLUTTER_CMD` to a Nix-managed `flutterdec-blutter` wrapper, so `--adapter-backend blutter` can run without manual Blutter path wiring. +The r2flutter backend is not bundled. Build it once against radare2 and point +`FLUTTERDEC_R2FLUTTER_BIN` at the result: + +```bash +git clone https://github.com/radareorg/r2flutter && cd r2flutter +./configure --prefix="$HOME/.local" && make +export FLUTTERDEC_R2FLUTTER_BIN="$PWD/bin/r2flutter" +``` + +It is the only backend that yields exact function names and a real `ObjectPool` index +space, so it is worth having when working on the semantic/naming passes; the internal +adapter cannot exercise them. + Common commands: ```bash diff --git a/docs/how-it-works.md b/docs/how-it-works.md index 6c065be..dbeed5f 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -259,6 +259,7 @@ Produced by adapter. Main fields: - `classes[]` - `functions[]` - `object_pool[]` +- `pool_geometry` (optional; see "Pool index space" below) Schema compatibility: @@ -287,7 +288,7 @@ Produced by disassembler. Per function: - function metadata (`id`, `name`, `entry_va`, `size`) - decoded instruction list (`AsmInstruction[]`) -- per instruction annotation (`call`, `branch`, `return`, `pool[...]`, empty) +- per instruction annotation (`call`, `branch`, `return`, `pool[]`, `poolOff[]`, empty) Example instruction: @@ -374,13 +375,28 @@ The current Python template adapter: - guesses libraries from `package:...dart` strings - recovers function starts with simple ARM64 heuristics - builds object pool from extracted strings -- can run in `auto|internal|blutter` backend mode (Blutter first in `auto` when configured) +- can run in `auto|internal|blutter|r2flutter` backend mode (`auto` tries r2flutter, then Blutter, then internal) - in Blutter mode, parses `asm/*.dart` and `pp.txt` and synthesizes `EntryPointCandidate` pool metadata for `main`/`runApp`-like functions -- emits schema version 2 JSON +- in r2flutter mode, shells out to `r2flutter -jH/-ji/-jc/-jxz/-jzz/-jp` and maps the AOT instruction table, class table, and ObjectPool-referenced strings onto the model +- emits schema version 3 JSON + +### Pool index space + +`ObjectPoolEntry.index` must be a real `ObjectPool` entry index, i.e. the value a +`ldr xN, [x27, #disp]` resolves to. An adapter asserts this by emitting +`pool_geometry` (`entries_offset`, `word_size`); core then converts displacements with +`index = (disp - entries_offset) / word_size`. + +Adapters that cannot recover the pool layout must omit `pool_geometry`. The internal +adapter is one: its `object_pool` is carved strings numbered by carve order, which has +nothing to do with the hardware index space. Core detects the absence and skips pool +value/semantic hints entirely rather than joining two unrelated index spaces, which +would attach real-looking strings to the wrong slots. `report.json.pool_metadata` +records `index_space_authoritative`, the geometry, and `hints_suppressed_reason`. Validation in Rust enforces: -- schema version equals 2 +- schema version is 2 or 3 - arch equals `arm64` - non-empty function list @@ -416,7 +432,15 @@ Important behavior: - jump - conditional branch - return -- detects pool loads from `ldr` patterns on `x27` and annotates as `pool[index]` +- detects pool loads and annotates them with the resolved entry, `pool[index]` + - direct form: `ldr xN, [x27, #disp]` + - page form: `add xD, x27, #K, lsl #S` followed by `ldr xN, [xD, #off]`, which Dart + emits whenever the displacement exceeds the load-immediate range and which is the + majority of pool traffic in real binaries + - page bases are tracked per function and dropped on any write to the register or on + control flow, so a stale base can never invent a slot + - without `pool_geometry` the annotation is `poolOff[]` instead, + which is honest about what is known and never matches a value hint Filtering behavior: diff --git a/docs/research-decisions.md b/docs/research-decisions.md index 9d4ffd2..e6a57fc 100644 --- a/docs/research-decisions.md +++ b/docs/research-decisions.md @@ -5,6 +5,32 @@ - Adapter language: Python for fast per-hash parser updates. - Scope baseline: Android ARM64 AOT static-first, correctness prioritized over breadth. - Quality gates: strict defaults to prevent low-confidence pseudocode output. +- Snapshot metadata: prefer an external snapshot-aware backend at the adapter boundary + over reimplementing Dart's clustered deserializer in core. +- Version tables: vendor as data, never as code. + +## Third-party data: `data/dart-profiles.json` + +Maps 61 Dart AOT snapshot hashes to 19 layout profiles (Dart version, object-header +tag style, compressed word size, class-id table). Imported from +[radareorg/r2flutter](https://github.com/radareorg/r2flutter) (MIT), `offsets.json`. + +Why vendor rather than derive: the snapshot hash is an MD5 over Dart VM serializer +sources, so the hash-to-version mapping cannot be computed from a binary. It has to be +tabulated by building every SDK release, which is exactly the kind of maintenance work +worth sharing instead of duplicating. + +Why data and not code: it costs nothing to keep current, has no build or runtime +dependency, and stays useful no matter which backend parses the snapshot. `flutterdec` +uses it for identification only (`info.dart_version`, `report.json.dart_profile`); it +does not deserialize snapshots with it. + +Two facts from that table constrain any future in-tree parser, including a native one: + +- there are three object-header tag encodings, not one (`CID_INT32` for Dart 2.10-2.13, + `CID_SHIFT1` for 2.14-3.3, `OBJECT_HEADER` for 3.4.3+ and the 2.18.2 outlier) +- class ids move between releases, so a `#[repr(u32)]` enum of class ids can only ever + be correct for one profile; the mapping has to be a runtime table ## North Star diff --git a/docs/user-guide.md b/docs/user-guide.md index 503b039..b4e7915 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -257,11 +257,30 @@ flutterdec decompile ./sample.apk -o ./out --analysis-profile light Adapter backend options: -- `--adapter-backend auto` (default): attempt Blutter bridge backend when configured, otherwise fallback to internal adapter +- `--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 if adapter snapshot hash does not match loader snapshot hash +Backend choice decides how much is actually recovered. The internal adapter carves +strings and scans prologues: every function comes out as `sub_` and its +`object_pool` is a list of carved strings, not real pool slots. `r2flutter` and +`blutter` parse the snapshot, so they return exact Dart names and a real `ObjectPool`. + +Only a backend that reports `pool_geometry` lets `flutterdec` turn a `pool[N]` +reference in the disassembly into a value. Without it, 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` diff --git a/schemas/adapter.schema.json b/schemas/adapter.schema.json index 5e58aeb..2787a7d 100644 --- a/schemas/adapter.schema.json +++ b/schemas/adapter.schema.json @@ -85,10 +85,20 @@ "confidence": { "type": "number", "minimum": 0.0, "maximum": 1.0 }, "source": { "type": "string", - "enum": ["vm", "blutter", "internal", "synthetic", "unknown"] + "enum": ["vm", "blutter", "r2flutter", "internal", "synthetic", "unknown"] } } } + }, + "pool_geometry": { + "description": "ObjectPool layout. Emit this only when object_pool[].index values are real ObjectPool entry indices, i.e. when a `ldr xN, [x27, #disp]` resolves to (disp - entries_offset) / word_size. Adapters that carve strings out of the snapshot must omit it; the core then declines to map pool references to values instead of guessing.", + "type": "object", + "required": ["entries_offset", "word_size"], + "additionalProperties": true, + "properties": { + "entries_offset": { "type": "integer", "minimum": 0 }, + "word_size": { "type": "integer", "minimum": 1 } + } } } }