From 8b1805ace1f4473bcba8ad5239e86812300c4356 Mon Sep 17 00:00:00 2001 From: Camilo Vera Vidales Date: Sat, 25 Jul 2026 11:51:09 -0400 Subject: [PATCH 01/12] feat(adapter): add pool_geometry to the model contract `object_pool[].index` has always been ambiguous: nothing in the contract said whether it was an ObjectPool entry index or some adapter-private numbering. Add an optional `pool_geometry` record (`entries_offset`, `word_size`) that an adapter emits to assert the former. Presence of the record is the assertion. Adapters that only carve strings out of the snapshot omit it, which lets core tell the two cases apart instead of assuming. --- crates/flutterdec-adapter/src/lib.rs | 35 +++++++++++++++++++ .../src/pipeline/apk_startup.rs | 1 + .../src/pipeline/runners/tests.rs | 17 +++++++++ crates/flutterdec-disasm-arm64/src/lib.rs | 21 +++++++++++ schemas/adapter.schema.json | 10 ++++++ 5 files changed, 84 insertions(+) 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-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/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-disasm-arm64/src/lib.rs b/crates/flutterdec-disasm-arm64/src/lib.rs index b4b4990..a3eef09 100644 --- a/crates/flutterdec-disasm-arm64/src/lib.rs +++ b/crates/flutterdec-disasm-arm64/src/lib.rs @@ -1415,6 +1415,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 +1489,7 @@ mod tests { name_kind: None, }, ], + pool_geometry: None, object_pool: vec![ObjectPoolEntry { index: 0, kind: "String".to_string(), @@ -1561,6 +1563,7 @@ mod tests { name_kind: None, }, ], + pool_geometry: None, object_pool: vec![ObjectPoolEntry { index: 0, kind: "String".to_string(), @@ -1619,6 +1622,7 @@ mod tests { name_kind: None, }, ], + pool_geometry: None, object_pool: vec![ObjectPoolEntry { index: 0, kind: "String".to_string(), @@ -1677,6 +1681,7 @@ mod tests { name_kind: None, }, ], + pool_geometry: None, object_pool: vec![ ObjectPoolEntry { index: 0, @@ -1749,6 +1754,7 @@ mod tests { name_kind: None, }, ], + pool_geometry: None, object_pool: vec![ObjectPoolEntry { index: 0, kind: "String".to_string(), @@ -1807,6 +1813,7 @@ mod tests { name_kind: None, }, ], + pool_geometry: None, object_pool: vec![ObjectPoolEntry { index: 0, kind: "String".to_string(), @@ -1865,6 +1872,7 @@ mod tests { name_kind: None, }, ], + pool_geometry: None, object_pool: vec![ObjectPoolEntry { index: 0, kind: "String".to_string(), @@ -1923,6 +1931,7 @@ mod tests { name_kind: None, }, ], + pool_geometry: None, object_pool: vec![ObjectPoolEntry { index: 0, kind: "String".to_string(), @@ -1996,6 +2005,7 @@ mod tests { name_kind: None, }, ], + pool_geometry: None, object_pool: vec![ ObjectPoolEntry { index: 0, @@ -2077,6 +2087,7 @@ mod tests { name_kind: None, }, ], + pool_geometry: None, object_pool: vec![ ObjectPoolEntry { index: 0, @@ -2180,6 +2191,7 @@ mod tests { name_kind: None, }, ], + pool_geometry: None, object_pool: vec![ ObjectPoolEntry { index: 0, @@ -2280,6 +2292,7 @@ mod tests { name_kind: None, }, ], + pool_geometry: None, object_pool: vec![ObjectPoolEntry { index: 0, kind: "String".to_string(), @@ -2416,6 +2429,7 @@ mod tests { name_kind: None, }, ], + pool_geometry: None, object_pool: vec![ObjectPoolEntry { index: 0, kind: "String".to_string(), @@ -2474,6 +2488,7 @@ mod tests { name_kind: None, }, ], + pool_geometry: None, object_pool: vec![ObjectPoolEntry { index: 0, kind: "String".to_string(), @@ -2541,6 +2556,7 @@ mod tests { name_kind: None, }, ], + pool_geometry: None, object_pool: vec![ObjectPoolEntry { index: 0, kind: "String".to_string(), @@ -2627,6 +2643,7 @@ mod tests { name_kind: None, }, ], + pool_geometry: None, object_pool: vec![ObjectPoolEntry { index: 0, kind: "String".to_string(), @@ -3031,6 +3048,7 @@ mod tests { libraries: Vec::new(), classes: Vec::new(), functions, + pool_geometry: None, object_pool: Vec::new(), }; let owner_library = HashMap::from([ @@ -3101,6 +3119,7 @@ mod tests { name_kind: None, }, ], + pool_geometry: None, object_pool: vec![ObjectPoolEntry { index: 0, kind: "String".to_string(), @@ -3173,6 +3192,7 @@ mod tests { name_kind: None, }, ], + pool_geometry: None, object_pool: vec![ObjectPoolEntry { index: 0, kind: "String".to_string(), @@ -3232,6 +3252,7 @@ mod tests { name_kind: None, }, ], + pool_geometry: None, object_pool: vec![ObjectPoolEntry { index: 0, kind: "String".to_string(), diff --git a/schemas/adapter.schema.json b/schemas/adapter.schema.json index 5e58aeb..b3758b4 100644 --- a/schemas/adapter.schema.json +++ b/schemas/adapter.schema.json @@ -89,6 +89,16 @@ } } } + }, + "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 } + } } } } From 6e9e40790d56bed4f6266c1a664d222f93679381 Mon Sep 17 00:00:00 2001 From: Camilo Vera Vidales Date: Sat, 25 Jul 2026 11:51:35 -0400 Subject: [PATCH 02/12] fix(disasm): resolve pool loads in the ObjectPool index space `ldr xN, [x27, #disp]` was annotated `pool[disp]`, using the raw byte displacement as if it were an entry index. It is not: the entry is `(disp - entries_offset) / word_size`. Downstream joins that number against `object_pool[].index`, so pool-backed values could be attached to the wrong slot. Also recognise the page form, `add xD, x27, #K, lsl #S` followed by `ldr xN, [xD, #off]`, which Dart emits once the displacement outgrows the load immediate. On a Dart 3.9.2 sample that form is 13903 of the 19604 pool loads across 60 functions, and none of them were annotated at all before. Page bases are tracked per function and dropped on control flow and on any write to the register, including both destinations of load-pair forms, so a stale base cannot invent a slot. Without `pool_geometry` there is no index space to map into, so the annotation is `poolOff[]`, which states what is actually known and never matches a value hint. Regression tests use instruction encodings taken from a real libapp.so, with expected indices cross-checked against an independent ObjectPool decoder. --- crates/flutterdec-disasm-arm64/src/lib.rs | 385 ++++++++++++++++++++-- crates/flutterdec-ir/src/lib.rs | 5 +- 2 files changed, 366 insertions(+), 24 deletions(-) diff --git a/crates/flutterdec-disasm-arm64/src/lib.rs b/crates/flutterdec-disasm-arm64/src/lib.rs index a3eef09..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 { 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(); From 70b38356b0a57bc5c27669291e6c42ab1218c688 Mon Sep 17 00:00:00 2001 From: Camilo Vera Vidales Date: Sat, 25 Jul 2026 11:51:40 -0400 Subject: [PATCH 03/12] fix(decompiler): stop reporting pool displacements as entry indices Page-based pool loads the disassembler's register tracker cannot follow still arrive here as `((pool + /* lsl #N */)).f` text. That text carries a byte displacement; the old code divided it by 8 and emitted `pool[]`, which is the right scale but skips `entries_offset`, so it landed two slots early and picked up whichever hint lived there. This layer has no pool geometry, so it cannot convert at all. Report the displacement as `poolOff[]` instead: unresolvable references stay unresolved rather than resolving to a neighbour. The two expression tests encoded the old arithmetic; they now assert the displacement. `resolves_shifted_pool_target_to_symbol_call_name` was asserting that this path resolves a semantic hint, which is exactly the behaviour being removed, so it becomes `residual_shifted_pool_syntax_reports_displacement_and_does_not_resolve`. Symbol resolution through a pool target is still covered via the normal `pool[N]` path in call_and_loops.rs. --- .../flutterdec-decompiler/src/helpers/expr.rs | 14 +++++++---- .../src/passes/expr_cleanup.rs | 6 +++-- .../readability_and_naming.rs | 23 +++++++++++-------- 3 files changed, 28 insertions(+), 15 deletions(-) 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..de7f36b 100644 --- a/crates/flutterdec-decompiler/src/passes/expr_cleanup.rs +++ b/crates/flutterdec-decompiler/src/passes/expr_cleanup.rs @@ -314,13 +314,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/emit_and_helpers/readability_and_naming.rs b/crates/flutterdec-decompiler/src/tests/emit_and_helpers/readability_and_naming.rs index a030256..069eeca 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!( From efe461f0175a5eede0c9a208c0e9f825216d16f0 Mon Sep 17 00:00:00 2001 From: Camilo Vera Vidales Date: Sat, 25 Jul 2026 11:51:44 -0400 Subject: [PATCH 04/12] fix(core): gate pool hints on an authoritative index space Pool value and semantic hints were built from `object_pool[].index` regardless of what that number meant. The internal adapter numbers its entries by string carve order, which has nothing to do with the hardware index space, so every hint it produced was a real string from the binary attached to a slot that never referenced it. Build hints only when the adapter reported `pool_geometry`. A missing string literal is recoverable; a plausible wrong one is not, because nothing in the output distinguishes it from a correct one. `report.json.pool_metadata` now carries `index_space_authoritative`, the geometry, and `hints_suppressed_reason`, so a run with no pool literals explains itself. --- .../flutterdec-core/src/pipeline/runners.rs | 29 ++++++++++++++++--- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/crates/flutterdec-core/src/pipeline/runners.rs b/crates/flutterdec-core/src/pipeline/runners.rs index 003408c..465e920 100644 --- a/crates/flutterdec-core/src/pipeline/runners.rs +++ b/crates/flutterdec-core/src/pipeline/runners.rs @@ -1293,8 +1293,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 +1322,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() @@ -1983,7 +1990,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, From c43faa9d165fa6981fcbd4f846c691726832d732 Mon Sep 17 00:00:00 2001 From: Camilo Vera Vidales Date: Sat, 25 Jul 2026 11:52:05 -0400 Subject: [PATCH 05/12] fix(decompiler): stop rewriting inside string literals The expression cleanups scan raw text, so they treat the contents of a string as code. A real pool string from a Flutter engine build reads "... the native peer has been collected (nullptr). This is usually ..." and `(nullptr).` is a parenthesised member access to a byte scanner, so the simplifier edited the parentheses out of a string that came out of the binary. Skip over string literals in both member-access simplifiers. Recovered strings are program data; a readability pass has no business rewriting them, and a silently altered string is indistinguishable from a correctly recovered one. --- .../src/passes/expr_cleanup.rs | 31 +++++++++++++++++++ .../alias_and_expr_cleanup.rs | 24 ++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/crates/flutterdec-decompiler/src/passes/expr_cleanup.rs b/crates/flutterdec-decompiler/src/passes/expr_cleanup.rs index de7f36b..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; 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}" + ); +} From 99baf01bfef2c54de86002e4358463d658908935 Mon Sep 17 00:00:00 2001 From: Camilo Vera Vidales Date: Sat, 25 Jul 2026 11:52:09 -0400 Subject: [PATCH 06/12] feat(decompiler): resolve pool strings at every use, not just call arguments Only call arguments were run through the pool hint, so a slot whose value we knew still printed as a bare `pool[N]` everywhere else: t2.f19 = pool[40]; if ((obj4.f31) == pool[893]) { return pool[5122]; Resolve the hint when a register is read as a whole value, which covers assignments, comparisons and returns from one place. On the Dart 3.9.2 sample this takes rendered literals from 15 to 24 across 80 functions and leaves 3 of the 27 known-value slots unresolved, down from 12. Dereferences deliberately keep the slot: `pool[40].f7` reads a field of the pooled object, so rendering the string there would claim a field access on a literal. Those still get `pool[40 /* "onError" */].f7`. `annotate_pool_refs` is now idempotent, since operands can reach it already resolved and it would otherwise nest a second hint comment inside the first. --- .../src/control_flow/emit.rs | 7 + .../src/control_flow/expression_lift.rs | 20 ++- .../readability_and_naming.rs | 127 ++++++++++++++++++ 3 files changed, 150 insertions(+), 4 deletions(-) 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/tests/emit_and_helpers/readability_and_naming.rs b/crates/flutterdec-decompiler/src/tests/emit_and_helpers/readability_and_naming.rs index 069eeca..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 @@ -1235,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() { From f6ea7228271417290400cf13f4de34d4c9476cb0 Mon Sep 17 00:00:00 2001 From: Camilo Vera Vidales Date: Sat, 25 Jul 2026 11:52:29 -0400 Subject: [PATCH 07/12] fix(core): cap artifact file name length `decompile` aborts with `File name too long (os error 36)` as soon as an adapter returns real Dart names. The longest name in a Dart 3.9.2 sample is 305 bytes, well past the 255-byte NAME_MAX, and mangled mixin and generic names routinely get there. Cap the sanitized stem at 160 bytes. Artifact names are already prefixed with the unique function id, so truncation cannot collide. Only unreachable today because every name the internal adapter produces is `sub_`. --- .../flutterdec-core/src/pipeline/helpers.rs | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) 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__"); + } } From 641fbe18eb48347baee42617907d01fbceaaf5bf Mon Sep 17 00:00:00 2001 From: Camilo Vera Vidales Date: Sat, 25 Jul 2026 11:52:34 -0400 Subject: [PATCH 08/12] feat(loader): resolve the Dart version from the snapshot hash We already read the snapshot hash and then report `dart_version: unknown`, which is a waste: the hash is an MD5 over Dart VM serializer sources, so it pins the exact SDK release and snapshot layout. The hash-to-version mapping cannot be computed from a binary, it has to be tabulated by building every SDK. Vendor that table as data from radareorg/r2flutter (MIT): 61 hashes over 19 layout profiles, 11 KB, embedded with `include_str!`. Resolution is by floor, matching how the table is built. Hashes are filed under exact versions (3.6.2) while profiles are keyed by the release that last changed the layout (3.6.0), so an exact-match lookup would reject most real snapshots. Identification only. Unknown hashes stay unknown rather than falling back to a guessed profile, since a guess would be indistinguishable from a real answer downstream. --- Cargo.lock | 2 + crates/flutterdec-loader/Cargo.toml | 2 + crates/flutterdec-loader/src/dart_profile.rs | 194 +++++++++ crates/flutterdec-loader/src/lib.rs | 8 + data/dart-profiles.json | 412 +++++++++++++++++++ 5 files changed, 618 insertions(+) create mode 100644 crates/flutterdec-loader/src/dart_profile.rs create mode 100644 data/dart-profiles.json 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/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 Date: Sat, 25 Jul 2026 11:52:40 -0400 Subject: [PATCH 09/12] feat(core): report the resolved Dart profile in info and report.json `info` now returns `dart_version` and `dart_tag_style` with no adapter installed and no disassembly, in both JSON and plain output, and `report.json` gains a `dart_profile` section with the profile bucket, compressed word size, header field count and alignment. `dart_tag_style` is the useful one for triage: `CID_INT32`, `CID_SHIFT1` and `OBJECT_HEADER` are three incompatible object-header encodings, and it is the first thing to check when a snapshot parser misbehaves on an older build. Both fields are null for hashes outside the bundled table. --- crates/flutterdec-cli/src/main.rs | 6 ++++++ crates/flutterdec-core/src/lib.rs | 5 +++++ crates/flutterdec-core/src/pipeline/runners.rs | 16 ++++++++++++++++ docs/cli-reference.md | 8 ++++++++ 4 files changed, 35 insertions(+) diff --git a/crates/flutterdec-cli/src/main.rs b/crates/flutterdec-cli/src/main.rs index a8f906f..cfedd5e 100644 --- a/crates/flutterdec-cli/src/main.rs +++ b/crates/flutterdec-cli/src/main.rs @@ -266,6 +266,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); diff --git a/crates/flutterdec-core/src/lib.rs b/crates/flutterdec-core/src/lib.rs index bd1c52b..58c1b9f 100644 --- a/crates/flutterdec-core/src/lib.rs +++ b/crates/flutterdec-core/src/lib.rs @@ -199,6 +199,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/runners.rs b/crates/flutterdec-core/src/pipeline/runners.rs index 465e920..71bdc1e 100644 --- a/crates/flutterdec-core/src/pipeline/runners.rs +++ b/crates/flutterdec-core/src/pipeline/runners.rs @@ -992,6 +992,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, @@ -1767,6 +1775,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 diff --git a/docs/cli-reference.md b/docs/cli-reference.md index ad83986..228d7ef 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -13,6 +13,14 @@ Arguments: - ``: 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` From f793b71c3cb1e0c4d64767a3146a1d523314217f Mon Sep 17 00:00:00 2001 From: Camilo Vera Vidales Date: Sat, 25 Jul 2026 11:53:00 -0400 Subject: [PATCH 10/12] feat(adapter): add an r2flutter backend The internal adapter carves strings and scans prologues, so it cannot name a single function and has no real ObjectPool. r2flutter (MIT, radareorg) deserializes the AOT snapshot, so wire it up at the existing adapter process boundary rather than growing a snapshot parser in core. Maps `-ji` (instruction table) to functions with exact names, `-jc` to classes, `-jxz` to pool entries keyed by the slot index r2flutter reports per string, `-jzz` to library URIs for scope filtering, and `-jp` to pool geometry. Pool entries come with their authoritative `entry=`, so nothing here is positional. When `-jp` cannot resolve the pool image, which happens on older and non-compressed snapshots, geometry and pool entries are both dropped instead of being guessed, and the key is omitted rather than set to null so the payload still validates. Every invocation is bounded by FLUTTERDEC_R2FLUTTER_TIMEOUT (default 900s). Six run per model build and each loads the whole binary, so an unbounded wait would hang the core behind a wedged radare2. `auto` order becomes r2flutter, blutter, internal, each falling through when its tooling is absent. Resolved via FLUTTERDEC_R2FLUTTER_BIN, FLUTTERDEC_R2FLUTTER_CMD, or PATH. On a Dart 3.9.2 sample: 37258 exactly named functions and 8986 classes, against 7458 `sub_*` placeholders and 1 synthetic class from the internal adapter. Optional fields are typed concretely in the schema, so the explicit nulls the adapters have always emitted (`target_va`, `selector`, `owner_class`, and now `pool_geometry`) fail validation where an absent key passes. Strip nulls on the way out; both the internal and r2flutter payloads validate against schemas/adapter.schema.json for the first time. --- adapters/python/adapter_template.py | 283 +++++++++++++++++++++++++++- schemas/adapter.schema.json | 2 +- 2 files changed, 279 insertions(+), 6 deletions(-) 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/schemas/adapter.schema.json b/schemas/adapter.schema.json index b3758b4..2787a7d 100644 --- a/schemas/adapter.schema.json +++ b/schemas/adapter.schema.json @@ -85,7 +85,7 @@ "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"] } } } From 4e4eeedbad74dcf62408468cd1d53cde5dec49d1 Mon Sep 17 00:00:00 2001 From: Camilo Vera Vidales Date: Sat, 25 Jul 2026 11:53:06 -0400 Subject: [PATCH 11/12] feat(cli): add --adapter-backend r2-flutter Expose the r2flutter backend through the same selection path as blutter, so it can be forced or excluded rather than only reached through `auto`. Both `r2-flutter` and the tool's own spelling `r2flutter` are accepted, since the docs, `report.json` and the environment variables all use the latter. `backend_label` was a second match arm listing every variant next to `AdapterBackend::as_str`; fold it into the one that already exists so a new backend cannot be added to one and forgotten in the other. --- crates/flutterdec-cli/src/main.rs | 27 +++++++++++++++++-- crates/flutterdec-core/src/lib.rs | 6 +++++ .../flutterdec-core/src/pipeline/runners.rs | 7 ++--- docs/cli-reference.md | 4 +-- 4 files changed, 37 insertions(+), 7 deletions(-) diff --git a/crates/flutterdec-cli/src/main.rs b/crates/flutterdec-cli/src/main.rs index cfedd5e..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, } } } @@ -751,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", @@ -770,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 58c1b9f..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", } } } diff --git a/crates/flutterdec-core/src/pipeline/runners.rs b/crates/flutterdec-core/src/pipeline/runners.rs index 71bdc1e..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", } } diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 228d7ef..059ec81 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -55,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: @@ -123,7 +123,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` From 2d8f301625ec2c4c4cc048ae47b3223c56fcbfdf Mon Sep 17 00:00:00 2001 From: Camilo Vera Vidales Date: Sat, 25 Jul 2026 11:53:07 -0400 Subject: [PATCH 12/12] docs: document the pool index space, Dart profiles and the r2flutter backend Write down the pool index contract in context.md and how-it-works.md, since it is the part that was implicit and therefore got it wrong: what `object_pool[].index` means, that `pool_geometry` is how an adapter claims it, and why core refuses to resolve references without it. Cover the r2flutter backend and its environment variables across the README, user guide, CLI reference and development guide, with a table of what each backend actually recovers so the internal adapter's limits are stated up front rather than discovered. Record the vendoring rationale for data/dart-profiles.json and credit r2flutter and blutter in the README. Correct the stale claim that core only accepts schema version 2. Also flags a follow-up: the README pipeline showcase assets were rendered before the pool index fix, so their `pool[...]` mappings come from the old index space and need regenerating before they can be cited as evidence. --- README.md | 43 ++++++++++++++++++++++++++++++++++++- context.md | 44 ++++++++++++++++++++++++++++++++++++-- docs/cli-reference.md | 3 +++ docs/development.md | 13 +++++++++++ docs/how-it-works.md | 34 ++++++++++++++++++++++++----- docs/research-decisions.md | 26 ++++++++++++++++++++++ docs/user-guide.md | 21 +++++++++++++++++- 7 files changed, 175 insertions(+), 9 deletions(-) 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/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/docs/cli-reference.md b/docs/cli-reference.md index 059ec81..bb847c0 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -102,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) 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`