SuperAPI AI Directory

re-flutter

Data & analytics

View repository

Updated 10-15 21:34

About this skill

Flutter / Dart AOT reverse engineering (snapshot parsing / symbol recovery / VM dynamic analysis)

When to use / when not to use

  • Use: found inside an Android package lib/<abi>/libapp.so(Dart AOT snapshot) and libflutter.so(engine) — business logic is in libapp.so
  • Use: iOS App.framework/App(AOT snapshot embedded in Mach-O,Flutter.framework is the engine)
  • Use: debug build's assets/flutter_assets/kernel_blob.bin(Dart kernel binary, contains class/function names and AST)
  • Use: need to recover a Flutter App's Dart business logic (network interfaces, signing/encryption algorithms, protocols, MethodChannel channels)
  • Don't use: pure native Android App (no libapp.so / kernel_blob, go to [[re-apk]])
  • Don't use: pure native iOS App (no App.framework, go to [[re-ios]])
  • Don't use: Flutter Web (output is JS/WASM rather than a snapshot, go to [[re-wasm]])
  • Don't use: the engine layer itself (internal logic of libflutter.so / Flutter.framework, see step 4)
  • Note: dynamic steps are executed within a controlled device / emulator snapshot ([[re-analyze/platform-tips]] highest principle); static first (large sample principle)

Tool preparation

See [[re-analyze/platform-tips]] — Flutter artifacts are large (libapp.so starts at tens of MB), follow "static first (large samples)": locate statically first, supplement dynamically as needed; dynamic (running / repackaging the App) defaults to within a controlled device / emulator snapshot.

python3 (main workhorse for snapshot header / section parsing)

  • Linux: apt install python3 / dnf install python3 / pacman -S python
  • macOS: built-in; Windows: official installer or choco install python
  • Verify: python3 --version

binutils (readelf / strings / objcopy, snapshot locating and segment extraction)

  • Debian/Ubuntu: apt install binutils;Fedora: dnf install binutils;Arch: pacman -S binutils
  • macOS: built into Xcode command line tools (xcode-select --install)
  • Windows: no built-in binutils — use WSL, or Ghidra's built-in parsing instead
  • Verify: readelf --version && strings --version

Ghidra (disassembly / decompilation and cross-references for instruction segments)

  • Installation and verification see [[re-ghidra]]; without Ghidra, IDA / radare2 are equivalent ([[re-ida]] / [[re-radare2]])
  • Verify: after importing libapp.so, can locate the _kDart* symbol

frida (dynamic side, Dart VM entry hook)

  • Installation, frida-server deployment and version matching see [[re-frida]] tool preparation (host and device versions must match)
  • Verify: frida --version;frida-ps -U lists device processes

blutter (automatic AOT snapshot recovery, optional)

  • Automatic recovery of classes/functions/object pool for release libapp.so, installation and usage see [[re-hybrid-app]] tool preparation; step 2 of this skill uses manual parsing as the core method, blutter serves as an automated alternative and cross-check
  • Verify: output directory shows pp.txt and contains target App class names

Operating steps

Execute in order; archive each step's artifacts (segment extraction / parsing scripts / hook scripts + sha256).

  1. Identify build mode (debug / release):

    unzip -l app.apk | grep -E 'kernel_blob|libapp|libflutter'    # Android 容器内定位
    file lib/arm64-v8a/libapp.so
    readelf -sW lib/arm64-v8a/libapp.so | grep _kDart             # release: AOT 快照符号
    xxd -l 32 assets/flutter_assets/kernel_blob.bin               # debug: 前 4 字节应为 ef cd ab 90(kernel magic 0x90abcdef 的小端)
    readelf -sW libapp.so | grep -c _kDartIsolateSnapshot         # AOT 段符号(data/instructions),比按 ASCII 串搜可靠
    
    • debug: assets/flutter_assets/kernel_blob.bin exists (header is 8 bytes total = magic 0x90abcdef + formatVersion, Dart kernel binary, class/function names and AST preserved, low recovery cost) → go directly to step 3
    • release: no kernel_blob, business code is in libapp.so's _kDartIsolateSnapshotData(data) / _kDartIsolateSnapshotInstructions(instructions) → Step 2
    • iOS: Payload/<App>.app/Frameworks/App.framework/App(business) and Flutter.framework/Flutter(engine), same segment name within Mach-O,otool -l / strings locate
  2. Dart AOT snapshot partition parsing (release):

    objcopy --dump-section .rodata=snap.rodata libapp.so    # 数据段(含 VM + isolate 两份快照)
    objcopy --dump-section .text=snap.text libapp.so        # 指令段
    python3 - <<'PY'
    import struct
    data = open('snap.rodata', 'rb').read()
    MAGIC = 0xDCDCF5F5                          # Dart VM snapshot magic(int32;文件里是小端 f5 f5 dc dc)
    KINDS = {0: 'full', 1: 'full-jit', 2: 'full-aot'}
    off = 0
    while True:
        off = data.find(struct.pack('<I', MAGIC), off)
        if off < 0: break
        length, kind = struct.unpack('<qq', data[off+4:off+20])   # 基础头共 20 字节
        print('snapshot @', hex(off), 'kind:', KINDS.get(kind, kind), 'length:', length)
        off += 4
    PY
    
    • Header format:magic(4, 0xdcdcf5f5) + length(8) + kind(8) = 20-byte base header(followed by fields such as hashes).No ASCII "SNAPSHOT", and no fixed 12-byte version string—search by string SNAPSHOT on real AOT samples finds nothing
    • After the header is the version-dependent internal serialization layout(the order and structure of sub-blobs change with the Dart version),do not treat it as a stable format promise: for precise parsing, read against the target SDK version's runtime/vm/snapshot.h, serializer, and image snapshot implementation, rather than applying a hardcoded partition table
    • The machine code itself is ina separate instruction section(_kDartIsolateSnapshotInstructions), separate from the data section;kind is full-aot means an AOT snapshot
    • _kDartVmSnapshot* is the VM snapshot (standard libraries such as dart:core/io, shared by all Flutter apps); business code is only in _kDartIsolateSnapshot*
    • Extract business strings from the strings blob:
      strings -n 6 -t x snap.rodata | grep -E 'https?://|token|secret|api/|MethodChannel'
      
    • Import the instruction section into Ghidra: automatic analysis often fails to recognize AOT code objects, so function boundaries must be divided according to the target version's code object metadata (the sub-blob layout changes with the version, see the previous item); after decompilation, locate business functions by string cross-references
    • Automatic alternative: blutter (tool preparation) restores asm/object pool/class names; manual parsing is used for verification and version fallback (pitfall 5)
  3. Symbol restoration:

    • The build side left a mapping (--obfuscate --split-debug-info):
      # 构建命令: flutter build apk --obfuscate --split-debug-info=symbols/
      ls symbols/                               # app.android-arm64.symbols 等(每 ABI 一个)
      python3 -c "import json; d=json.load(open('symbols/app.android-arm64.symbols')); print(len(d['data']))"
      # JSON 结构: {"data": {"混淆名": "原名"}} —— 反向映射表,查表即得原名
      
    • When there is no mapping, infer by string cross-references: UI copy / error messages / URLs in the strings blob → Ghidra cross-references to find referencing instructions → expand upstream and downstream along the call chain (methods correspond to [[re-binary-core]] cross-references)
    • debug kernel_blob.bin: the kernel format contains class/function names and the AST; strings + structure parsing directly restores call relationships, without needing a symbol table
  4. Distinguishing engine and business code:

    • libflutter.so / Flutter.framework: Flutter engine (C++: Dart VM, Skia, channel implementation), basically identical across apps—do not analyze, only use it to get VM exported symbols (Step 5)
    • libapp.so / App.framework/App: Dart business code AOT snapshot—the analysis target
    • _kDartVmSnapshot*(VM snapshot / standard library) ≠ _kDartIsolateSnapshot*(business): business strings and class names are only in the isolate snapshot
    • Native plugins and the native side of MethodChannel are outside the business so, use [[re-binary-core]]
  5. Dynamic side (inside a controlled device / emulator snapshot, [[re-analyze/platform-tips]] default sandbox):

    • Hook the Dart VM class library registration point—exported by libflutter.so Dart_CreateRootLibrary(root library registration entry, onEnter reads the library URI):
      var f = Process.getModuleByName("libflutter.so").findExportByName("Dart_CreateRootLibrary");
      if (f) Interceptor.attach(f, {
        onEnter: function (args) {
          var uri = args[1].readCString();     // 库 URI(C 字符串)
          console.log("root library: " + uri);
        }
      });
      
      • Under release AOT this C API may not trigger (the library is deserialized directly from the snapshot)—instead hook the VM internal dart::Library::New(symbol within libflutter.so, trimmed depending on version) or directly use the VM Service enumeration below
    • Dart VM Service enumerate isolate / libraries (release requires enabling the VM service first: hook Dart_SetVMFlags / Dart_InitIsolate inject --enable-vm-service, or switch to a debug build to verify dynamic behavior):
      adb forward tcp:8181 tcp:8181
      curl http://127.0.0.1:8181/getVM
      curl "http://127.0.0.1:8181/getIsolate?isolateId=isolates/1"
      curl "http://127.0.0.1:8181/getInstalledLibraries?isolateId=isolates/1"
      
      • getInstalledLibraries List all business libraries (library name = package name / path);getObject Enumerate classes/fields by objectId; class names and strings in the VM service metadata are still readable
    • After locating the target function, hook by address (_kDartIsolateSnapshotInstructions base address + code object offset); for the instrumentation foundation see [[re-frida]]

Cross-domain combination

  • [[re-mobile]]: Mobile App analysis gateway—the Flutter branch is handled by [[re-hybrid-app]] and this skill (container-side static and dynamic run-through goes through the gateway)
  • [[re-hybrid-app]]: Flutter snapshot automatic restoration (blutter / reFlutter) and MethodChannel interception—complements this skill (manual snapshot parsing + VM-layer dynamic)
  • [[re-apk]] / [[re-ios]]: Android / iOS container-side static (unpacking, manifest, signature, DEX/ObjC native bridging)
  • [[re-frida]]: Dynamic instrumentation foundation (version matching, spawn/attach, anti-detection, script templates)
  • [[re-binary-core]]: Native layer of libapp.so / libflutter.so ([[re-format-elf]] + [[re-ghidra]] decompilation engine and JNI bridge)

Common Pitfalls and Traps

  • release has no kernel_blob.bin: Symptom—following a debug tutorial to find assets/flutter_assets/kernel_blob.bin cannot be found; Cause—the build mode determines the artifacts:Debug → JIT (kernel binary, business code in kernel_blob),Profile and Release are both AOT(business code in the snapshot section of libapp.so)—Profile does not rely on kernel_blob to execute business code, and its difference from Release is profiling/service/optimization configuration rather than the execution model; Countermeasure—first determine the mode according to step 1: no kernel_blob → go to step 2 snapshot partition parsing, do not keep searching in assets
  • Short names after obfuscate/tree-shaking: Symptom—function/class names in the snapshot are all a/b and other short names or are directly missing; Cause—--obfuscate obfuscates names + tree-shaking removes unreferenced code; Countermeasure—if the build side has a --split-debug-info mapping file, look it up directly (step 3 JSON); if there is no mapping, use the strings blob's copy/URLs as anchors to cross-reference and infer the call chain in reverse; on the dynamic side, hook key functions to observe behavior as a complement
  • Engine and business code confusion: Symptom—analysis gets stuck deep in libflutter.so's dart:: internal functions or the _kDartVmSnapshot* section (dart:core/io standard library), wasting time; Cause—the engine layer and VM snapshot are shared code across all Flutter Apps, not business code; Countermeasure—analyze only the _kDartIsolateSnapshot*of libapp.so for business; from libflutter.so take only the VM exports (steps 4/5)
  • frida version mismatch: Symptom—frida-ps -U reports a protocol error / unable to communicate; Cause—the host frida-tools and device frida-server version numbers are inconsistent, or the architecture does not match; Countermeasure—strictly prepare according to [[re-frida]] tooling:frida --version download the same version of frida-server by comparison, choose the correct architecture (arm64/arm/x86_64)
  • Snapshot version does not match the parsing script: Symptom—partition offsets are misaligned, strings blob contents are garbled, or parsing is interrupted; Cause—snapshot layouts differ between Dart versions (the version string is visible at the header), and manual/third-party scripts only cover specific versions; Countermeasure—read the header version string first; branch parsing by version; when the version is too new, fall back to generic methods (magic scanning + partition semantic inference)
  • hook reads Dart strings as garbled text: Symptom — hooking a Dart object pointer but printing garbled text; Cause — Dart strings are not C strings (at object pointer +7 offset there are 4 bytes of Smi-encoded length, right-shift by 1 bit gives the real length, from +15 onward is UTF-8 data; this offset is a community-measured value); Countermeasure — write a readDartString utility function that reads according to this layout and add a length cap (for layout details see the [[re-hybrid-app]] pitfall entry);the offset drifts with the Dart version— rely on the authoritative offset exported by gen_snapshot --print-object-layout-to or on measurement; don't copy hardcoded offsets on a different version
  • Object pool deserialization treated as fixed-width read (VLE): Symptom — blutter fails on snapshots of custom engine / structurally rearranged builds (No cluster defined for cid N or empty output), and afterward a self-written parser reads refs misaligned and all strings garbled; Cause — the Dart AOT object pool is a tagged array of entries (X27 holds the pool base address,LDR Xn,[X27,#off] loads slot number off/8), and in the fill area each entry has 1 byte of type (older versions TypeBits = eb & 0x7f,PatchableBit = 0x80), type 0 (kTaggedObject) carries a VLE ref (variable-length),type 1 (kImmediate) payload is a fixed-width integer (intptr_t, not VLE)(a common pitfall); unsigned VLE has 7 data bits per byte, with the last segment having bit7 set (5→0x85,27673→19 58 81);Note the above type numbering/bit widths are the layout from the Dart 2.9 era; in current versions the TypeBits bit count and enum order have changed — before parsing, obtain the layout table according to the snapshot version, don't apply it across versions; Countermeasure — after VLE-reading the length at the start, decode entry by entry by type, slot_offset = idx × 8; first validate with 2–4 known ref→offset anchors before trusting the full set, and when an anchor doesn't match, narrow the scan window and retry; the object pool is the symbol table of unsigned binaries —pool_offset → {加载它的所有 PC} → {所属函数}, enabling "which functions touch string ref N" to be answered statically (source: reverse-skills (inliver233), MIT)
  • strings cannot find Chinese text: Symptom — running strings/grep on libapp.so cannot find Chinese/CJK/emoji business text, and the lead goes dead; Cause — Dart snapshot strings have two storage forms: OneByteString (Latin-1/UTF-8, ordinary grep works) and TwoByteString (UTF-16LE); Chinese/CJK/emoji are basically all in TwoByteString, so matching a UTF-8 byte sequence against UTF-16LE storage of course won't hit;CID numbers drift with the Dart version(older versions 85/86, current 3.x is 93/94/95 etc.), don't hardcode the CID — read it dynamically from the snapshot class table; Countermeasure — for the target string, search both UTF-8 and UTF-16LE encodings at the same time, recording offset + encoding + occurrence count;--dump-utf16 fully export CJK strings then grep; in the object pool a function's Code is adjacent to the strings it loads, so when unsigned you can guess string↔function attribution by proximity within the pool (source: reverse-skills (inliver233), MIT)
  • Pool slot strings cannot find cross-references: Symptom — you know a string's offset in the pool, but the decompiler's cross-reference list is empty and you can't locate the referencing function; Cause — there are only two standard idioms for fetching from the pool:ADD xR,x27,#page,LSL#12 followed by LDR xD,[xR,#off](offset=(page<<12)+off), and when page==0 the direct LDR xD,[x27,#off]; generic xref analysis often misses this kind of indirect addressing; Countermeasure — write a pattern scanner using raw bytes to match the two idioms (no decompiler needed), and for function attribution use prologue features (fd 79 bf a9 fd 03 0f aa) to reverse-scan and locate the function start; first calibrate with one known load point before trusting the full set;"scanner didn't find it" ≠ "no loader exists"— Loads with non-standard encodings will be missed, and negative results require cross-validation with a second method; a string loaded by exactly one function is feature-dedicated and is a strong clue for a functional UI builder (source: reverse-skills (inliver233), MIT)
  • build/virtual method callers=0 misjudged as dead code: Symptom—performing a BL xref scan on build or virtual methods yields 0 callers, and they are misjudged as dead code and abandoned; Cause—Dart build/virtual methods are not called via BL, but via dispatch stubs: read object cid → look up dispatch table → blr x30 virtual dispatch, which static BL scanning naturally cannot see; Countermeasure—callers=0 is the norm and is not evidence of dead code; finding callers must be dynamic: capture at the dispatch stub or the target function's onEnter lr + Thread.backtrace(Backtracer.ACCURATE) to locate the dispatch point (the "while rebuilding dirty elements" frame is the framework rendering path; the business builder has already returned); the stub puts cid in x0 (low 16 bits) and the object pointer in x1; the object field @1 also stores the cid as a fallback; after obtaining the cid, statically search movk xR,#<cid>,lsl#16 for the constructor, whose BL caller is the parent build; X21 = dispatch table base; when the whole table needs to be covered, combine it with a static cid→constructor mapping (source: reverse-skills (inliver233), MIT)
  • Dart bool treated as 0/1: Symptom—when decompiling AOT code, bool is read as an ordinary 0/1 integer, and the logic never lines up no matter how you go back and forth; Cause—Dart AOT has fixed register conventions: X15=shadow-stack, X26=Thread, X27=Pool, X22=null sentinel, X28=heap base (compressed pointers), X21=dispatch table base;Dart bool is a tagged pointer to a canonical object, not 0/1—True is at a low offset from null, False at a higher offset (with uncompressed pointers, True = null+0x20 (the low 16 bits often look like …8061), False = null+0x30 (…8071);with compressed pointers (the Flutter release default), True = null+0x10 (…8051), False = null+0x18 (…8059); the consumer side tbz w0,#4(with compressed pointers, #3)bit=0 goes to the true branch—the one whose test bit is 0 is True; Countermeasure—read the disassembly according to the fixed register semantics (mov x0,x22 returns Dart null; the standard prologue stp x29,x30,[x15,#-16]!; mov x29,x15, byte fd 79 bf a9 fd 03 0f aa); the bool encoding can be cracked by instruction frequency:add xR,x22,#0x20(with compressed pointers, #0x10) appears thousands of times (True),add xR,x22,#0x30(compressed #0x18) likewise (False); offsets/test bits fluctuate with pointer compression and version, so rely on actual measurement or --print-object-layout-to ; the Thread global G = Thread[x26+0x80], fields are read by offset; the allocation thunk pattern mov x2,#imm; movk x2,#cid,lsl16; ldr x4,[x26,#0x228]; br x4(source: reverse-skills (inliver233), MIT)
  • Patching to materialize false with movz w0,#0 crashes (SIGILL): Symptom—using a static patch or a Frida runtime memory write with movz w0,#0(byte 52800000) to materialize "false" causes a runtime crash with signal 4 (SIGILL); Cause—Dart bool is a tagged pointer, and 0 is an illegal immediate tag; Countermeasure—materialize True with add x0,x22,#0x20(byte c0820091), False with +0x30(under compressed pointers this is True +0x10、False +0x18); this rule applies to both static patches and Frida runtime memory writes; when writing a Dart bool, always write the canonical address; the encoding fluctuates with the pointer compression mode and version, so rely on actual measurement (source: reverse-skills (inliver233), MIT)
  • Popup patch ineffective (three-source triage): Symptom — after NOP-ing out all showDialog calls, the dialog still appears as before, or the dialog appears on a timer of about 60 seconds, and repeated retries become a time sink; Cause — Flutter dialogs have only three sources, and mis-triage is the biggest waste: (a) explicit showDialog BL; (b) uncaught exceptions — MissingPluginException → FlutterError.onError/zone onError → asynchronous dialog, not going through any showDialog BL; (c) integrity aggregation gate — one aggregation function ORs about 7 checks, and if any is true it shows a dialog; Countermeasure — if logcat + Dart stack frames show MissingPluginException/Unhandled Exception and it appears on a timer → source (b), and the correct fix is to truncate the fault-body constructor (change the conditional branch that builds the body to an unconditional skip), so the global handler has no content to display,stop NOP-ing the dialog; dialog text → pool ref → body builder → caller tracing, callers=0 (cid dispatch) and no showDialog BL → also source (b); for source (c), find the aggregation function (references many check*/get* channel method strings + conditions → dialog construction), NOP its aggregation failure branch (CMP W0,W22; B.EQ fail) or the entry-null dialog construction helper; when a patch works on one device but not another, use the text content to locate the newly triggered check category (e.g. validation prompt text corresponds to environment/emulator checks) (Source: reverse-skills (inliver233), MIT)
  • Treating the kernel format magic as ASCII KERNEL: Symptom — judging by grepping/xxd-ing on the basis that "the file header is KERNEL" kernel_blob.bin, or searching the container for kernel files by that string, yields no hits or hits something else; Cause — the magic of the Dart kernel binary isthe numeric value 0x90abcdef(in the source runtime/vm/kernel_binary.h of kMagicProgramFile), which appears in the file as little-endian bytes ef cd ab 90,and is not an ASCII string at all; the header is 8 bytes total (magic + formatVersion), followed by the format version number, which can be used for version discrimination; Countermeasure — judge by the 4-byte magic number, do not use string search
  • Finding the AOT snapshot by ASCII SNAPSHOT : Symptom —: Symptom —grep SNAPSHOT/data.find(b'SNAPSHOT') gets zero hits on a real libapp.so, so you conclude "there is no snapshot" or switch to guessing offsets by string; Cause — the header of a Dart VM snapshot isthe numeric magic 0xdcdcf5f5(4B) + length(8B) + kind(8B) 20-byte base header,there is no ASCII SNAPSHOT, nor a fixed 12-byte version string; the sub-blob layout after the header isa version-dependent internal serialization implementation, which cannot be treated as a stable format; Countermeasure — locate by 0xdcdcf5f5(little-endian f5 f5 dc dc) and read length/kind (kind being full-aot means AOT snapshot); for precise parsing, consult the target SDK version's runtime/vm/snapshot.h and serializer implementation; the machine code itself is in a separate instruction section
  • Tens of thousands of unsigned functions, no idea where to start: Symptom — libapp.so is unsigned, with about twenty thousand functions, so directly digging through the disassembly is like looking for a needle in a haystack; Cause — when unsigned, function boundaries, string ownership, and call relationships all depend on static reconstruction, lacking priority signals; Countermeasure — use prologue signature bytes (fd 79 bf a9 fd 03 0f aa) to reverse-scan within .text (window about 0x6000–0x8000 bytes) to first recover function boundaries; build a feature vector per function: the set of strings accessed (dominant signal), BL targets/caller fan-in ((word>>26)==0b100101 decode imm26), branch point after gate (TBZ/TBNZ/B.EQ), null comparison (CMP W0,W22/MOV X0,X22), bool return flag; the four sorters reduce 20,000 → shortlist: keyword string shortlist → total access count (keyword-class strings ≥2 → orchestrator) → dual-anchor set intersection (intersection of the access function sets of the title string + body string → precise builder, 0 or 1 function) → BL call fan-in upward iteration;caller count = risk metric——high counts (shared-state functions) are avoided as patch points, dedicated handlers with a count of 1 are safe targets; the aggregate score of high cmp + conditional branch count is used to surface integrity gates/aggregate predicates (source: reverse-skills (inliver233), MIT)