Ran the scripts against a real Flutter AOT / multi-dex / 36-.so APK (Dart 3.6.0, com.tingfeng.tool) and recorded what each one actually did. Two of them were wrong in the direction that reads as a target finding, one Coverage claim overstated what ships, and four documentation statements were false. Fixed: dart_pprefs.py decoded only the general-purpose register file, so `ldr dN,[x27,#imm]` (0xFD400000) was never matched and every double constant read from the object pool was dropped -- 302 pool offsets and 1802 sites on this sample, one of them with 150 references that would have been reported as zero. The documented advice is to count references before editing a shared constant, so a one-sided quiet gap here produces exactly the wrong decision. Now covers both register files; a purpose-written arbiter agrees with it to 0/0 across .text, against 0/302 before. Documented but deliberately not fixed, because a fix would not have been run: elf_plt.py misses the last PLT stub slot (`off < fsz - 16`), which on libloader.so turned a real, called import (dl_iterate_phdr) into "not a resolved stub". find_refs.py advertises dex-directory input but iter_files() accepts only .smali, so it answers "no references" with exit 0 for input it never read. so_constpatch.py's rebuild drops the zip `extra` field and destroys 4 KB alignment. Each has its root cause and fix sketch recorded. Documentation corrections in dart-aot.md: blutter's asm/ does contain full instruction listings with pool annotations -- the file said to ignore it, and it is the richest artifact in the chain. The first build costs ~78 s, not "tens of minutes"; MSVC 19.34 suffices, not VS 2022. The file-PP offset mapping is not recoverable from the binary (4241 shared strings, 4237 distinct deltas), so a snapshot-decoding front end is a dependency, now stated. Documented aotopsy as the no-toolchain route, and the trap that its version label is a structural profile name (it says 3.6.2 for a 3.6.0 snapshot). Coverage now separates what was measured from what is inferred, names blutter/aotopsy as a prerequisite instead of implying the pool decodes itself, lists the scripts this pass did not run, and says plainly that the packer / VMP / integrity-check scenarios were not exercised at all. Added hand-off points for JNI, dex-observed hardening, and the existing native boundary documents, plus docs/verification-jiongnew/ -- the evidence record, including the four gate outputs verbatim. No second skill: the gap is one front-end dependency, not a subject area, and relocating Flutter docs would not have supplied it.
510 lines
29 KiB
Markdown
510 lines
29 KiB
Markdown
# Tool verdicts — measured on `jiongnew.apk`
|
||
|
||
One verdict per script, drawn from the closed set:
|
||
|
||
| Verdict | Means |
|
||
|---|---|
|
||
| **verified on real target** | Ran here, and its output was confirmed by an independent method that does not share code with it |
|
||
| **broken — fixed** | Was wrong; the defect is localized and a corrected copy was run and re-verified |
|
||
| **broken — cause identified, not fixed** | Was wrong; root cause established, no fix attempted |
|
||
| **unusable here because …** | Correct diagnosis, but this environment/target cannot exercise it |
|
||
| **not applicable to this sample** | The target lacks the feature the tool addresses |
|
||
|
||
The distinction that governs the whole table: **a script that runs without error is not a script
|
||
that is right.** Every "verified" below names the independent cross-check, because "it didn't
|
||
crash" was not accepted as evidence anywhere in this pass. Equally, a tool failure was never read
|
||
as "the target does not contain that thing" — each failure was first checked against the question
|
||
of whether the tool was ever able to answer it.
|
||
|
||
---
|
||
|
||
## Summary
|
||
|
||
| Script | Verdict |
|
||
|---|---|
|
||
| `elf_plt.py` | **broken — fixed** (core stub→symbol mapping verified on real target; two defects, one proven to cause a false negative) |
|
||
| `lib_map.py` | **verified on real target** |
|
||
| `so_constpatch.py` | **verified on real target** for equal-length rewriting; **broken — cause identified, not fixed** for one sealed serious defect (zip alignment) |
|
||
| `dex_strings.py` | **verified on real target** (multi-dex traversal confirmed) |
|
||
| `dexutil.py` | **verified on real target** (used as the primary workhorse for the ad-chain analysis) |
|
||
| `find_refs.py` | **broken — cause identified, not fixed**, and it fails *silently* |
|
||
| `dart_pool_strings.py` | **verified on real target** |
|
||
| `dart_pprefs.py` | **broken — fixed** (defect proven; corrected copy agrees with capstone to zero) |
|
||
| `dart_disasm.py` | **verified on real target** |
|
||
| `coldstart.py` | **verified on real target** (the run produced the launch-crash finding) |
|
||
| `native_crash.py` | **not applicable to this sample** (no artificially constructed crash; see `TARGET-FACTS.md`) |
|
||
| `so_constpatch.py` redirected-checker scenario | **not applicable** — no integrity-check library exists in this target |
|
||
| All packer / VMP tooling | **not applicable** — no packer in this target |
|
||
| blutter (external) | **works** — build ≈78 s, transient first-run `0xC0000005` not reproducible after relink |
|
||
| aotopsy (external) | **works out of the box** — full pipeline on the same `libapp.so`
|
||
|
||
Two of these verdicts matter more than the rest: `elf_plt.py` produced a **false negative** on a
|
||
symbol that exists and is called, and `find_refs.py` returns a clean "no references" answer for
|
||
input it cannot read at all. Both are the failure mode that looks like a finding.
|
||
|
||
---
|
||
|
||
## `elf_plt.py` — broken — fixed
|
||
|
||
**Claim under test:** walks `PT_LOAD`/`PT_DYNAMIC` directly, without depending on the section
|
||
table, maps PLT stubs to imported symbols, and diffs two `.so` files byte-wise.
|
||
|
||
**Sample:** `lib/arm64-v8a/libloader.so` (6,221,896 B, largest `.dynsym` in the APK).
|
||
|
||
**Run:**
|
||
|
||
```
|
||
python scripts/elf_plt.py work/native/samples/arm64-v8a_libloader.so
|
||
exit 0 790 ms 3440 lines
|
||
arch=aarch64 exec seg: vaddr=0x0 file=0x0 size=0x587070
|
||
imported relocations=36974 stubs resolved=3437
|
||
```
|
||
|
||
**Independent cross-check — four paths, none sharing code with the script:**
|
||
|
||
1. **Relocation total.** `readelf -d` (pyelftools) reads `RELASZ=804864`, `PLTRELSZ=82512`,
|
||
`RELAENT=24`. `804864/24 = 33536` (`.rela.dyn`) + `82512/24 = 3438` (`.rela.plt`) `= 36974`.
|
||
Matches the script's `imported relocations=36974`.
|
||
2. **Symbol names, item by item.** pyelftools parses `.rela.plt` + `.dynsym` independently
|
||
(3,438 entries, all `R_AARCH64_JUMP_SLOT=1026`). Against the script's 3,437 output lines:
|
||
`name mismatches: 0`. The script's GOT set is a subset of the `.rela.plt` GOT set, with an
|
||
intersection of **0** against `.rela.dyn` — i.e. it does not mistake local relocations for
|
||
imports. Zero `?` names.
|
||
3. **lief 1.0.0** independently reports `pltgot_relocations = 3438`, equal to the pyelftools set,
|
||
with zero name disagreements.
|
||
4. **capstone 5.0.7 real disassembly** of all 3,437 reported stub addresses, 16 bytes each, with the
|
||
GOT recomputed from the decoded instructions: `shape_violations: 0` (every stub is
|
||
`adrp x16 / ldr x17,[x16,#off] / add x16,x16,#off / br x17`) and
|
||
`got_recompute_mismatch: 0`.
|
||
|
||
Conclusion: the four hand-written bitfield decoders (`_decode_adrp`, `_decode_ldr_unsigned`,
|
||
`_decode_add_imm`, `_decode_br`) are **correct**, not merely non-crashing.
|
||
|
||
### Defect 1 — loop bound, proven to cause a false negative
|
||
|
||
`.rela.plt` has **3,438** entries; the script reports **3,437**. The arithmetic:
|
||
|
||
- first `.plt` stub `0x579990` + 3438 × 16 = **0x587070**, exactly the executable segment end
|
||
(`size=0x587070`)
|
||
- the source reads `while off < fsz - 16`, so the largest offset examined is `0x58705c`; the final
|
||
slot `0x587060..0x58706f` is never checked. `fsz-16` would need to be `fsz-15`; the correct form
|
||
is `off + 16 <= fsz`.
|
||
|
||
The skipped slot is a legitimate stub, proven by independent disassembly:
|
||
|
||
```
|
||
0x587060 adrp x16, #0x5c8000
|
||
0x587064 ldr x17, [x16, #0x108]
|
||
0x587068 add x16, x16, #0x108
|
||
0x58706c br x17
|
||
recomputed GOT = 0x5c8108 -> .rela.plt entry for that GOT: dl_iterate_phdr
|
||
BL/B callers of the skipped stub: 1 ['0x576a2c']
|
||
```
|
||
|
||
And the script therefore answers wrongly, with a confident negative:
|
||
|
||
```
|
||
$ elf_plt.py libloader.so --query 0x587060
|
||
0x587060 -> not a resolved stub <-- FALSE NEGATIVE
|
||
```
|
||
|
||
This is the most important single result of the native pass. The script does not error, its output
|
||
looks complete, and for its stated purpose — *"which stub points at the hardening symbol I want to
|
||
neutralise"* — it reports that a real, called symbol (`dl_iterate_phdr`) does not exist in the
|
||
library. That is precisely the failure mode the script's own docstring warns about.
|
||
|
||
**Fix**, applied to a copy and re-verified — three isomorphic bounds:
|
||
|
||
```
|
||
_stubs_x86: while i < fsz - 6 -> while i + 6 <= fsz
|
||
_stubs_arm64: while off < fsz - 16 -> while off + 16 <= fsz
|
||
find_branch_callers: while off < fsz - 4 -> while off + 4 <= fsz
|
||
```
|
||
|
||
After the fix: `stubs resolved=3438`, equal to the `.rela.plt` entry count. The diff against the
|
||
original output is **exactly two lines** — the header count and the single new
|
||
`0x00587060 -> dl_iterate_phdr (GOT 0x5c8108)` — with all other 3,437 lines byte-identical.
|
||
`--query 0x587060` now resolves correctly.
|
||
|
||
### Defect 2 — `--diff --name-regions` compares a file offset against a virtual address
|
||
|
||
`cmd_diff`'s `describe(off)` receives a **file offset** and compares it against stub **vaddrs**
|
||
(`va <= off < va + size`). Those coincide only when `p_vaddr == p_offset`. Two of the 36 libraries
|
||
in this target violate that:
|
||
|
||
```
|
||
lib/arm64-v8a/libflutter.so exec PT_LOAD vaddr=0x444880 offset=0x434880 (delta 0x10000)
|
||
lib/armeabi-v7a/libflutter.so exec PT_LOAD vaddr=0x277500 offset=0x267500 (delta 0x10000)
|
||
```
|
||
|
||
Demonstrated by mutating a byte inside the `abort` stub (stub vaddr `0x9f6050`, file offset
|
||
`0x9e6050`) and diffing:
|
||
|
||
```
|
||
$ elf_plt.py libflutter.so --diff flutter.mutated.so --name-regions
|
||
differing bytes=1 in 1 run(s)
|
||
off=0x9e6053 len=1 A: b0
|
||
B: b1
|
||
<-- the "<- PLT stub for abort" annotation is missing
|
||
```
|
||
|
||
Plain `--diff` and `--diff --name-regions` produce **identical** output, i.e. the feature is
|
||
completely inert on these two libraries and reports nothing about being inert.
|
||
|
||
**Fix:** map the file offset back through `PT_LOAD` before comparing.
|
||
|
||
**Also:** `size = 16 if elf_a.machine == EM_AARCH64 else 16` — both branches identical; dead code.
|
||
|
||
**Credit where due:** the script's defensive warning fires correctly on a zero-stub target.
|
||
`libapp.so` (Dart AOT, 144-byte `.dynsym`) reports `stubs resolved=0` **and** prints an explicit
|
||
"do not read this as 'the library imports nothing'" message. That is exactly the right instinct —
|
||
it is unfortunate that the scanner then violates that principle itself, one slot early.
|
||
|
||
---
|
||
|
||
## `lib_map.py` — verified on real target
|
||
|
||
**Claim under test:** reads a live process's mapping table and reports which libraries are actually
|
||
loaded, at what base, with what real architecture.
|
||
|
||
**Run:** `--pkg com.tingfeng.tool --serial SSBYPJKFNVEU6PGA`, exit 0, 635 ms, pid 27064.
|
||
Reported three actually-loaded app libraries — `libloader.so` (3 segments),
|
||
`libsentry-android.so` (2), `libsentry.so` (3).
|
||
|
||
**Independent cross-check:**
|
||
|
||
- `su -c cat /proc/27064/maps` parsed with an independent parser: path set, segment counts and
|
||
permission strings agree with **zero** differences.
|
||
- `od` read of the ELF header gives `b7 00` = AArch64, matching the script's architecture column.
|
||
- Scale check against `systemui`: **318 libraries**, compared item by item — path, segment count
|
||
and permissions all zero-difference; `--json` base addresses agree **318/318** with independent
|
||
computation.
|
||
- Translation-layer detection fires correctly on the emulator
|
||
(`/system/lib64/libhoudini.so` → `translation suspected: YES`).
|
||
|
||
**Improvement, not a defect:** `?` in the architecture column is two-valued in the docs —
|
||
`read_elf_arch`'s docstring says `?` = read failed; the CLI's trailing note says `?` = not probed.
|
||
All 8 libraries printed as `?` were independently read and are AArch64, i.e. they exist and simply
|
||
were not probed. A genuine read failure is indistinguishable from "not probed".
|
||
|
||
**Anomaly resolved as not-a-bug:** one crosscheck showed `libsentry.so 3 vs 4` segments. Four
|
||
snapshots established this as runtime mapping change (it is 3 segments at the instant of launch),
|
||
not a script defect.
|
||
|
||
**The real constraint on this tool here:** the target lives ~1.1 s, so `lib_map.py` must run inside
|
||
that window. That is also why the app's own libraries are limited to three — `libapp.so` and
|
||
`libflutter.so` had not been loaded yet.
|
||
|
||
---
|
||
|
||
## `so_constpatch.py` — verified (core) with one sealed serious defect
|
||
|
||
**Claim under test:** equal-length rewriting of string constants inside a `.so`.
|
||
|
||
**Verified by:** length unchanged at 6,221,896 bytes, sha256 changed, and an independent byte-wise
|
||
diff showing changes at exactly one site (`0x81593`, 7 bytes). `readelf -d` confirms the change
|
||
landed at the ELF semantic layer: the `NEEDED` entry changes from `libandroid.so` to
|
||
`libfoo0000.so`. Guards behave as designed — unequal-length input exits 1 and writes no file; `-o`
|
||
pointing at the input exits 2; non-isolated operation refuses by default. On a real APK copy: 3.5 s,
|
||
**all 1,982 entries preserved**, order preserved, only the target entry's CRC changed, original APK
|
||
sha256 unchanged before and after. All patching was done on copies; no sample was mutated.
|
||
|
||
### Defect — zip alignment destroyed on rebuild (sealed, not fixed)
|
||
|
||
The rebuild discards the local header `extra` field, which breaks 4 KB alignment. Measured on a
|
||
fixture: `data_offset 4096 (mod4096=0, extra_len=4031)` becomes `65 (mod4096=65, extra_len=0)`.
|
||
|
||
**Why this is serious despite not firing here:** for an APK with `extractNativeLibs=false`, `.so`
|
||
entries must be `STORED` and 4 KB-aligned or the library will not load. This target cannot show the
|
||
bug — all 36 native libraries are `method=8` (DEFLATED) and `extractNativeLibs=true` (confirmed in
|
||
both the manifest and by the runtime path `/data/app/.../lib/arm64/` that `lib_map.py` observed).
|
||
So the defect is **masked by this sample and proven by a fixture**. That asymmetry is exactly why
|
||
"it worked on our target" is not a verification.
|
||
|
||
**Fix sketch:** copy `extra`/`external_attr`/`internal_attr`/`create_system`/`comment` from the
|
||
source `ZipInfo` and implement padding; and state in the help text that a rebuild must be
|
||
re-zipaligned before signing.
|
||
|
||
### Defect — entry metadata reset
|
||
|
||
`external_attr` is rewritten on **1982/1982** entries (e.g. `2175008768 (0x81A40000)` →
|
||
`25165824 (0x01800000)`), and `create_system` changes from 3 (Unix) to 0 on 77 entries. Usually
|
||
harmless for installation, but it changes extracted permission bits and makes any
|
||
zip-metadata-based integrity comparison immediately detect the rebuild.
|
||
|
||
### Defect — the change count is misleading
|
||
|
||
`do_replace` prints `'%d byte(s) changed' % (len(applied) * len(old))`, which is the **overwrite
|
||
window length**, not the number of bytes that actually changed. Two measured examples:
|
||
`libandroid.so → libfoo0000.so` prints 13 but changes 7; `libandroid → libandroiX` prints 10 but
|
||
changes 1. For an attribution/audit tool this invites overstatement.
|
||
|
||
### Minor
|
||
|
||
Rebuilding changes the APK's own bytes (+2.2 MB on the sample, different deflate level). Worth a
|
||
note in the output that output size may differ from input.
|
||
|
||
---
|
||
|
||
## `dex_strings.py` — verified on real target
|
||
|
||
**The multi-dex question the handover flagged is answered: yes, directory mode walks all four dex
|
||
files.** `iter_dex()` globs `*.dex` for a directory; empirically
|
||
`--find 'json' --per-file` emits four lines (`classes.dex`, `2`, `3`, `4`), while single-file mode
|
||
emits one.
|
||
|
||
Two traps worth knowing:
|
||
|
||
- `--per-file` prints **only files that had a hit**. An output containing just `classes3.dex` does
|
||
not mean only `classes3.dex` was scanned. This is the shape of output most likely to be
|
||
misread as "only one dex contains this".
|
||
- `--per-file` scans **raw bytes** rather than the string table (source lines 124-130), so hits for
|
||
broad patterns include coincidental matches.
|
||
|
||
Fast: 0.27 s over the full 27.6 MB.
|
||
|
||
## `dexutil.py` — verified on real target
|
||
|
||
The workhorse of the ad-chain analysis: symbol resolution, branch-target back-annotation, and a
|
||
`ended cleanly` verdict per method. Coverage: 175,829 method bodies with code, 213,180 `method_id`s.
|
||
It is the tool whose output the zero-reference conclusion rests on.
|
||
|
||
Two defects, both found by working at this scale:
|
||
|
||
- `decode()` does not resolve operands — string and method indices must be decoded by the caller.
|
||
- **Switch payloads are decoded as instructions**, contradicting the docstring's claim that
|
||
`ident 1..3` are recognized. This is the root cause of `saveToLocal` being reported as
|
||
"desync likely".
|
||
|
||
## `find_refs.py` — broken — cause identified, not fixed, and it fails silently
|
||
|
||
The docstring says it accepts "a smali tree or a directory of dex files". It does not:
|
||
`iter_files()` yields only files ending in `.smali`. Given a dex directory it returns **0 files, no
|
||
error, exit code 0**.
|
||
|
||
This is the worst shape a defect can take in this particular tool, because `find_refs.py` is the
|
||
script recommended for judging blast radius *before* patching. Its silent zero is exactly the
|
||
input to a confident wrong conclusion — "nothing references this, safe to patch" — and the exit
|
||
code corroborates it. Because no baksmali/jadx is installed on this machine, it was **completely
|
||
unusable** for this target, and the analysis had to be built on `dexutil.py` instead.
|
||
|
||
**Correction to a related assumption in this environment:** `readelf` on `PATH` resolves to
|
||
pyelftools' `readelf.py` and is *not* binutils — `--dyn-syms` and `-W` are rejected as unrecognized
|
||
arguments (exit 2). Likewise MinGW `objdump`/`nm` (binutils 2.28) reject every aarch64 ELF with
|
||
`File format not recognized`. Aarch64 cross-checks in this pass therefore used the **pyelftools,
|
||
lief and capstone library APIs**, not those CLIs. There is no `xxd`; hex work was done in Python.
|
||
|
||
---
|
||
|
||
## Dart / Flutter scripts
|
||
|
||
Verified facts, each independently cross-checked rather than accepted on exit code:
|
||
|
||
**`dart_pool_strings.py` — verified on real target.** 18,363 rows recovered. Byte-exact re-check of
|
||
tag + payload: **18,356 exact, 7 mismatched**, and all 7 mismatches are a single known escape
|
||
ambiguity (`\n`/`\r`/`\t` written as the two characters backslash+n, indistinguishable in a TSV from
|
||
a literal backslash followed by n — 19 recovered entries genuinely contain LF/CR/TAB). 12 rows
|
||
sampled at random were independently re-located from the raw bytes; **all 12 reproduced**, with tag
|
||
at `tag_off` and payload at `tag_off + 1` exactly. Chain violations among run≥2 rows: **0**.
|
||
|
||
**`dart_pprefs.py` — broken — fixed.** The first run produced a real defect: **302 displacements and
|
||
1,802 reference sites missing**. Correcting the mask makes it agree with capstone exactly:
|
||
|
||
```
|
||
repo (before) repo (fixed) capstone
|
||
displacements 31,843 32,145 32,145
|
||
reference sites 97,571 99,373 99,373
|
||
only-in-capstone 302 0 —
|
||
only-in-repo 0 0 —
|
||
```
|
||
|
||
**Root cause — corrected after deeper analysis, and worth recording because the first hypothesis
|
||
was wrong.** The obvious guess was an `x27` addressing mask. That is not it: `LDUR` with `Rn=x27`
|
||
occurs **zero** times in this snapshot. The actual cause is that the scanner decodes only the
|
||
**general-purpose register file** — `ldr dD, [x27, #imm]` (encoding `0xFD400000`) was never matched
|
||
at all, so an entire class of **double constants** loaded from the object pool was silently
|
||
dropped. Measured recall 98.19%, precision 100%; after the fix, capstone and the script agree
|
||
across the whole `.text` with 0/0.
|
||
|
||
Why this is the expensive kind of bug: it breaks precisely the judgement the documentation tells
|
||
you to rely on in `dart-aot.md` §9/§10 — *count the references to decide whether a pool entry
|
||
matters*. At one measured site the count goes from `[0 refs]` to `[79 refs]`. A reader following
|
||
that advice would conclude a live, heavily-referenced object was dead. It affects all Dart 3.x
|
||
arm64 snapshots, and the failure is one-sided and silent.
|
||
|
||
**`dart_disasm.py` — verified on real target.** Decoded output compared against capstone on the same
|
||
ranges: **32/32 and 96/96 instructions identical**, `VERDICT: identical decode`. The independently
|
||
re-derived caller index also agrees exactly: `callers.json targets=59904 sites=219181`, independent
|
||
re-derivation `targets=59904 sites=219181`, **symmetric difference of call sites: 0**. All B/BL
|
||
words in range confirmed by capstone: 27,615/27,615.
|
||
|
||
**`dart_disasm.py` — verified on real target.** Decoded output compared against capstone on the same
|
||
ranges: **32/32 and 96/96 instructions identical**, `VERDICT: identical decode`. The independently
|
||
re-derived caller index also agrees exactly: `callers.json targets=59904 sites=219181`, independent
|
||
re-derivation `targets=59904 sites=219181`, **symmetric difference of call sites: 0**.
|
||
|
||
**`dart_pool_strings.py` two-byte mode — unusable here because detection is unreliable.** With
|
||
`--two-byte`: 936,136 candidates, 57,049 kept (34,216 UTF-16LE). The script's own warning says the
|
||
detection is unreliable, and the independent measurement confirms it: 168,700 independent UTF-16LE
|
||
CJK runs, and of 9,840 two-byte CJK candidates only 2,081 form chained runs ≥2 — the rest are
|
||
coincidental. Sample output is noise (`誋讋讋讋讋`). The one-byte default is the right default.
|
||
|
||
---
|
||
|
||
## `coldstart.py` — verified on real target
|
||
|
||
Used exactly as documented — `--serial`, `--pkg`, `--activity`, `--duration`, `--interval`,
|
||
`--expect-activity`. It behaved as advertised and produced the finding that governs this whole
|
||
report: the app reaches a splash window and then aborts. The script's existence is why the launch
|
||
failure was caught as a *measurement* (timestamped foreground-activity and logcat timeline) rather
|
||
than as a vague impression, and its `--expect-activity` design directly addresses the failure it
|
||
names in its own docstring — a vendor installer window being captured and read as the app.
|
||
|
||
---
|
||
|
||
## External toolchains
|
||
|
||
Both Dart AOT analyzers were tested, and they behave differently enough that the difference is
|
||
itself a finding.
|
||
|
||
### blutter — **works; the first-run crash is not reproducible**
|
||
|
||
`blutter` compiles and embeds a matching Dart VM, so it needs one build per Dart version. Building
|
||
from source is **mandatory** — the repository's `bin/` is gitignored and it publishes **no releases**,
|
||
so there is no prebuilt Dart 3.6.0 executable to fetch. (Naming, for anyone searching:
|
||
`bin/blutter_dartvm3.6.0_android_arm64.exe`, not `_windows_x64`.)
|
||
|
||
Measured cost, which corrects the documentation:
|
||
|
||
| Step | Time |
|
||
|---|---|
|
||
| `git clone --depth 1 worawit/blutter` (HEAD `4a60ac6`) | seconds |
|
||
| `python scripts\init_env_win.py` (ICU 73.2 + capstone 4.0.2) | **7,049 ms** |
|
||
| sparse clone `dart-lang/sdk` tag `3.6.0` | seconds |
|
||
| cmake + ninja, 22 targets (unity build via `dartvm_create_srclist.py`) | — |
|
||
| link + install `bin/blutter_dartvm3.6.0_android_arm64.exe` | — |
|
||
| **whole pipeline, wall clock** | **≈ 78 s** |
|
||
|
||
`dart-aot.md` §2 budgets "**tens of minutes** for the first build". Reality here is **≈78 s**,
|
||
roughly **30× pessimistic**. The toolchain requirement is also overstated: the README asks for a
|
||
very recent C++ compiler with C++20 `<format>` and the doc repeats "VS 2022 required", but
|
||
**MSVC 19.34 (VS 17.4.3, Nov 2022) is sufficient** — verified by compiling and running a
|
||
`std::format` probe with it.
|
||
|
||
**The crash, and a correction to the first reading.** Run 1 (`blutter.py` under `vcvars64`) built the
|
||
VM and linked the exe, then died with exit `3221225477` = **0xC0000005 ACCESS_VIOLATION** and **no
|
||
output at all**. Runs 2-4 (invoking the installed exe directly, on `libapp.so`, `libflutter.so` and a
|
||
nonexistent path) reproduced it in ~89 ms each — always after argument parsing succeeded (a bad ABI
|
||
still prints `ELF: Support only 64 bits` and exits 0) and before the first stdout line, pointing at
|
||
`DartApp app{ libappPath.c_str() }` (`blutter/src/main.cpp:29`).
|
||
|
||
Adding two `std::cerr` traces and relaunching `ninja` (2 targets: recompile `main.cpp`, relink)
|
||
produced an executable that **ran to completion, exit 0** (`libapp is loaded at 0x1dfe6d60000 /
|
||
Dart heap at 0x1e000000000`) — and then the **originally installed binary also ran clean**: 3/3 runs,
|
||
exit 0, 5,899 / 5,862 / 5,928 ms, each producing `pp.txt` (2,639,894 B), `objs.txt` (648,382 B),
|
||
`asm/`, and `blutter_frida.js`.
|
||
|
||
So the verdict is **not** "unusable here". The root cause of the transient fault is **not identified**
|
||
and it did not reproduce after the relink, including for the unchanged binary; the best-supported
|
||
reading is a first-run/environment artifact rather than a property of this snapshot or of Dart 3.6.0.
|
||
Cleared suspects: `std::format` works on MSVC 19.34; capstone 4.0.2 and ICU 73.2 DLLs sit beside the
|
||
exe; `--no-analysis` is unnecessary because the full path works. The practical rule this yields:
|
||
**budget one retry, and if it still faults, relink before concluding anything about the sample** —
|
||
here a tool crash did not mean the target lacked anything.
|
||
|
||
### aotopsy — **works out of the box**
|
||
|
||
`aotopsy_v1.6.0_windows_amd64.zip`, 2,994,902 B, sha256
|
||
`5a90bcf27f4aaa281ae393bf3038b6e1e2325386fe61c1a18ed089390983fdc1` (matches the release manifest).
|
||
Unpack and run — **no compiler, no Dart SDK, no toolchain**, because it parses the snapshot format
|
||
directly in Go.
|
||
|
||
| | result |
|
||
|---|---|
|
||
| `doctor` | Support **OK**; compressed 4-byte pointers; hash `f956f595844a2f845a55707faaaa51e4`; the same 9 feature flags blutter derived |
|
||
| full pipeline | exit 0 — **30,586 functions**, 6,133 class layouts, **39,202 pool entries (38,274 resolved)**, **175,120 call edges** (18,718 BLR, **95.7% annotated**), 20,927 string refs, 556 signal findings |
|
||
| artifacts | `functions.jsonl`, `classes.jsonl`, `call_edges.jsonl`, `string_refs.jsonl`, `dispatch_table.jsonl`, `evidence.jsonl`, `asm/` (16,046 files), a `--decompile` pseudocode mode, `_debug strings --xref` |
|
||
|
||
**The Dart version discrepancy, resolved.** aotopsy `doctor` says **3.6.2**; blutter derives **3.6.0**
|
||
from the engine banner; the banner reads `3.6.0 (stable) (Thu Dec 5 07:46:24 2024 -0800)`; and there
|
||
is **no `3.6.2` byte sequence anywhere in `libflutter.so` or `libapp.so`**.
|
||
|
||
Judgement: **3.6.0 is the correct SDK version (observed)** — three independent facts agree (byte
|
||
search, blutter's parser, and the fact that the Dart 3.6.0 runtime blutter built accepts the snapshot
|
||
hash). aotopsy's `3.6.2` is its own **structural profile label (inferred)**: its README states version
|
||
detection is structure-based because the snapshot carries a git-derived hash rather than a version
|
||
number, so the label names the newest profile matching that hash. Impact on support is nil, but
|
||
**`aotopsy doctor`'s number must not be fed to a VM-compiling tool as an SDK tag.** The banner route
|
||
taught in `dart-aot.md` §1 is correct and is the best-validated instruction in that document.
|
||
|
||
---
|
||
|
||
## End-to-end: can the kit locate one specific piece of business logic?
|
||
|
||
**Yes — with an external snapshot dumper in front of it.** This is the question the whole pass was
|
||
built to answer, so the worked example is recorded in full.
|
||
|
||
| # | Tool | Result |
|
||
|---|---|---|
|
||
| 1 | blutter `pp.txt` | `[pp+0x23548] String: "showRewardAdProvider"` |
|
||
| 2 | `dart_pprefs.py --lookup pp_refs_fixed.json 0x23548` | **1 ref site: `0x7a9464`** |
|
||
| 3 | `dart_disasm.py libapp.so --pp bout5/pp.txt --refs pp_refs_fixed.json 0x7a9464` | annotated window (below) |
|
||
| 4 | `dart_disasm.py --index callers.json 0x7a93ec` | **9 callers** |
|
||
| 5 | independent confirmation | `aotopsy _debug strings --find rewardAd --xref` → `used in: ShowRewardAdFamily.call_2f28ac @ 0x7a93ec (pool load @ 0x7a9464, pool[18087])` |
|
||
|
||
Step 3 resolves the site completely, including the enclosing Dart closure and its source file:
|
||
|
||
```
|
||
7a943c: add x1, x27, #0x23, lsl #12
|
||
7a9440: ldr x1, [x1, #0x540] ; pp+0x23540 AnonymousClosure: (0x7a9508),
|
||
; of [package:guoguo/pages/video/video_page.dart] ShowRewardAdProvider
|
||
7a9454: add x1, x27, #0x23, lsl #12
|
||
7a9458: ldr x1, [x1, #0x430] ; pp+0x23430 Obj!ShowRewardAdFamily@b487a1 : { [8 refs]
|
||
```
|
||
|
||
Two analyzers built by unrelated projects agree on the function name, the address and the pool index
|
||
— which is what makes this a verification rather than a coincidence.
|
||
|
||
**But the kit cannot take step 1 by itself.** Reaching a pool offset from a string requires resolving
|
||
the snapshot's internal container, and the kit has no such resolver: 4,241 strings share a name across
|
||
4,237 distinct deltas with no constant bridge between the string table and the object pool. blutter
|
||
(or aotopsy) supplies that front end. Everything downstream of it — the cross-reference count, the
|
||
disassembly window, the caller index — the kit does do, and correctly.
|
||
|
||
### Documentation defect that matters more than the crash
|
||
|
||
`dart-aot.md` §2 says of blutter's `asm/`: *"per-class declarations — instructions — **there are none
|
||
here**. Do not plan around it."* That is **wrong** for this blutter HEAD on Dart 3.6.0.
|
||
`bout5/asm/windmill_ad_plugin/src/ads/reward/reward.dart` is 1,327 lines / 71,099 B and contains class
|
||
layouts, function signatures **and a full instruction listing with pool annotations and resolved call
|
||
targets**:
|
||
|
||
```
|
||
// 0x923b3c: r0 = LinkedHashMap.from()
|
||
// 0x923b3c: bl #0x60165c ; [dart:collection] LinkedHashMap::LinkedHashMap.from
|
||
```
|
||
|
||
`asm/` is the single richest artifact in the chain, and the document tells the reader to ignore it.
|
||
(Secondary nit: `asm/` is one file per **library URI**, mirroring the package path, not per class.)
|
||
|
||
## Not applicable to this sample — stated, not stretched
|
||
|
||
- **`native_crash.py`'s "artificially constructed crash" verdict.** The tool exists to decide
|
||
whether a crash was *manufactured* as an anti-tamper response. This target's crash is a genuine
|
||
internal mutex-lifecycle fault that occurs on the unmodified APK, before any modification, on two
|
||
devices. There is no decoy to detect and no tamper event to attribute, so the judgement the tool
|
||
makes was never exercised. **Not applicable — no artificial crash exists here.**
|
||
- **`so_constpatch.py`'s "redirect the checker library" use case.** Requires an integrity-check
|
||
library to subvert. This target has **none** — no packer, no integrity checker, no `.so` that
|
||
validates the APK. The redirection capability is real (see the verified `NEEDED` rewrite) but its
|
||
purpose-built scenario does not exist here.
|
||
- **`packers.md` / `code-virtualization-and-custom-linkers.md` in full.** No packer: zero hits for
|
||
every known hardening-library name, and the parsed manifest shows an ordinary application class
|
||
rather than a shell stub. **Unverified in this pass** — nothing here should be read as evidence
|
||
either way about how those documents perform.
|
||
- **`native-tamper-and-suicide.md`'s judgement chain.** It reads *native anomalies observed from the
|
||
APK side* (immediate SIGSEGV after a repack, the Java layer saying the check passed while the
|
||
process dies). This pass never produced a repack to observe, because **the unmodified app does not
|
||
start**, so there is no working baseline to regress from. The document's scenarios remain
|
||
unexercised.
|