{"id":44882588,"url":"https://github.com/zboralski/unflutter","last_synced_at":"2026-04-02T21:40:08.884Z","repository":{"id":338266700,"uuid":"1157362267","full_name":"zboralski/unflutter","owner":"zboralski","description":"Static analyzer for Flutter/Dart AOT snapshots","archived":false,"fork":false,"pushed_at":"2026-02-17T19:22:45.000Z","size":280,"stargazers_count":30,"open_issues_count":0,"forks_count":4,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-02-18T18:50:45.788Z","etag":null,"topics":["aot","dart","decompiler","disassembler","flutter","reverse-engineering","reversing","static-analysis","static-analyzer"],"latest_commit_sha":null,"homepage":"","language":"Go","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/zboralski.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-02-13T18:23:22.000Z","updated_at":"2026-02-18T00:05:31.000Z","dependencies_parsed_at":"2026-02-18T17:00:31.223Z","dependency_job_id":null,"html_url":"https://github.com/zboralski/unflutter","commit_stats":null,"previous_names":["zboralski/unflutter"],"tags_count":6,"template":false,"template_full_name":null,"purl":"pkg:github/zboralski/unflutter","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/zboralski%2Funflutter","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/zboralski%2Funflutter/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/zboralski%2Funflutter/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/zboralski%2Funflutter/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/zboralski","download_url":"https://codeload.github.com/zboralski/unflutter/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/zboralski%2Funflutter/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":29626591,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-02-19T13:04:20.082Z","status":"ssl_error","status_checked_at":"2026-02-19T13:03:33.775Z","response_time":117,"last_error":"SSL_connect returned=1 errno=0 peeraddr=140.82.121.6:443 state=error: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["aot","dart","decompiler","disassembler","flutter","reverse-engineering","reversing","static-analysis","static-analyzer"],"created_at":"2026-02-17T16:31:23.343Z","updated_at":"2026-02-19T18:00:44.545Z","avatar_url":"https://github.com/zboralski.png","language":"Go","funding_links":[],"categories":[],"sub_categories":[],"readme":"# unflutter\n\nStatic analyzer for Flutter/Dart AOT snapshots. Recovers function names, class hierarchies, call graphs, and behavioral signals from `libapp.so`, without embedding or executing the Dart VM.\n\n## Why Not Blutter\n\n[Blutter](https://github.com/aspect-sec/blutter) solves Flutter reverse engineering by embedding the Dart VM itself. It calls `Dart_Initialize`, creates an isolate group from the snapshot, and walks the deserialized heap with internal VM APIs. No Dart code from the snapshot is executed. The VM is used purely for introspection. But this still means Blutter must compile a matching Dart SDK for every target version and link against VM internals.\n\nunflutter takes a different path. No VM. No SDK compilation. The snapshot is a byte stream with a known grammar. We parse it directly.\n\nThe tradeoff: Blutter gets perfect fidelity because it deserializes through the VM's own code paths. unflutter gets portability, speed, and the ability to analyze snapshots from any Dart version without building anything version-specific. The cost is that every format change across Dart versions must be handled explicitly in our parser. There is no runtime to fall back on.\n\n## Design\n\nConstraint elimination. We treat the snapshot as a deterministic binary grammar.\n\n```\nOmega = all possible interpretations of the byte stream\n\nC = {\n  ELF invariants,\n  snapshot magic (0xf5f5dcdc),\n  version hash (32-byte ASCII),\n  CID table (class ID -\u003e cluster handler),\n  cluster grammar (alloc counts, fill encoding),\n  instruction layout (stubs + code regions)\n}\n\nR = Omega reduced by C\n```\n\nEach constraint narrows the space. ELF validation eliminates non-ARM64 binaries. The snapshot magic eliminates non-Dart data. The version hash selects exactly one CID table and tag encoding. Cluster alloc counts fix the object population. Fill parsing recovers field values within that fixed population. What survives all constraints is the analysis result.\n\n```\nif |R| == 0  → HALT: overconstrained (bug in our model)\nif |R| \u003e 1   → HALT: underdetermined (missing constraint)\nif |R| == 1  → COMMIT: the answer\n```\n\nNo heuristics. No runtime fallback. No inference outside constraints.\n\n## How It Works\n\n### Snapshot reconstruction\n\nDart AOT snapshot = two-phase serialization: **alloc** then **fill**.\n\n**Alloc** walks clusters in CID order. Each cluster declares how many objects of that class exist. This assigns sequential reference IDs to every object. No data is read yet, just counts.\n\n**Fill** walks the same clusters again. This time it reads the actual field values: string bytes, reference IDs pointing to other objects, integer scalars. The fill encoding varies by object type and Dart version.\n\nWe replay both phases from raw bytes. The alloc phase gives us the object census. The fill phase gives us names, strings, and cross-references. Combined with the instructions table (which maps code objects to their machine code offsets), we recover the full function-name-to-address mapping that Blutter gets from the VM API.\n\n### Code recovery\n\nThe isolate instructions image contains two regions:\n\n**Stubs** (indices 0 through `FirstEntryWithCode-1`): runtime trampolines (type-check handlers, allocation stubs, dispatch helpers) placed before user code.\n\n**Code** (indices `FirstEntryWithCode` onward): user functions and framework code. Each Code object maps to a PC offset via the instructions table.\n\nWe resolve both regions, producing a complete function map that covers the entire executable range.\n\n### ARM64 disassembly and call edges\n\nEach function's code bytes are decoded instruction-by-instruction using `arm64asm.Decode`. Branch detection handles B, B.cond, CBZ, CBNZ, TBZ, TBNZ, RET, all from raw 32-bit encodings.\n\n**CFG construction** follows a 3-phase algorithm:\n1. Collect block leaders: instruction 0, branch targets, instructions after terminators\n2. Sort and partition into basic blocks\n3. Walk blocks, compute successor edges from terminal instructions\n\n**Call edge extraction** distinguishes two kinds:\n\n- **BL (direct call)**: decode target address from imm26 field, resolve to function name via symbol map\n- **BLR (indirect call)**: resolve target register provenance via `RegTracker` (sliding window W=8)\n\nThe register tracker traces how BLR target registers get their values:\n\n| Provenance | Pattern | Description |\n|------------|---------|-------------|\n| PP (object pool) | `LDR Xt, [X27, #imm]` | X27 is the pool pointer. Pool index = byte_offset / 8 |\n| THR (thread) | `LDR Xt, [X26, #imm]` | X26 is the thread pointer. Resolved via version-specific offset maps |\n| Peephole PP | `ADD Xd, X27, #hi; LDR Xt, [Xd, #lo]` | Two-instruction PP for large pool indices |\n| Dispatch table | `LDR Xn, [X21, Xm, LSL #3]` | X21 is the dispatch table register |\n\nEach BLR gets annotated with its provenance (e.g., `PP[42] Widget.build`, `THR.AllocateArray_ep`, `dispatch_table`).\n\n### Graph construction\n\nCall edges and CFGs are converted to [lattice](https://github.com/zboralski/lattice) types, an architecture-neutral graph IR shared with SpiderMonkey-dumper (for JS bytecode analysis). The lattice library provides DOT rendering.\n\n### Decompilation (Ghidra + IDA)\n\nBoth decompilers share a common metadata pipeline. `flutter-meta` generates `flutter_meta.json` with function names, class struct layouts, THR fields, string references, and pointer size metadata. Each decompiler's script consumes this file.\n\n**Ghidra** (`unflutter decompile`) runs a headless pipeline:\n\n1. Pre-script registers a `__dartcall` calling convention via `SpecExtension` (marks X15/X26-X28 as unaffected, kills scratch registers)\n2. Post-script applies all metadata:\n   - Disassembles at all known function addresses\n   - Creates/renames functions\n   - Creates Dart class struct types with correct field sizes (4-byte for compressed pointers, 8-byte otherwise)\n   - Creates a `DartThread` struct (200 fields) for THR (X26) accesses\n   - Applies typed function signatures (`this` pointer, parameter count, return type)\n   - Sets EOL comments for THR fields, PP pool references, and string literals\n   - **Register retyping**: renames decompiler variables for Dart-specific registers and types X26 as `DartThread*`, enabling struct field resolution:\n\n| Register | Variable | Purpose |\n| -------- | ------------------- | ----------------------------------------------- |\n| X15      | `SHADOW_SP`         | Dart shadow call stack                          |\n| X21      | `DT`                | Dispatch table pointer                          |\n| X22      | `DART_NULL`         | Dart null object                                |\n| X26      | `THR` (DartThread*) | Thread pointer, field accesses resolve to names |\n| X27      | `PP`                | Object pool pointer                             |\n| X28      | `HEAP_BASE`         | Compressed pointer base                         |\n| X29      | `FP`                | Frame pointer                                   |\n| X30      | `LR`                | Link register                                   |\n\n**IDA** (`unflutter ida`) runs via idalib (headless):\n\n1. Generates C header with all struct types, parsed via `idc_parse_types()` in one shot\n2. Creates functions with Dart checked/unchecked entry point splitting (splits IDA-merged functions at metadata addresses)\n3. Applies function signatures via `apply_type()` (IL2CppDumper pattern)\n4. Sets repeatable comments (visible in Hex-Rays decompiler)\n5. Hex-Rays register retyping (same register table as Ghidra)\n\n**Ghidra vs IDA output quality:**\n\nGhidra wins on readability: struct field resolution (`THR-\u003estack_limit` vs `THR + 72`), indexed access (`SHADOW_SP[-2]` vs `*(_QWORD*)(SHADOW_SP - 16)`), and no `_QWORD`/`_DWORD` casts.\n\nIDA wins on type cleanliness: zero `undefined` types, zero `unaff_` register names, zero warnings. IDA uses `__int64` and `_QWORD` casts which are verbose but type-correct.\n\nThe THR struct field resolution gap is a Hex-Rays microcode limitation. `set_lvar_type()` doesn't restructure the decompiler's AST to use struct member syntax.\n\n### Version handling\n\n| Dart | Tag Style | Pointers | Key change |\n|------|-----------|----------|------------|\n| 2.10.0 | CID-Int32 | Uncompressed | 4 header fields, pre-canonical-split |\n| 2.13.0 | CID-Int32 | Uncompressed | 5 header fields, split canonical |\n| 2.14.0 | CID-Shift1 | Uncompressed | CID shifted into uint64 tag |\n| 2.15.0 | CID-Shift1 | Uncompressed | NativePointer CID inserted |\n| 2.16.0 | CID-Shift1 | Uncompressed | ConstMap/ConstSet added |\n| 2.17.6 | CID-Shift1 | Uncompressed | Last unsigned-ref version |\n| 2.18.0 | CID-Shift1 | Compressed | Signed refs, compressed pointers |\n| 2.19.0 | CID-Shift1 | Compressed | 64-byte alignment |\n| 3.0.5-3.3.0 | CID-Shift1 | Compressed | Progressive CID table changes |\n| 3.4.3-3.10.7 | ObjectHeader | Compressed | New tag encoding, record types |\n\nNo version-conditional architecture. The version hash selects a constraint set. Same pipeline runs.\n\n## Build and Install\n\nRequires Go 1.24+. One external dependency: `golang.org/x/arch` (ARM64 instruction decoding).\n\n```bash\nmake build          # build ./unflutter binary\nmake install        # install binary to /usr/local/bin, scripts to ~/.unflutter/\nmake test           # run tests\n```\n\nGhidra integration requires Ghidra 11.x with Jython support. Auto-detected from `GHIDRA_HOME`, `PATH`, or common brew locations.\n\n## Usage\n\n### Full pipeline (default)\n\n```bash\nunflutter libapp.so\n```\n\nRuns ELF parse, disassembly, signal analysis, and metadata generation in one shot:\n\n```text\nelf Dart SDK 3.10.7\n\ncode 284352 bytes at VA 0x569a8\n  instructions: 1465 entries (0 stubs + 1465 code)\n  ranges: 1465 (0 stubs + 1465 code)\n  classes: 402 layouts\n\ndisasm 1465 functions, pool 1511 entries (1318 resolved)\n  functions: 1465 -\u003e samples/evil-patched.unflutter/asm\n  call edges: 5937 (822 BLR: 757 annotated, 65 unannotated)\n  string refs: 620\n  BLR annotation: 92.1%\n\nsignal 71 signal + 1076 context, 4178 edges\n  net: 40\n  url: 4\n  base64: 1\n  cloaking: 1\n  asm snippets: 1142\n  -\u003e signal_graph.json (900218 bytes)\n  -\u003e signal.html (456296 bytes)\n  -\u003e signal.dot (5809 bytes)\n  -\u003e signal_cfg.dot (51 functions, 50855 bytes)\n  -\u003e signal.svg (18136 bytes)\n  -\u003e signal_cfg.svg (145979 bytes)\n\nmeta 1465 functions\n  focus: 71 signal functions (use --all for everything)\n  dart: 3.10.7  ptr_size: 4  thr_fields: 272\n  classes: 402 layouts\n  comments: 1363 from asm files\n  string refs: +461 comments\n  -\u003e flutter_meta.json (577230 bytes)\n\nsummary\n  output:     samples/evil-patched.unflutter\n  dart:       3.10.7\n  functions:  1465\n  classes:    402\n  signal:     71\n\nnext\n  open samples/evil-patched.unflutter/signal.html\n  unflutter ghidra libapp.so --from samples/evil-patched.unflutter\n  unflutter ida libapp.so --from samples/evil-patched.unflutter\n```\n\nUse `--quiet` / `-q` to suppress verbose output. Use `--out` to set the output directory (default: `\u003cbasename\u003e.unflutter/`).\n\n### Quick scan\n\n```bash\nunflutter scan libapp.so           # print snapshot info\n```\n\n### Signal only (skip metadata)\n\nThe default pipeline already includes signal analysis. Use `unflutter signal` to run the same pipeline but skip the metadata generation stage:\n\n```bash\nunflutter signal libapp.so                    # default pipeline without meta\nunflutter signal libapp.so -k 3               # custom context depth (default: 2)\nunflutter signal libapp.so --from out/target   # rerun signal from existing disasm\n```\n\n### Ghidra decompilation\n\n```bash\nunflutter ghidra libapp.so                    # full pipeline + Ghidra headless\nunflutter ghidra libapp.so --from out/target   # reuse existing disasm output\nunflutter ghidra libapp.so --all               # decompile ALL functions\n```\n\n### IDA decompilation\n\n```bash\nunflutter ida libapp.so                       # full pipeline + IDA idalib\nunflutter ida libapp.so --from out/target      # reuse existing disasm output\nunflutter ida libapp.so --all                  # decompile ALL functions\n```\n\n### Metadata only\n\n```bash\nunflutter meta libapp.so                      # full pipeline, produce flutter_meta.json\nunflutter meta --from out/target               # regenerate from existing disasm\n```\n\n### Output artifacts\n\n| File | Description |\n|------|-------------|\n| `functions.jsonl` | Function records: name, address, size, owner, param count |\n| `call_edges.jsonl` | Call edges: BL/BLR with resolved targets and provenance |\n| `classes.jsonl` | Class layouts: fields, offsets, instance sizes |\n| `string_refs.jsonl` | String references from PP loads |\n| `dart_meta.json` | Snapshot metadata: Dart version, pointer size, THR fields |\n| `flutter_meta.json` | Unified metadata for Ghidra/IDA: functions, classes, THR fields, comments |\n| `asm/*.txt` | Annotated ARM64 disassembly per function |\n| `cfg/*.dot` | Per-function control flow graphs (with `--graph`) |\n| `callgraph.dot` | Full call graph (with `--graph`) |\n| `signal.html` | Behavioral signal report |\n| `decompiled/*.c` | Ghidra decompiled C output |\n\n## Architecture\n\n```\ninternal/\n  elfx/       ELF validation, ARM64 symbol extraction\n  snapshot/   Region extraction, header parsing, version profiles\n  dartfmt/    Dart VM stream encoding (variable-length integers)\n  cluster/    Two-phase snapshot deserialization (alloc + fill)\n  disasm/     ARM64 decode, CFG, call edge provenance, register tracking\n  callgraph/  Lattice graph builders (call graph + CFG)\n  signal/     Behavioral string classification\n  render/     HTML/DOT visualization\n  output/     JSONL serialization\n```\n\n### Pipeline\n\n```\nlibapp.so\n  → ELF parse (elfx)\n  → snapshot region extraction (snapshot)\n  → header + version detection (snapshot)\n  → cluster alloc scan (cluster)\n  → cluster fill parse (cluster)\n  → instructions table: stubs + code (cluster)\n  → ARM64 disassembly + CFG (disasm)\n  → call edge extraction with register tracking (disasm)\n  → lattice graph construction (callgraph)\n  → signal classification (signal)\n  → Ghidra metadata + decompilation (ghidra-meta / decompile)\n  → JSON / DOT / HTML artifacts\n```\n\nEach stage is a pure function from bytes to structured data. No mutable global state. No VM runtime. Same input, same output.\n\n## Known Limitations\n\n- **AOT only.** No JIT mode support.\n- **ARM64 only.** No x86 or RISC-V.\n- **No source reconstruction.** Output is function names, call edges, structs, strings, not Dart source.\n- **BLR tracking window.** Register provenance uses a sliding window (W=8). Complex register chains outside the window are unresolved.\n- **Dart 2.12.x not validated.** No samples available.\n- **Large signal graphs.** DOT files over 1 MB are skipped for SVG rendering. Graphviz `dot` uses O(n²) hierarchical layout that hangs on graphs with thousands of nodes. Use `sfdp -Tsvg` for large graphs.\n- **Every format change must be modeled.** There is no runtime to handle it automatically.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fzboralski%2Funflutter","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fzboralski%2Funflutter","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fzboralski%2Funflutter/lists"}