Locate Dart instruction images without symbols on stripped Mach-O

The four kDart*Snapshot* symbols are external/global and load-bearing (the engine
resolves them at runtime), so they survive strip; but obfuscators/repackagers can
remove them, leaving only the two data snapshots findable by magic scan
(instruction images carry no magic) and iso_instr=0, which disables IT name
recovery.

Add locate_instr_images_structural(): vm_instr = start of the first executable
section; read the Dart Image header (word0=ImageSize) and chain
iso_instr = align_up(vm_instr + ImageSize, 64). Every step is cross-checked
(plausible headers; the isolate image must end where the first data image
begins), so an unknown/gapped layout degrades to 0 (no IT names) rather than junk
addresses. No-op when symbols already resolved the instructions.

Verified against symbol-derived ground truth: Runner (0x4000/0xe700, full 7671
IT entries recovered) and AuthPass (0x5a80/0xfdc0); the gapped macOS hello.aot
safely falls back to 0. Symbol-present bins unchanged; suite green (95 db/cmd,
3 extras, 35 custom).

Adds scripts/strip_macho_symbols.py (zeroes LC_SYMTAB + dyld export trie, since
strip cannot) and test/db/cmd/discovery-stripped-ios which builds the fixture
from the committed Runner App and asserts structural recovery. Remaining
follow-ups tracked in COMMIT2.md.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
pancakeandClaude Opus 4.8 committed 2026-08-11 12:32:01 +02:00
1 parent cd6b655c0c
commit 986e2cb42a
4 files changed
+160 -77

No files matched your search

-77
View File
@@ -1,77 +0,0 @@
# COMMIT 2 — Locate Dart instruction images without symbols (stripped Mach-O)
Follow-up to commit 1 (`Harden Dart snapshot discovery...`). Commit 1 removed the
junk-`iso_instr` risk and hardened `find_snapshots`, but on a genuinely
symbol-less binary it deliberately leaves `vm_instr`/`iso_instr = 0` (instruction
images carry no magic, so the scan cannot find them). This file records what
commit 2 must do and everything we learned so we don't re-derive it.
## Goal
When the four `kDart*Snapshot*` symbols are absent, still recover `vm_instr` and
`iso_instr` structurally so InstructionTable-based name recovery works (it is
gated behind `if (!ctx->iso_instr) return 0;` in `dart_pool_parse.c`).
## Key findings from the commit-1 investigation (don't redo these)
- **Only data snapshots carry the magic `0xf5f5dcdc`.** Instruction images do
not, so magic-scanning can only ever locate `vm_data` / `iso_data`. Confirmed:
`test/bins/ios/Runner.app/.../App` has exactly 2 magic hits (both data).
- **Layout (Runner.app, ground truth from the symbol table):**
- `_kDartVmSnapshotInstructions` @ `0x4000` — start of `__TEXT.__text` (`r-x`)
- `_kDartIsolateSnapshotInstructions` @ `0xe700` — inside `__TEXT.__text`
- `_kDartVmSnapshotData` @ `0x1b0900` — start of `__TEXT.__const`
- `_kDartIsolateSnapshotData` @ `0x1b9600` — inside `__TEXT.__const`
So instruction images live consecutively at the start of the first executable
section: `[VmInstructions][IsolateInstructions]`.
- **The Dart snapshot symbols are load-bearing and survive `strip`.** They are
external/global (the Flutter engine looks them up at runtime), so `llvm-strip`,
`llvm-strip -x`, and `strip` all leave them in place (`strip` even returns rc=1).
Consequence: a *functioning* Flutter app almost always keeps these symbols, so
the symbol path already works for normal apps. The symbol-less case is an
**edge case** (obfuscators, repackagers, hand-stripped LC_SYMTAB), which is why
this is a lower-frequency follow-up, not urgent.
## Proposed approach
1. `vm_instr` = start (`vaddr`) of the first executable (`perm & R_PERM_X`) section
that is not a segment. On the samples this is `__TEXT.__text`.
2. Read the Dart **instructions-image header** at `vm_instr` to get the VM
instructions payload length, then `iso_instr = vm_instr + align_up(len, alignment)`.
- Need to nail the instructions-image header format (`Image::kHeaderSize` /
the leading length/size field) from `third_party/dart-sdk/runtime/vm/image_snapshot.{h,cc}`.
Cross-check the computed `iso_instr` against the ground-truth `0x4000`→`0xe700`
gap for Runner (i.e. VM-instructions length ≈ `0xe700 - 0x4000 = 0xa700`).
- Fallback if the header can't be parsed: leave `iso_instr = 0` (current safe
behavior) rather than guess.
3. Only run this when `vm_instr`/`iso_instr` are still 0 after symbols + data scan,
so it never overrides symbol-derived values.
## Test / fixture strategy (the hard part)
- `strip`/`llvm-strip` do NOT remove the global Dart symbols, so we cannot make a
fixture with the CLI strip tools. Options, in order of preference:
1. Obtain a **real** obfuscated/stripped Flutter iOS `App` where the symbols are
actually gone (best signal). **TODO: collect more iOS Flutter binaries** —
especially release/obfuscated ones — into `test/bins/ios/` for coverage.
2. Craft a fixture by rewriting a copy of an existing `App` to drop the four
symbols from `LC_SYMTAB` (e.g. an r2 script that zeroes/removes the nlist
entries), keeping the snapshot bytes intact.
- Regression assertion: on the symbol-less fixture, `-H -j` yields
`vm_data`/`iso_data` from the scan AND `vm_instr=0x4000` / `iso_instr=0xe700`
from structural location, and `-i`/`-f` recover IT entries/names.
- Also add a scan-path assertion for commit 1 itself once a fixture exists:
data found, `instr` cleanly 0 (no junk), no crash.
## Also deferred here (small, separate robustness items from the audit §3.1)
- **Single-data-snapshot aliasing** (`pick_vm_iso_by_size`, count==1 sets
`vm_data == iso_data`). Visible on the synthetic bin (`vm_data==iso_data==4`).
The `sbom-synthetic` golden currently *encodes* this (`"vm_data":4,"iso_data":4`).
Fixing it (assign the lone snapshot to `iso_data`, leave `vm_data=0`) requires
updating that golden — do it alongside commit 2 and decide the correct semantics.
- Raw-file fallback scan (`find_snapshots`, the `r_io_size` branch) conflates
physical offsets with IO addresses when a bin is loaded with maps but has
zero/unnamed sections; only fully correct under `r2 -n`. Low priority.
## Files likely touched
- `src/lib/dart_pool_discovery.c` — structural instruction-image location.
- possibly a small helper to read the instructions-image header (new, or in
`dart_pool_snapshot.c`).
- `test/bins/ios/...` — new stripped/obfuscated fixture(s).
- `test/db/cmd/` — new discovery test(s).
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env python3
"""Write a copy of a Mach-O with its symbol table and dyld export trie removed.
Used to build symbol-less test fixtures: the four kDart*Snapshot* symbols are
external/global and load-bearing (the Flutter engine looks them up at runtime),
so strip(1)/llvm-strip cannot remove them. This neutralises LC_SYMTAB (nsyms=0),
LC_DYLD_INFO[_ONLY] (export_off/size=0) and LC_DYLD_EXPORTS_TRIE (datasize=0) in
every arm64 slice (or the thin Mach-O), which makes r2 report no symbols while
leaving the snapshot bytes intact. This exercises r2flutter's symbol-less
discovery path (structural instruction-image location).
Usage: strip_macho_symbols.py <input> <output>
"""
import struct, shutil, sys
CPU_ARM64 = 0x0100000C
def patch_macho(d, base):
is64 = struct.unpack_from('<I', d, base)[0] == 0xfeedfacf
ncmds = struct.unpack_from('<I', d, base + 16)[0]
off = base + (32 if is64 else 28)
acted = False
for _ in range(ncmds):
cmd, cmdsize = struct.unpack_from('<II', d, off)
c = cmd & ~0x80000000
if cmd == 0x2: # LC_SYMTAB -> nsyms = 0
struct.pack_into('<I', d, off + 12, 0)
acted = True
elif c == 0x22: # LC_DYLD_INFO[_ONLY] -> export_off/size = 0
struct.pack_into('<II', d, off + 40, 0, 0)
acted = True
elif c == 0x33: # LC_DYLD_EXPORTS_TRIE -> datasize = 0
struct.pack_into('<I', d, off + 12, 0)
acted = True
off += cmdsize
return acted
def main(src, dst):
shutil.copy(src, dst)
d = bytearray(open(dst, 'rb').read())
magic = struct.unpack_from('>I', d, 0)[0]
ok = False
if magic in (0xcafebabe, 0xcafebabf): # fat
b64 = magic == 0xcafebabf
nfat = struct.unpack_from('>I', d, 4)[0]
ao = 8
for _ in range(nfat):
if b64:
ct, _cs, offset, _sz, _al, _res = struct.unpack_from('>IIQQII', d, ao)
ao += 32
else:
ct, _cs, offset, _sz, _al = struct.unpack_from('>IIIII', d, ao)
ao += 20
if ct == CPU_ARM64:
ok = patch_macho(d, offset) or ok
else:
ok = patch_macho(d, 0)
if not ok:
print("no symbol/export commands found to strip", file=sys.stderr)
return 1
open(dst, 'wb').write(d)
return 0
if __name__ == "__main__":
if len(sys.argv) != 3:
print(__doc__)
sys.exit(2)
sys.exit(main(sys.argv[1], sys.argv[2]))
+79
View File
@@ -98,6 +98,82 @@ static int collect_snapshot_magics_in_range(DartCtx *ctx, ut64 start, ut64 size,
return found_cnt;
}
// A Dart Image starts with a kObjectStartAlignment (64)-byte header whose first
// host word is ImageSize (header + payload) and second is
// InstructionsSectionOffset. Bare-instructions AOT lays the two instruction
// images consecutively at the start of the first executable section:
// [VmInstructions][IsolateInstructions], each padded up to this alignment.
#define DART_IMAGE_ALIGN 64
#define DART_IMAGE_HEADER_MIN 64
static ut64 dart_align_up(ut64 v, ut64 a) {
return (v + (a - 1)) & ~(a - 1);
}
// Read an Image header at addr and return its ImageSize in *size_out, true only
// if it is a plausible instructions image: it fits before the first data image
// and its InstructionsSection offset (word 1) is a small nonzero aligned value
// inside the image.
static bool dart_image_at(DartCtx *ctx, ut64 addr, ut64 data_lo, ut64 *size_out) {
ut8 hdr[16];
if (!read_mem (ctx, addr, hdr, sizeof (hdr))) {
return false;
}
ut64 size = r_read_le64 (hdr), isoff = r_read_le64 (hdr + 8);
*size_out = size;
return size >= DART_IMAGE_HEADER_MIN && addr + size <= data_lo &&
isoff >= 8 && (isoff & 7) == 0 && isoff < size && isoff < 0x10000;
}
// When the four kDart* symbols are stripped, the magic scan can only find the
// data snapshots (instruction images carry no magic). Recover vm_instr/iso_instr
// structurally: vm_instr is the start of the first executable section, and the
// isolate instructions image follows the VM one and ends where the first data
// image begins. Every step is cross-checked; if anything is off we leave the
// addresses at 0, so a wrong/unknown layout degrades to "no IT names" rather
// than junk addresses. No-op when symbols already resolved the instructions.
static void locate_instr_images_structural(DartCtx *ctx) {
if (!ctx || !ctx->core || !ctx->core->bin || ctx->vm_instr || ctx->iso_instr) {
return;
}
ut64 data_lo = ctx->vm_data && ctx->iso_data
? R_MIN (ctx->vm_data, ctx->iso_data)
: (ctx->vm_data? ctx->vm_data: ctx->iso_data);
if (!data_lo) {
return;
}
RVecRBinSection *sections = r_bin_get_sections_vec (ctx->core->bin);
if (!sections) {
return;
}
ut64 vm_instr = 0, sz1 = 0, sz2 = 0;
RBinSection *sec;
R_VEC_FOREACH (sections, sec) {
if (sec && !sec->is_segment && sec->vaddr && sec->vsize && (sec->perm & R_PERM_X)) {
vm_instr = rebase_bin_addr (ctx, sec->vaddr);
break;
}
}
if (!vm_instr || vm_instr >= data_lo || !dart_image_at (ctx, vm_instr, data_lo, &sz1)) {
return;
}
ut64 iso_instr = dart_align_up (vm_instr + sz1, DART_IMAGE_ALIGN);
if (iso_instr <= vm_instr || iso_instr >= data_lo || !dart_image_at (ctx, iso_instr, data_lo, &sz2)) {
return;
}
// The isolate instructions image is the last text image: it must end right
// where the first data image begins (within a page of alignment slack).
ut64 iso_end = dart_align_up (iso_instr + sz2, DART_IMAGE_ALIGN);
if (iso_end > data_lo || data_lo - iso_end > 0x4000) {
return;
}
ctx->vm_instr = vm_instr;
ctx->iso_instr = iso_instr;
if (ctx->verbose > 0) {
fprintf (stderr, "[r2flutter] located instruction images structurally: vm_instr=0x%" PFMT64x " iso_instr=0x%" PFMT64x "\n", (ut64)vm_instr, (ut64)iso_instr);
}
}
int find_snapshots(DartCtx *ctx) {
if (!ctx || !ctx->core) {
return -1;
@@ -212,6 +288,9 @@ int find_snapshots(DartCtx *ctx) {
ctx->iso_data = scan_iso_data;
}
}
// Symbols are absent (we reached the scan): try to recover the
// instruction images from the section layout so IT name recovery works.
locate_instr_images_structural (ctx);
if (ctx->vm_data || ctx->iso_data || ctx->vm_instr || ctx->iso_instr) {
return 0;
}
+10
View File
@@ -0,0 +1,10 @@
NAME=discovery-stripped-ios-structural-instr
FILE=-
CMDS=<<EOF
!!sh -c 'A=bins/ios/Runner.app/Frameworks/App.framework/App; T=$(mktemp); python3 ../scripts/strip_macho_symbols.py "$A" "$T" >/dev/null 2>&1 && printf "kdart=%s\n" "$(r2 -qc "is~kDart" "$T" 2>/dev/null | grep -c kDart)" && ../bin/r2flutter -q -H "$T" 2>/dev/null | grep -oE "vm_instr=\S+ iso_data=\S+ iso_instr=\S+"; rm -f "$T"'
EOF
EXPECT=<<EOF
kdart=0
vm_instr=0x4000 iso_data=0x1b9600 iso_instr=0xe700
EOF
RUN