Files
apk-reverse/docs/tool-verification/EXTENSION-dart-aot-formats.md
T
inliver233 6cffc3d2ac Absorption pass: take the mechanics from four external skill repositories, keep our discipline
Four projects were cloned for comparison (reverse-skill, awesome-game-security,
android-reverse-engineering-claude-skill, MobileRE-Skill) and read for what they do that this
repository does not. None is vendored and none becomes a runtime dependency: what crosses over is a
phase order, a checklist or a command pattern, with the source URL and the retrieval date recorded in
the file that absorbed it.

The rule applied to every candidate: does this make a judgement easier or a verification harder? The
repository is a decision-and-verification layer, and the four things kept are all of that kind.

  new  scripts/scan_leaks.py            target identity scanned before publishing, with an explicit
                                        do-not-anonymize list and four RESULT=/exit states
  new  references/precedents/           the positive half of the record: route including dead ends, a
                                        grade per assertion, measured pits, and a write-back checklist
  new  scripts/svc_scan.py              does a libc-level hook structurally see this call at all
  new  scripts/anti_detect_probe.js     observer-only: name the check, and report the environment it reads
  new  references/desensitization-and-leak-scans.md
  new  references/dart-aot.md sections   the 32-bit snapshot's real string-record format

  check_repo.py now delegates the identity scan to scripts/scan_leaks.py and runs it over
  git ls-files, so the gate reads content instead of layout for the first time. An explicit file list
  cannot be narrowed by the default work-area exemptions -- that defect was caught by the gate itself.

Two claims that this repository had already written down turned out to be wrong, and both are
recorded as refuted rather than quietly rewritten:

  dart-aot.md   the armv7 string table was documented as [len*2 u32le][UTF-16LE]. It is not. The same
                extractor keeps 4,980 chained entries on arm64 and zero on armv7, and the byte layer
                shows the 32-bit record is [header u32][byte-count u32le][UTF-8 payload]: two adjacent
                literals carry 0x46 (70) and 0x48 (72), exactly their byte lengths. The zero was a
                format mismatch, not an empty table. The engine-version banner on the same target is
                66 bytes of "0.0.1" with a blank build id, so the documented regex returns a
                version-shaped lie.
  leak scan     the pass that added the scanner found 26 strong hits inside its own evidence file on
                the day it was written -- the fixture's literal match values, quoted to prove the
                rules fire. The corpus may contain values; the published surface may not.

Measured, not asserted. The check that fires was named on a public MASTG target: three arms with one
variable, the control alive at the end and the probe arm dead in about 300 ms, with strstr("frida") at
t=303 ms and no tombstone. Reproducibility is bounded honestly -- one full sequence in three arms, on
a target whose whole window is sub-second. svc_scan.py's two independent decoders agree on an
identical 214-site set, and ptrace-free dumping gained a negative boundary: a target whose dex is
deflated inside its APK has no whole-image dex in memory to export, for reasons that say nothing about
protection.

Also in this pass: the detector-locating pipeline (detection-and-anti-analysis.md Step 3) with its
branch conditions; the layered Java-to-svc descent and the dump's honesty boundary in
advanced-unpacking.md; a sourced capability-gap list in toolchain.md where every row is graded
URL-verified or measured; the RESULT=-token convention; and SKILL.md opening with an immediate-action
block, because the symptom index and the gates were sitting in the part of a long file nobody reads.

Gates: check_repo.py 0 problem, check_refs.py 0 dangling / 0 warning, check_budget.py 0 failure /
0 warning / 1 pre-existing note. The scan reports RESULT=leaks_found_strong_only, exit 0: four
endpoint/weak hits, all of them addresses from the RFC 5737 documentation range in usage examples.
2026-09-21 22:51:09 +08:00

184 lines
9.4 KiB
Markdown

# EXTENSION — Dart AOT string-table formats, measured on a real dual-ABI app
Evidence file for the `dart-aot.md` changes in this pass. Every claim below carries a grade:
- **observed** — a command was run here and its output is quoted in this file.
- **inferred** — follows from an observed fact or from documented mechanism; the step was not executed.
- **unverified** — assumed or reported elsewhere, not reproduced here.
Target identity is replaced with `<PKG>`; the artifact is referred to as "the sample". The technique
(tool, library, function, hardening product) is kept, per
`skills/apk-reverse/references/desensitization-and-leak-scans.md`.
## 1. What was measured
| Claim under test | Grade | Result |
|---|---|---|
| The arm64 Dart AOT string table is `[0x80\|(len<<1)\|two_byte][payload]` and the entries chain | **observed** | confirmed on 4,980 entries |
| The armv7 (32-bit) snapshot uses the *same* format | **refuted** | the same extractor keeps 0 entries; the tag byte is not present before the literal |
| An armv7 string record is `[length u32le][UTF-16LE chars]`, so the length field reads `len*2` | **refuted** | the u32 before the payload reads the **byte** count, and the payload is UTF-8 |
| The engine version banner always yields `x.y.z (stable) (…)` | **refuted** | a real `libflutter.so` carries `0.0.1` with a blank build id and no `(stable)` |
| An equal-length replacement of a data key is legal on both ABIs without recomputing the tag | **observed** (record layout), **unverified** (end-to-end device run) | the record is self-contained on both ABIs |
Two of the five were assumptions that would have been written into the reference as fact. Both were
killed by a single command each, which is the whole argument for measuring before documenting.
## 2. Sample and preparation
The sample is the locally supplied "complex encryption and hardening" APK (360 shell + Flutter).
The two AOT snapshots were extracted from the zip without repacking:
```
$ python -c "import zipfile; z=zipfile.ZipFile(r'存在问题和例子\<sample>.apk'); \
[print(n, len(z.read(n))) for n in ('lib/arm64-v8a/libapp.so','lib/armeabi-v7a/libapp.so')]"
lib/arm64-v8a/libapp.so 16352152
lib/armeabi-v7a/libapp.so 17629776
```
Work copies live under the ignored workspace `tools/_work/dart/`; nothing from this evidence pass is
committed except the reference text.
## 3. The arm64 packed table — confirmed
```
$ python skills/apk-reverse/scripts/dart_pool_strings.py tools/_work/dart/app_arm64.so s64.tsv --min 6
candidates : 17352
kept (run >= 3) : 4980 (one-byte 4980 / utf16le 0)
below threshold : 12372 [--keep-isolated to keep them]
written s64.tsv
```
The tag arithmetic was checked directly against real literals in the image rather than only through
the script. For each literal, the byte immediately before it must equal `0x80|(len<<1)`:
```
$ python -c "... for m in (b'AdmobBanner', b'showSplashAd', b'AdPgl_Splash'): ..."
### app_arm64.so
AdmobBanner @0x4932e len=11 expected_tag=0x96
tag_byte_at_-1=0x96 matches=True
showSplashAd @0x39deb len=12 expected_tag=0x98
tag_byte_at_-1=0x98 matches=True
AdPgl_Splash @0x493ee len=12 expected_tag=0x98
tag_byte_at_-1=0x98 matches=True
```
**observed**: three independent literals of two different lengths, three matching tag bytes. This is
also the assertion a patch script should perform (`dart-aot.md` §10), and it is what rejects a hit
that landed mid-string.
## 4. The armv7 assumption — refuted, and what is actually there
The starting hypothesis (carried over from an external case) was that the 32-bit snapshot stores a
string as `[tag 0x81][len*2 u32le][UTF-16LE chars]`. Two scanners written against that hypothesis
found nothing, which is itself the evidence:
```
$ python tools/_work/dart/probe3.py app_armv7.so raw # [8-byte hdr ending 0x81][u32le len][chars]
hits=0 chained_runs=0 entries_in_runs=0
$ python tools/_work/dart/probe3.py app_armv7.so packed # the arm64 tag scheme
hits=0 chained_runs=0 entries_in_runs=0
```
The same extractor that keeps 4,980 entries on arm64 keeps **zero** on armv7:
```
$ python skills/apk-reverse/scripts/dart_pool_strings.py tools/_work/dart/app_armv7.so s7.tsv --min 6
candidates : 1355
kept (run >= 3) : 0 (one-byte 0 / utf16le 0)
```
A zero here is a **format mismatch, not an empty string table**, and that distinction is now stated in
`dart-aot.md` §7: reading it as "the strings were stripped" is exactly the wrong conclusion the same
section warns about for UTF-16.
What the record actually looks like, from bytes around a known literal:
```
$ python -c "... b.find(b'SystemChrome') ..."
0x1ffb3c b'\x00\x00\x00\x008\x06U\x00F\x00\x00\x00\xce-\x8a@SystemChrome.setEnab...'
0x204ce4 b'\x00\x00\x00\x008\x06U\x00H\x00\x00\x00\xa45jVSystemChrome.setSyst...'
0x2185c4 b' ere8\x07U\x00J\x00\x00\x00\x8c]\xb4GSystemChrome.setPref...'
```
Read as `[header u32][length u32le][payload]`: `0x00550638` then `0x00000046` = **70**, which is the
byte length of `SystemChrome.setEnabledSystemUIMode` (36 chars) immediately followed by
`SystemChrome.setSystemUIOverlayStyle` (34 chars) — **70 bytes, UTF-8, not UTF-16**. The second
literal starts at the exact byte where the first ends, and its own length field reads `0x00000048` =
72, matching its own continuation.
**observed**: `length == payload byte count`, payload is UTF-8, and in-place equal-length replacement
therefore keeps the record valid without recomputing anything. **refuted**: the `len*2` / UTF-16 form.
The header word is a tags value with full 4-byte alignment, not a constant to hard-code:
```
$ python -c "... alignment histogram over the image ..."
alignment mod 4: {0: 13397}
```
```
$ python tools/_work/dart/perf.py app_armv7.so
scanned 17629776 B in 0.94s using 4-byte stride + int.from_bytes
aligned-header string records: 582
stride-1 micro scan over 17629776 B took 2.80s (found 2762)
```
The literal search plus a backwards length assertion is the tool-independent method, and it costs
under a second over a 17.6 MB image — so validating every candidate is affordable and the armv7
limitation does not justify a hand-built table parser. **unverified**: whether a *different* armv7
snapshot uses a different header word; the reference tells the reader to read it from the file.
## 5. The engine version banner — a version-shaped lie
```
$ python -c "... rfind NUL before the ' on \"android_arm64\"' suffix ..."
end-of-version offset 0x110821
version string start 0x1107f1 => total 66
b'0.0.1 on "android_arm64"'
```
The banner exists, is 66 bytes long, and its numeric field is `0.0.1` with a blank build-id field —
no `(stable)`, no `(Mon …)`. A regex that takes `\d+\.\d+\.\d+` out of this returns a value that would
send a reader to build a Dart VM for `0.0.1`.
**observed** (the byte string above), **inferred** (that this is a build-time population failure rather
than a deliberately rewritten banner). The actionable consequence is in `dart-aot.md` §1: assert the
`(stable) (…)` tail before using the number, and fall back to the snapshot hash otherwise. **The
sample's Dart version was not pinned in this pass** — that is an unverified item, not a finding.
## 6. Cross-validation
Every measurement above is a **raw byte search with an independent arithmetic assertion** (find the
literal in the file; check the byte or word immediately before it against the length of what follows).
It does not depend on `dart_pool_strings.py`, and the script's numbers are reported alongside the raw
probe rather than as the source of truth. The two agree on arm64 (4,980 kept entries, tag byte equal to
`0x80|(len<<1)` at every literal tested) and on armv7 (the packed scheme finds nothing, the byte layer
shows why).
**Not obtained**: a second, independently implemented Dart snapshot parser. `blutter` was not built in
this pass, so the reference's `pp.txt`-side claims were not re-derived here, and the armv7 conclusion
rests on the byte layout rather than on a tool's agreement. That is the boundary of this evidence.
## 7. What this pass changed in the repository
- `skills/apk-reverse/references/dart-aot.md` — §7 gained the armv7 record format and the
"the armv7 zero is a format mismatch" warning; §1 gained the banner-shape assertion; §10 gained the
data-key-versus-API-path rule with its measured failure mode, and the longest-first / assert-the-prefix
rule. No new file, no new index row: the finding belongs in the file that already owns this layer.
- The equal-length-replacement failure (a 404 whose body is not JSON, `jsonDecode` throwing, the app
frozen on the launch logo) is documented as the concrete instance of the top-level constraint
"do not make an API fail to suppress a UI element".
## 8. Not done, and known to be outstanding
1. **No end-to-end device run.** The string-table patch was not repacked, signed and installed in this
pass, so "an equal-length key replacement suppresses the feature while the app still starts" is
`unverified` end-to-end even though the record layout that makes it legal is `observed`.
2. **No `blutter` build**, so no `pp.txt` comparison and no confirmation of the arm64 pool space from a
second implementation (§6).
3. **The sample's Dart version is unpinned** (§5); a structural probe or a snapshot hash is the next
experiment, not a guessed number.
4. **The armv7 header word is not characterised.** It is read from the file and used as a validator;
which component assigns it was not determined.