#!/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) | * 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 text run form [ 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())