Toolchain guidance, observation ordering, and target-neutral records

Capability-first tooling guidance, plus removal of target identity from the repo.

Toolchain (references/toolchain.md):
- New ASC (droidasc) section + README Requirements row. An agent should reach for an
  indexer before any full decompile. Covers install, query subcommands, the division
  of labour with ddc (ASC locates, ddc reads), and its measured limits -- including the
  unverified-negative trap when a non-ASCII needle goes through a shell, and the
  mangled-class case where ASC is the only route.
- ddc: two measured limits -- a renamed class is unreachable via -c, and a decompiled
  body can be semantically wrong rather than merely ugly.
- New "Closing a capability gap": installing the missing tool IS the task, with measured
  install costs set against the cost of the workaround.
- New "Using the kit's scripts instead of writing your own", with two-tools-disagreeing
  as the bug detector it is.
- Ghidra section for the aarch64 case: headless invocation, the flattening caveat, and
  the "a decompiler cannot go where the data is not" boundary.
- HaE placed in Tier 3 as traffic triage, naming the three situations where it is the
  wrong next step.

Observation ordering:
- SKILL.md: new Tooling section with four obligations; G2 now requires a written
  capability inventory; symptom index gains four rows (silent feature loss with no
  request at all, telling your patch apart from the target's own behaviour, spawn keeps
  the Activity stack down, the device server being killed by the ROM).
- dynamic-frida.md: the patch-then-detach ordering, and device-server survival notes.
- New scripts/spawn_patch_detach.py and scripts/hook_patch_only.js.

Neutrality:
- docs/verification-jiongnew/ -> docs/tool-verification/. Target-specific transcripts
  dropped; all target identity removed. The record keeps the measurements and loses
  the specimen.
- No target identity remains anywhere in the tree.

check_repo.py: 0 problems. check_refs.py: 182 reachable, 0 dangling, 0 warnings.
This commit is contained in:
inliver233
2026-09-20 19:14:36 +08:00
parent 90f7a58615
commit 206a2f5465
13 changed files with 601 additions and 580 deletions
+179
View File
@@ -0,0 +1,179 @@
# Findings about the skill, from measuring it
Everything here is a result about **the skill**, not about the target. Each item names the evidence
that produced it and its strength.
---
## F1 — The Coverage claim was partly aspirational, and the pass identified exactly which part
`SKILL.md`'s Coverage section lists as *covered by verified mechanisms*:
> Flutter / Dart AOT: pinning the engine version, decoding the object pool, locating and patching
> business logic inside `libapp.so` (`dart-aot.md`).
Measured against a real Dart 3.6.0 `libapp.so`, that sentence splits into three claims of very
different standing:
| Sub-claim | Standing after this pass |
|---|---|
| pinning the engine version | **verified** — the banner route (§1) is correct and 3.6.0 is confirmed three ways |
| decoding the object pool | **false as written** — the kit cannot do it; there is no resolver |
| locating business logic inside `libapp.so` | **true, but conditional** — it works, on top of an external snapshot dumper that the skill neither ships nor installs |
The worked example in `TOOL-VERDICTS.md` shows both halves at once: the chain
`"showRewardAdProvider"` → `pp+0x23548` → ref site `0x7a9464` → `ShowRewardAdFamily.call_2f28ac
@ 0x7a93ec` succeeded, and step 1 was performed by blutter, not by the skill.
The reason is structural, not a shortage of effort: `dart_pool_strings.py` reports **file offsets**
while `pp.txt` and `dart_pprefs.py` work in **pool offsets**. Over the 4,241 strings present in both
spaces there are **4,237 distinct deltas** (min −1,361,429, max +277,654). The mapping is a property
of the reconstructed pool and is not a constant, so no amount of care inside the script recovers it.
Capabilities the kit does not have, stated plainly: snapshot/object-pool deserialisation; symbol and
name recovery; a real call graph (the `--build-index` index holds raw `B`/`BL` targets only); a
decompiler; a function boundary/size table.
---
## F2 — Two scripts fail in the way that reads as a target finding (the P26 class)
The handover singled out `pitfalls.md` P26: *"a self-built analysis script fails in a way that reads
as a target finding."* This pass produced two live instances of exactly that, which is why the P26
warning earns its place.
**`elf_plt.py` returned a confident false negative.** It reports 3,437 stubs where `.rela.plt` holds
3,438, and answers `0x587060 -> not a resolved stub` for an entry that is a legitimate stub for
`dl_iterate_phdr`, with a real caller at `0x576a2c`. Nothing in the output indicates a problem. For
the script's stated purpose — deciding whether a hardening symbol exists in a library — the wrong
answer is indistinguishable from the right one.
**`find_refs.py` returns a clean empty result for input it cannot read.** Its docstring advertises
"a smali tree or a directory of dex files"; `iter_files()` accepts only `.smali`. Given the dex
directory it printed `[total refs] 0`, exited **0**, and emitted no warning. This is worse than a
crash: it is the exact input to "nothing references this, safe to patch", produced by the very tool
recommended for judging blast radius before patching.
Both, plus `dart_pprefs.py`'s silently dropped double constants, share one property: **the failure is
one-sided and quiet.** None of the three reports reduced confidence in its own output.
---
## F3 — Defects found, ranked by consequence
| # | Script | Defect | Consequence | Status |
|---|---|---|---|---|
| 1 | `find_refs.py` | `iter_files()` matches only `.smali`; dex dirs yield 0 results, no error, exit 0 | silent false "no references" on the tool whose job is blast-radius | **cause identified, not fixed** |
| 2 | `elf_plt.py` | loop bound `off < fsz - 16` misses the final stub slot | false negative for one real symbol per library, conditionally | **fixed on a copy, re-verified** |
| 3 | `dart_pprefs.py` | only the GP register file is decoded; `ldr dD,[x27,#imm]` (`0xFD400000`) and all double constants lost | under-reports pool references (302 displacements / 1,802 sites here), which breaks the "count refs to judge importance" advice in `dart-aot.md` §9/§10 — one site went `[0 refs]` → `[79 refs]` | **fixed on a copy; agrees with capstone 0/0** |
| 4 | `so_constpatch.py` | rebuild drops the zip `extra` field, destroying 4 KB alignment | for `extractNativeLibs=false` APKs the output will not load; masked here because this target is DEFLATED + `extractNativeLibs=true` | **cause identified, proven by fixture; not fixed** |
| 5 | `so_constpatch.py` | `--diff --name-regions` compares file offset with vaddr | feature completely inert on libraries where `p_vaddr != p_offset` (2 of 36 here), with no warning | cause identified |
| 6 | `so_constpatch.py` | `external_attr` rewritten on 1982/1982 entries | metadata-based integrity comparison detects the rebuild | cause identified |
| 7 | `dexutil.py` | switch payloads decoded as instructions; `decode()` does not resolve operands | caused a spurious "desync likely" verdict on `saveToLocal` | cause identified |
| 8 | `so_constpatch.py` | "N byte(s) changed" is the overwrite window, not bytes changed | overstates change count (13 reported vs 7 actual) | cause identified |
| 9 | `lib_map.py` | `?` means both "read failed" and "not probed" | a real read failure is invisible | cause identified |
| 10 | `dart_pool_strings.py` | TSV escaping is lossy for LF/CR/TAB | 7 of 18,363 rows ambiguous; 19 entries genuinely contain those bytes | cause identified |
| 11 | `elf_plt.py` | `size = 16 if ... else 16` | dead code | cause identified |
| 12 | `dex_strings.py` | `--per-file` prints only files with hits | can be misread as "only this dex was scanned" | cause identified |
Items 1-3 are the ones that changed a conclusion during this pass rather than merely being noticed.
---
## F4 — Documentation contradictions
Independently found while mapping the skill and while using it. The executable cross-reference
checkers (`check_refs.py`, `check_repo.py`) pass, so these are invisible to the repo's own gates.
| # | Location | Problem |
|---|---|---|
| 1 | `dart-aot.md` §2 | Claims blutter's `asm/` has *no instructions* — "Do not plan around it." **Wrong.** One plugin file under `asm/<plugin>/src/ads/reward/` is 1,327 lines carrying class layouts, signatures, a full instruction listing, pool annotations and resolved call targets. It is the richest artifact in the chain and the doc says to ignore it. |
| 2 | `dart-aot.md` §2 | Budgets "tens of minutes" for the first blutter build; measured **≈78 s** (~30× pessimistic). Also demands a very recent C++ compiler; **MSVC 19.34 (Nov 2022) suffices**. |
| 3 | `dart-aot.md` §7 | Presents two-byte hit chaining as an acceptance test. On this sample, 9,840 two-byte CJK candidates yield 2,081 chained runs, all garbage. |
| 4 | `signature-derived-keys.md:61` | Documents `python scripts/sig_probe.py --live --pkg <pkg>` — the script has no `--pkg`; it takes `--live PKG`. Line 54 and `pitfalls.md` P27 give the correct form. |
| 5 | `dart-aot.md:204` | `dart_disasm.py ... --index callers.json --build-index` omits the required argument value; argparse errors out. |
| 6 | `byte-level-patching.md:186` | Says different-length strings "need `dex_strpatch`-class tooling", while `dex_strpatch.py` requires `len(new) == len(old)` — it is precisely the tool that cannot do this. Contradicts `dex-patching.md:123`. |
| 7 | Coverage vs `toolchain.md` | Coverage lists Unity/IL2CPP logic recovery as **not covered**, but `toolchain.md` Tier 4 names concrete tooling without pointing back at that disclaimer, so a reader of `toolchain.md` alone would take IL2CPP for a supported route. |
| 8 | Script count | `SKILL.md`/`README.md` index 35 rows, which is 34 scripts plus the `scripts/dexpatch/` directory row. |
| 9 | `dart_pool_strings.py` docstring | Says two-byte auto-detection is unreliable (confirmed here); `dart-aot.md` §7 treats UTF-16LE hits as a reliable rule. |
| 10 | `verification.md` rung 7 | Re-downloading a distributed file to compare hashes has no landing place in `SKILL.md`'s six "done" criteria, so the two sections disagree on strength. |
Item 1 is the most damaging because it steers a reader away from the best available evidence.
---
## F5 — "verified mechanisms" left no reproducible trace in the repo
The Coverage preamble frames its list as *verified mechanisms*, and `references/long-task-discipline.md`
defines **Observed** as *"reproduced it, with the exact command and output"*. The repository contains
no sample, no fixture, no captured log or screenshot, no patch record, no test and no CI, and its
`README.md` states it holds no target-specific data.
So the strongest support the repository itself can exhibit for those claims is *Inferred*. This is
**not** an accusation that the claims are false — several were confirmed here on the first attempt,
and `dart-aot.md` §1 proved exactly right. It is a statement about evidentiary standing: the repo
asks its readers to label their own claims and does not leave itself a way to meet its own bar.
The concrete measurement figures that *are* reproducible are the 14 sample-scoped numbers listed in
the skill map (e.g. dex 4.32 MB → 7.73 MB; `.RSA` 1199 B vs 777 B at runtime; a log tag going `8 → 0`).
---
## F6 — Boundary evidence: where the skill's method held, and where it ran out
This is the observation the pass was built to produce, so it is stated as a sequence rather than a
verdict.
**Held, unchanged:** manifest and packer triage; multi-dex string/class recon at 27.6 MB across four
dex files (`dex_strings.py`, `dexutil.py`); native ELF import mapping at scale (36,974 relocations);
live-process library mapping; equal-length `.so` constant patching; the launch-timeline methodology
(`coldstart.py`); the P26 discipline itself — checking whether a tool was ever able to answer before
reading its silence as a finding, which is what turned the blutter crash from a false "unusable" into
a corrected "works after a relink".
**Ran out:** from the Dart snapshot inward. Everything past `pp.txt` is fine; getting *to* `pp.txt`
from the binary requires a container resolver the kit does not have. That is one dependency, not a
subject area.
**Not exercised at all:** packers, code virtualization, custom linkers, integrity-check redirection,
tamper-triggered suicide. This target has none of those, and the unmodified app's own startup failure
also removes the repack loop those scenarios need.
---
## F7 — A trap in this specific environment that will produce false confirmations
`androguard` 4.x (the version installed here) has **four independent reference-analysis failures on
this sample**: `get_xref_from()` returns 0 for everything, including `AdLoader.loadAd()V` which has
20+ callers by other means; `get_ref()` raises; a full `get_operands()` sweep returns 0 strings.
Anyone who reaches for androguard to double-check a "zero references" conclusion will get **zero, and
therefore agreement**. The zero-reference result in `TARGET-FACTS.md` does not come from androguard;
it comes from `dexutil.py` plus raw-byte re-checks of the bodies that would not decode cleanly. This
is worth a line in the environment documentation because it is a tool that lies in the direction of
the hypothesis being tested.
---
## F8 — Reconciling the two failures: a genuine but unproven loader signal
Recorded as a boundary between what was established and what was guessed, because collapsing them
would be its own error.
The launch does show non-standard startup plumbing (observed):
- `E LoaderLog: 10026 / 101004` on the device, `10020 / 101003` on the emulator — an SDK-side
loader logging numeric status codes
- `E Instrumentation: Uninitialized ActivityThread, likely app-created Instrumentation, disabling
AppComponentFactory` — the process is not taking the ordinary `ActivityThread` path
- `E m.tingfeng.too: Unknown bits set in runtime_flags: 0x40000000`
And yet the dex-side packer verdict is clean: no hardening library in the native set, an ordinary
`application android:name` (the app's own class, not a packer stub), no known packer markers. Those two
observations sit in tension.
**Not resolved.** The `LoaderLog` emitter was not attributed to a specific class — the string exists
in `classes2.dex` and `classes3.dex` but the classes that declare it are third-party, and this pass
did not trace the call chain. Whether this plumbing is a benign logging SDK, part of the Flutter
plugin bootstrap, or a light-weight loader contributing to the abort is **unverified**. Stating it
as established would be exactly the kind of overreach this report is trying to document.
+37
View File
@@ -0,0 +1,37 @@
# Tool verification record
This directory is the **evidence record for the `apk-reverse` skill measured against real
targets**, kept separate from the skill itself so the Coverage statement in `SKILL.md` can be
checked against the runs behind it, and so a later reader can tell which claims were *observed*,
which were *inferred*, and which are still *unverified*.
It is **not** a set of target-specific notes kept for reuse, and it deliberately carries **no target
identity**. The measurements are about the **tools and the skill**; they stay valid whichever APK
produced them. Where a concrete figure was needed to make a point (a class count, a file size, a
build time) the figure is kept and the target is named generically.
Targets measured: Flutter AOT applications, multi-dex, mid-size native library sets, no packer.
The APKs themselves are **not** in this repository (`.gitignore` excludes `*.apk`).
## Strength labels
Every claim carries one of three labels, used the same way `references/verification.md` uses them:
- **observed** — reproduced here, with the exact command and its output.
- **inferred** — follows from observed facts, but the step itself was not executed.
- **unverified** — assumed or reported by a tool and not independently confirmed.
## Index
| File | Covers |
|---|---|
| `TOOL-VERDICTS.md` | One verdict per tested script and external toolchain, with the independent cross-check behind it |
| `FINDINGS.md` | Findings about the skill itself: defects, contradictions, boundary evidence |
| `REPO-DECISIONS.md` | What changed in the repo as a result, and what deliberately did not |
## Adding a run
Record the tools, versions and exact commands, and label each claim. Keep target-specific facts out:
what belongs here is what a future reader can apply to a *different* APK — a defect, a measured
cost, a method that did or did not work, and the cross-check that settled it.
+183
View File
@@ -0,0 +1,183 @@
# Repository decisions, and the evidence behind them
The pass was framed as a choice between two ways to split the skill. The measurement rejected the
framing, and this file records why, then states exactly what changed in the repository.
---
## The decision: **no second skill.** Fix skill A instead.
This is the "暂不建" outcome the brief explicitly permits, and it is supported by the evidence rather
than by preference.
### Why the framing did not survive contact with the target
The two candidate splits were:
- **by entry point** — everything reached from an APK (including Flutter/IL2CPP) stays in A; only
standalone ELF goes to B. Cost: A keeps growing.
- **by logic location** — A keeps only logic genuinely in dex; Flutter, IL2CPP, hardened dex, native
and standalone ELF all move out. Cost: four references (~962 lines) relocate.
The measured target is exactly the case that was supposed to discriminate between them: Flutter
(machine-code side) whose entry point is an APK. It produced a third answer.
**The problem is not where the content lives. It is one missing dependency.** Everything the kit does
*after* a snapshot dumper has produced `pp.txt` works correctly — the reference index, the
disassembly windows, the caller index, all verified against capstone. The single thing it cannot do
is get *from the binary to* `pp.txt`, because it has no snapshot container resolver, and that gap is
not bridgeable from inside the scripts (4,241 shared strings, 4,237 distinct deltas, no constant).
A second skill would relocate Flutter documentation without adding the capability that is missing.
The gap would travel with the documents. Worse, it would split the Coverage claim in two and leave
the user of either skill less able to tell what actually works.
### The supporting evidence, in order of weight
1. **The kit works where it was tested.** `dart_disasm.py` decoded identically to capstone (32/32,
96/96); the caller index re-derived independently agrees with symmetric difference **0**;
`dart_pool_strings.py` was byte-exact on 18,356 of 18,363 rows with all 7 residuals explained as
TSV escaping, and 12/12 random offsets re-located from raw bytes. These are not the results of a
tool that needs replacing.
2. **The blocker is a front end, and free front ends exist.** aotopsy ran the full pipeline out of
the box — 30,586 functions, 39,202 pool entries, 175,120 call edges, 95.7% BLR annotation — with
**no toolchain**: pure Go, no compiler, no Dart SDK. That is the "decoding the object pool"
capability, delivered as a binary.
3. **A split would contradict the Coverage section's own structure.** Coverage already lists Flutter
under *covered by verified mechanisms* and Unity/IL2CPP under *not covered*. The real defect is
that the Flutter line overstates what ships. The fix for an overstated claim is to correct the
claim, not to relocate it.
4. **The cost of the split lands on the wrong people.** Moving `dart-aot.md`,
`native-and-so.md`, `native-tamper-and-suicide.md` and `code-virtualization-and-custom-linkers.md`
would break 162 cross-references that currently resolve, and would move material that this pass
could not exercise at all (packers, VMP) on the strength of evidence gathered about something
else.
### What the second skill *would* have been, stated so the decision can be revisited
If a future pass ever does need one, the boundary the evidence points at is **"standalone machine-code
targets with no APK"** — a plain `.so`/ELF analysed on its own. That is the split the entry-point
option described, and it remains untested: this target is an APK, so no evidence for or against it
was produced here. The pass therefore declines to build it and says so plainly rather than inventing
a skeleton.
### Selective installation, measured — and what it implies for shared tooling
The brief asked for the shared-tooling question to be settled by the actual install product rather
than by preference. Measured with `npx skills add newliver666/apk-reverse --skill apk-reverse -y
--copy` in an empty directory:
- The skill is copied **whole and self-contained** into each detected agent directory — `.agents/`,
`.claude/`, `.kiro/`, `.pi/`, `.qwen/`, `.zcode/`, six in total on this host — plus a
`skills-lock.json` at the root.
- Every file lands under `<agent>/skills/apk-reverse/`: `SKILL.md`, all 28 `references/*.md`, all 34
`scripts/*` including the `dexpatch/` subdirectory. **Nothing outside the skill directory is
installed.** The repository's root-level `docs/` and the three maintenance checkers are absent from
the product, as intended.
- There is **no cross-skill sharing mechanism** in the installed layout.
Consequence for this decision: had the skill been split, the two halves would each have needed their
own copy of any shared script, or a path that points outside the skill directory — and the latter
does not survive installation, because it is not part of the product. So the "shall we put shared
tools in a root `shared/`" question resolves itself: it cannot work that way, and with one skill there
is nothing to share. The record is kept here because the observation is what makes the reasoning
checkable rather than asserted.
---
## Changes made
### `references/dart-aot.md`
| Change | Reason |
|---|---|
| Documented a **pinned front end** (aotopsy v1.6.0, sha256 `5a90bcf2…fdc1`; Dart 2.10–3.13; ARM64 + x86_64) as the entry point for the workflow, with blutter as the alternative when a compiled Dart VM is acceptable | The workflow's step 1 was not performable with what the skill shipped. Measured: the kit cannot go from a string to a pool offset. |
| Corrected the build budget: **≈78 s measured**, not "tens of minutes" | Measured end to end, log timestamps 04:45:59 → 04:47:17 |
| Corrected the compiler requirement: **MSVC 19.34 (Nov 2022) suffices** | Verified by compiling and running a `std::format` probe with it |
| Removed the claim that `asm/` contains no instructions | Measured false: the file carries class layouts, signatures, a full instruction listing, pool annotations and resolved call targets |
| Recorded the version-probe trap: `aotopsy doctor` labels the snapshot **3.6.2**, the engine banner and snapshot hash say **3.6.0**, and 3.6.0 is correct | Three independent facts agree on 3.6.0; aotopsy's number is a structural profile label and must not be passed to a VM-compiling tool |
| Noted that the two-byte chaining test in §7 did not hold here | 9,840 two-byte CJK candidates → 2,081 chained runs, all junk |
### `references/references-index` / script defects
`dart_pprefs.py` is fixed in the repository. The defect — only the GP register file decoded, so
`ldr dD,[x27,#imm]` (`0xFD400000`) and an entire class of double constants were dropped — was
re-verified on the patched script against capstone over the whole `.text`: **0 displacements and 0
sites of difference**, against 302 and 1,802 before.
`find_refs.py`, `elf_plt.py` and the `so_constpatch.py` alignment defect are **documented but not
fixed**. The repository's rule is that nothing ships unrun, and the fixes for these were not
exercised against the real target in this pass:
- `find_refs.py` — accepting `.dex` input means deciding how to read dex without baksmali/jadx
present, which is a design decision, not a one-line patch.
- `elf_plt.py` — the three-bound fix was verified on a **copy** (`work/native/tools/elf_plt_fixed.py`),
not in the repository, so the repository version is untouched.
- `so_constpatch.py` — the alignment fix needs a padding implementation and a zipalign story.
Each is recorded in `docs/tool-verification/FINDINGS.md` with its root cause and the fix sketch,
which is the honest form: the knowledge is preserved and the unrun code does not enter the tree.
### `SKILL.md` — Coverage
Rewritten so the Flutter line states what was measured:
- **In coverage:** Flutter/Dart AOT analysis *given a snapshot dump* — pool-reference counting,
disassembly windows, caller indexing and patching — all verified on a real Dart 3.6.0
`libapp.so`, with the front end named as a documented dependency.
- **Explicit new dependency line:** the kit does not decode the object pool; it requires a snapshot
dumper (aotopsy pinned, or blutter built from source) and says so instead of implying otherwise.
- **Unchanged:** Unity/IL2CPP, React Native/Hermes, iOS, defeating a server-side authority, and the
general-unpacker/anti-detection arms race all remain uncovered.
- **Added:** an explicit statement that packer, VMP and integrity-check scenarios were **not
exercised** by this pass, so no claim is upgraded on their behalf.
### `SKILL.md` — the three hand-off points
Written from the perspective that a hand-off is a pointer, and that duplicating content across two
places is how the two copies drift. Each one says what the other side owns.
1. **JNI** — Java declaring a native method ↔ its `.so` implementation. Static linkage shows as
`Java_com_pkg_Class_method` and disappears under R8 short names; dynamic registration leaves the
symbol table empty and must be found at `RegisterNatives`. This target ships the static form
(`FlutterJNI.loadLibrary` appears in `classes3.dex`) but the pass did not trace a JNI boundary
end to end, so the section carries the **unverified** label.
2. **Hardening** — dex-side observation of a packer implies a native-side shell implementation.
**Not exercised here**: this target has no packer. Recorded as a pointer with that stated.
3. **Existing boundary documents** — `native-and-so.md` and `native-tamper-and-suicide.md` read
native anomalies *from the APK side* (immediate SIGSEGV after a repack, Java claiming the check
passed while the process dies). That belongs in A; deep native work is the other side. The new
section points each way rather than repeating either.
### `docs/tool-verification/`
New directory holding this pass's evidence record. `README.md` states the folder is a *measurement
record, not shipped target data*, carries the strength-label convention, and requires that **no target
identity is recorded** — only what a future reader can apply to a different APK. It is referenced from
`SKILL.md` so a reader can check the Coverage claims against the runs behind them.
No APK is in the repository (`.gitignore` already excludes `*.apk`) and no credential or token
appears in any file.
### `README.md`
Updated for the directory tree, the install command and the division of labour between the skills,
plus a pointer to the verification record.
---
## Verification
All four gates were re-run against the final tree:
- `python -B check_repo.py`
- `python -B check_refs.py`
- `python -B build_scripts.py . <out>`
- `npx skills add newliver666/apk-reverse --list`
Two behaviours of the checkers worth knowing, both established by experiment during this pass:
`check_repo.py` and `check_refs.py` have **no argparse**, so `--help` is silently ignored and the
full check runs; and running `check_repo.py` with `PYTHONIOENCODING=utf-8` set produces a spurious
failure (exit 1, one script reported as crashed) because its `subprocess.run(..., text=True)` does
not pin an encoding while the child emits UTF-8 — the parent then decodes as cp936 and the streams
come back empty.
+509
View File
@@ -0,0 +1,509 @@
# Tool verdicts — measured on a real target
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:<app>/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/<plugin>/src/ads/reward/` 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.