# Dart AOT (Flutter) — reaching the layer that actually owns the logic Load this when: the target is a Flutter app (`libflutter.so` + `libapp.so`) and the behaviour you must change is decided **inside** the Dart snapshot, not in the dex. The Java side of a Flutter app is a thin plugin shell; a dex-only plan will stall (`framework-runtimes.md` §the layer trap). Everything below assumes you already confirmed the runtime is Flutter and that Java hooks fire **zero times** while the UI plainly works. --- ## 1. Pin the Dart version before choosing any tool The snapshot format is version-specific. A decompiler built for another Dart version may run and produce plausible-but-wrong output, which is worse than failing. ```python import re blob = open('libflutter.so', 'rb').read() # the engine, not libapp.so m = re.search(rb'(\d+\.\d+\.\d+ \(stable\)[^\x00]{0,60})', blob) print(m.group(1).decode() if m else 'not found') # e.g. b'3.9.0 (stable) (Mon ...) on "android_arm64"' ``` If the string is absent, fall back to the snapshot hash the loader reports at runtime, or to the engine build id in `libflutter.so`'s version string. ## 2. Get a decompiler that matches that version **blutter** is the practical choice for Android `libapp.so` (arm64). It reconstructs the object pool and per-class declarations. It is a *source tree you build*, not a binary you download — and its prebuilt outputs only cover the Dart versions someone already built. The version question is the first obstacle, and it has a clean answer: - blutter's git HEAD typically supports newer Dart than its shipped `dartsdk/` directory. - When the target's version is missing, blutter fetches it itself: it sparse-clones `dart-lang/sdk` at that version tag (only `runtime`, `tools`, `third_party/double-conversion`), generates a source list, and builds a `dartvm` static library for the target ABI. - That build needs a real toolchain: `cmake` (>=3.20 is fine, including 4.x), `ninja`, `git`, and a C++ compiler with C++20 `` support (VS 2022 on Windows, gcc>=13 / clang>=16 elsewhere). A `vcvars64`-style environment must be active before the build, and `CMakeLists` in the tree pins `cmake_minimum_required(3.20)`. - Budget **tens of minutes** for the first build of a new version and run it as a background job; the compiler log is enormous (per-file include trees), so read only its tail. Outputs of interest: | Output | What it gives you | What it does *not* give | |---|---|---| | `pp.txt` | every object-pool entry with its `pp+0x…` offset: strings, types, closures, fields, stubs | who references what | | `objs.txt` | reconstructed object shapes with type annotations | code | | `asm/` | per-class declarations | **instructions — there are none here** | | `ida_script/`, `blutter_frida.js` | symbol/annotation helpers | a finished analysis | > `asm/` having one file per (obfuscated) class invites the assumption that it holds disassembly. > It does not. Do not plan around it. **`product` builds carry no debug info.** Expect `no-code_comments`, no function names, and obfuscated identifiers. That is normal; the strings still survive (see §7). ## 3. The object pool is the whole game Dart AOT uses **compressed pointers**: heap references are 32-bit offsets from a base held in a dedicated register. Constants, strings, types, closures and field metadata live in one contiguous **object pool**, reached through that register. Two offset spaces exist and they are **not the same number** — mixing them wastes hours: - **PP offsets** (`pp+0x…`) — what `pp.txt` and every pool-load in disassembly use. - **file offsets** — where a byte actually sits in `libapp.so` on disk. Keep the mapping explicit in your notes. When a tool reports "this string is at X", state which space X is in. ## 4. Build your own reference index (blutter will not give you this) `pp.txt` tells you what is in the pool. To *find the code that uses it* you need pool-offset → referencing-instruction-address. Generate it once, then query it constantly: ``` python dart_pprefs.py libapp.so pp_refs.json python dart_pprefs.py --lookup pp_refs.json 0x1d1a8 0xc9d0 ``` **Do not build this index with a full-capstone pass.** Decoding every instruction of a multi-MB `.text` with `detail=True` and querying `regs_access()` per instruction costs minutes of CPU and 1–2 GB of peak memory, and on a 16 GB host it presents as a hang with no progress output. Only three encodings can read the pool, so decode them from the raw 32-bit words instead — the shipped script does exactly that and finishes in seconds. The same argument applies to building a call graph (§9): decode `B`/`BL` arithmetically. ## 5. What the registers mean Stable across Dart 3.x arm64 AOT. Verify once on a known function, then rely on it. | Register | Role | |---|---| | `x27` | **object pool base (PP)** — every constant/string/type load goes through it | | `x26` | current thread (`[x26,#0x38]` stack limit, `[x26,#0x68]` isolate group) | | `x28` | heap base — how compressed pointers are decompressed | | `x22` | **null/base for booleans** (see §6) | | `x15` | Dart's own stack pointer (not the system SP) | | `x21` | class dispatch table (virtual calls load a target from it) | ## 6. Booleans are not 0/1 `true` and `false` are objects immediately adjacent to null, so they appear as small fixed offsets from `x22`: ```asm add x0, x22, #0x20 ; construct TRUE add x0, x22, #0x30 ; construct FALSE tbnz w0, #4,