Files

9.4 KiB

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.