Add Dart AOT depth (reference + 3 scripts); fix encoding rule and add P26

New reference:
- dart-aot.md: pinning the Dart version, building a matching decompiler, the object-pool
  model and reference indexes, register/boolean conventions, the three assembly signatures
  that identify most business logic, the locating workflow, patch strategy, and the traps
  specific to this layer (including ambiguous identifiers that resolve to protocol fields).

New scripts (parameterized, no machine-specific paths):
- dart_pprefs.py: object-pool offset -> code-site index, decoded arithmetically. Avoids the
  full-capstone pass that costs minutes and 1-2 GB and reads as a hang.
- dart_pool_strings.py: framed literal recovery with file offsets. Defaults to one-byte
  because automatic two-byte (UTF-16) detection is unreliable; --find searches both
  encodings and is the reliable way to locate a known literal.
- dart_disasm.py: annotated windowed disassembly (pool + boolean annotations) and a B/BL
  caller index that needs no disassembler.

Fixes and integration:
- framework-runtimes.md: point the Dart depth at dart-aot.md, and make the string-encoding
  rule precise (ASCII is one-byte/UTF-8; CJK is UTF-16LE) instead of leaving it as 'try both'.
- pitfalls.md: P26 -- self-built tooling that fails in a way that reads as a target finding
  (silent arithmetic, helper shadowing a stdlib module, measuring against an incomplete
  reference, and a knob that does not move the metric).
- SKILL.md / README.md: index the new reference and scripts.

Validated by running all three scripts against a real Dart AOT sample: the pool-ref index
agrees with an independently built index to 99.2%, and the literal extractor's one-byte
output was verified string-by-string against a decompiler's pool listing.
This commit is contained in:
inliver233
2026-09-19 22:19:55 +08:00
parent 46b0858efb
commit cf636c4263
8 changed files with 970 additions and 4 deletions
+273
View File
@@ -0,0 +1,273 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Recover string literals from a Dart AOT snapshot, and locate them by file offset.
WHY THIS EXISTS
---------------
Strings are the cheapest anchor for locating logic in a stripped AOT snapshot: field
names, endpoint paths and labels survive compilation even when every symbol name is gone.
Two things this gives you that a decompiler's own listing does not:
* **file offsets**, where the constant physically sits in the binary (what you need to
patch bytes, as opposed to a pool offset);
* **identifiers the decompiler's listing omits** -- measured on a real sample, most of
what this recovers was absent from the pool listing entirely. Treat the two as
complementary, not competitive.
THE FRAMING
-----------
[ tag ][ payload ] tag = 0x80 | (len << 1) | <two-byte flag>
* tag bit 7 set -> a string entry
* (tag >> 1) & 0x3F -> payload length in characters (this framing tops out at 63)
* the low bit -> nominally "payload is UTF-16LE"
ENCODING, AND WHY ONE-BYTE IS THE DEFAULT
-----------------------------------------
The two payload kinds are searched differently, and this is where most people lose time:
* **one-byte** payloads are ASCII / Latin-1 -> a **UTF-8 byte search finds them**
* **two-byte** payloads are **UTF-16LE** -> a UTF-8 search returns *zero hits*
So search ASCII as UTF-8 and CJK/non-Latin as UTF-16LE; forcing one encoding on both is
the classic false negative (`pitfalls.md` P25).
**But automatic two-byte detection does not work reliably.** The low tag bit is not a
dependable discriminator against ordinary data, and UTF-16LE decoding essentially never
fails -- *any* byte pair is a legal code unit -- so decoding success filters nothing. On a
real sample, two-byte output was ~10k entries of near-pure garbage that happened to form
long runs, while one-byte output was almost entirely genuine. Hence:
* default: one-byte only (reliable; verified against a decompiler listing on a real sample)
* `--two-byte`: opt in, and **verify every hit yourself**
* to find a *known* non-Latin literal, use `--find` -- a direct search is far more
reliable than enumerating candidates
WHY THE RUN LENGTH IS REPORTED
------------------------------
A per-byte scan for tag-like bytes hits constantly inside ordinary data; a multi-MB
snapshot yields hundreds of thousands of candidates. Most real string tables are laid out
consecutively, so a genuine entry usually begins a run of back-to-back entries (each
entry's tag+payload ends exactly where the next begins). `--min-chain` uses that to shed
the scattered tail.
Note what the measurement showed: raising this threshold barely changes one-byte
precision while steadily destroying recall. It is a **noise filter, not a confidence
score** -- do not tune it expecting accuracy to improve.
USAGE
-----
python dart_pool_strings.py libapp.so strings.tsv
python dart_pool_strings.py libapp.so strings.tsv --pp pp.txt # annotate cross-hits
python dart_pool_strings.py libapp.so strings.tsv --min-chain 2 --two-byte
python dart_pool_strings.py libapp.so --find /activateVipCode --find vipflag
python dart_pool_strings.py libapp.so --stats
OUTPUT
------
offset_hex <TAB> text <TAB> run <TAB> form [<TAB> in_pp]
`in_pp` is present with `--pp`: `1` means the decompiler's own pool listing contains this
exact string. Absence is **not** evidence of a false positive -- the decompiler's listing
is incomplete by design.
NOTES
-----
* `--max` is capped at 63 by the framing; longer literals are serialized differently and
will not appear here (a few hundred per sample at most).
* Never edit a string without counting its references first: a shared entry changes every
consumer at once (`references/dart-aot.md` section 10).
"""
import argparse
import collections
import re
import sys
TAG_STR = 0x80
BAD = re.compile(r'[\x00-\x08\x0b\x0c\x0e-\x1f]')
def decode_payload(raw, two_byte):
if two_byte:
if len(raw) % 2:
return None
try:
text = raw.decode('utf-16-le')
except UnicodeDecodeError:
return None
else:
try:
text = raw.decode('utf-8')
except UnicodeDecodeError:
return None
if BAD.search(text):
return None
return text
def extract(blob, min_len, max_len, want_two_byte):
"""Return {start: (end, text, form)} for every framed candidate of the wanted kind."""
n = len(blob)
cands = {}
for i in range(n - 2):
tag = blob[i]
if not (tag & TAG_STR):
continue
ln = (tag >> 1) & 0x3F
if not (min_len <= ln <= max_len):
continue
two = tag & 1
if two and not want_two_byte:
continue
end = i + 1 + (ln * 2 if two else ln)
if end > n:
continue
text = decode_payload(blob[i + 1:end], two)
if text is None or len(text.strip()) < min_len:
continue
cands[i] = (end, text, 'utf16le' if two else 'one-byte')
return cands
def run_lengths(cands):
"""For each candidate, how many back-to-back entries follow it (inclusive)."""
nxt = {s: cands[s][0] for s in cands}
lengths = {}
closed = set()
for start in sorted(cands):
if start in closed:
continue
seq = []
cur = start
guard = 0
while cur in nxt and cur not in closed:
seq.append(cur)
closed.add(cur)
cur = nxt[cur]
guard += 1
if guard > 1_000_000:
break
for i, item in enumerate(seq):
lengths[item] = len(seq) - i
return lengths
def load_pp(path):
"""Strings present in a decompiler pool listing, for cross-annotation."""
if not path:
return set()
out = set()
pat = re.compile(r'^\[pp\+0x[0-9a-f]+\] String: (.*)$')
for line in open(path, encoding='utf-8', errors='replace'):
m = pat.match(line.strip())
if not m:
continue
s = m.group(1).strip()
if len(s) >= 2 and s[0] == '"' and s[-1] == '"':
s = s[1:-1]
if s:
out.add(s)
return out
def histogram(lengths):
counts = collections.Counter(lengths.values())
for run in sorted(counts)[:10]:
print(' run=%-4d %d' % (run, counts[run]))
tail = sum(v for k, v in counts.items() if k > 10)
if tail:
print(' run>%-3d %d' % (10, tail))
def search(blob, needles):
total = 0
for needle in needles:
for label, enc in (('utf-8', needle.encode('utf-8')),
('utf-16le', needle.encode('utf-16-le'))):
offsets = []
start = blob.find(enc)
while start != -1 and len(offsets) < 8:
offsets.append(start)
start = blob.find(enc, start + 1)
if offsets:
total += len(offsets)
print('%-26s %-9s file offset %s' % (
needle, label, ' '.join(hex(o) for o in offsets)))
if not total:
print('no hits in either encoding. Try the shortest distinctive fragment -- text is\n'
'often assembled from parts, and a literal may only exist as fragments\n'
'(pitfalls.md P25).')
return 0
def main():
ap = argparse.ArgumentParser(description=__doc__.split('\n')[0])
ap.add_argument('sample', help='libapp.so or any artifact holding the snapshot')
ap.add_argument('out', nargs='?', help='output tsv')
ap.add_argument('--min', type=int, default=4, help='minimum payload length (default 4)')
ap.add_argument('--max', type=int, default=63,
help="maximum payload length; 63 is this framing's ceiling (default 63)")
ap.add_argument('--min-chain', type=int, default=3,
help='noise filter: minimum run of consecutive entries (default 3). '
'This does not improve accuracy -- see the module docstring.')
ap.add_argument('--two-byte', action='store_true',
help='ALSO emit UTF-16 candidates. Detection is unreliable; verify each.')
ap.add_argument('--pp', metavar='PP_TXT',
help='decompiler pool listing, to mark which strings it also contains')
ap.add_argument('--stats', action='store_true',
help='print the run-length histogram and exit')
ap.add_argument('--keep-isolated', action='store_true',
help='also write entries below --min-chain (noisy; triage only)')
ap.add_argument('--find', action='append', default=[], metavar='TEXT',
help='search a literal in BOTH encodings and print file offsets '
'(repeatable; the reliable way to locate a known literal)')
a = ap.parse_args()
blob = open(a.sample, 'rb').read()
if a.find:
return search(blob, a.find)
if not a.out and not a.stats:
raise SystemExit('give an output path, or --find / --stats')
cands = extract(blob, a.min, a.max, a.two_byte)
lengths = run_lengths(cands)
if a.stats:
print('candidates (two-byte included: %s): %d' % (a.two_byte, len(cands)))
print('run-length histogram:')
histogram(lengths)
return 0
pp = load_pp(a.pp)
kept = [s for s in cands if lengths[s] >= a.min_chain]
rows = kept + (sorted(s for s in cands if lengths[s] < a.min_chain)
if a.keep_isolated else [])
with open(a.out, 'w', encoding='utf-8') as fh:
fh.write('# offset_hex\ttext\trun\tform%s\n' % ('\tin_pp' if pp else ''))
for s in sorted(rows):
_end, text, form = cands[s]
flat = text.replace('\n', '\\n').replace('\t', ' ')
extra = ('\t%d' % (1 if text in pp else 0)) if pp else ''
fh.write('0x%06x\t%s\t%d\t%s%s\n' % (s, flat, lengths[s], form, extra))
one = sum(1 for s in kept if cands[s][2] == 'one-byte')
print('candidates : %d' % len(cands))
print('kept (run >= %d) : %d (one-byte %d / utf16le %d)'
% (a.min_chain, len(kept), one, len(kept) - one))
print('below threshold : %d%s' % (
len(cands) - len(kept), '' if a.keep_isolated else ' [--keep-isolated to keep them]'))
if pp:
hits = sum(1 for s in kept if cands[s][1] in pp)
print('also in --pp listing : %d / %d (absence is NOT a false positive: the'
% (hits, len(kept)))
print(' decompiler listing is incomplete by design)')
if a.two_byte:
print('WARNING: --two-byte detection is unreliable (see module docstring);')
print(' verify each UTF-16 hit before trusting it.')
print('written %s' % a.out)
return 0
if __name__ == '__main__':
sys.exit(main())