Files
apk-reverse/scripts/snap.py
T
inliver233 0ea42265b7 Add deliverable-form discipline, execution identity, and four reusable scripts
Generalized from a long hardened-target engagement. Additions target the failure
modes that cost the most time and are not yet covered; nothing here is sample-specific.

New discipline
- SKILL.md: a third override rule -- deliver in the environment the request requires.
  A root-only or live-instrumentation result can look like success while failing the
  actual requirement (unrooted, installable, self-contained). Also adds two classify
  questions: what form the deliverable must take, and which architecture/library is
  actually executing.
- long-task-discipline.md: "the most expensive drift" -- a constraint table (privilege,
  modification form, ABI, network, persistence, distribution), the checkpoint question,
  and how to report a privileged workaround as a fallback rather than a deliverable.
  Plus "bound every wait": timeouts on every command, budgets calibrated from
  measurement so slow and hung are distinguishable, and sampling an observable you can
  inspect instead of sleeping blind.
- environment.md: look at the screen instead of driving blind; which of the two screen
  evidence kinds is trustworthy (an empty accessibility tree is common for
  runtime-rendered UI, where the image is primary rather than a fallback); emulator
  console control and adb recovery; preflight before every experiment block.
- native-and-so.md: what the device claims vs what the package manager chose vs what is
  actually mapped; translation layers and their consequences for hooks and native
  checks; fat-APK ABI selection.
- runtime-data.md: when the app rewrites your edit (server refresh vs local
  regeneration), why chmod/chown cannot work because the app deletes and recreates the
  file, and the immutable-attribute fix; decoding a value that only looks encrypted.
- framework-runtimes.md: string encoding traps (a UTF-8 scan of a UTF-16 snapshot
  returns nothing and reads as "the strings were stripped"), and non-ASCII obfuscated
  identifiers -- resolve by shape, not by name.
- pitfalls.md: P23 the fix lives in a file the app rewrites; P24 the artifact changed
  but the wrong one is executing; P25 "search found nothing" treated as "not there".

New scripts (parameterized, path-agnostic, all with explicit timeouts)
- preflight.py  environment check: device, root, ABI/translation, clock skew, leftover
                proxy/forwards, dead device server, chosen ABI, free space
- lib_map.py    what is actually mapped into a live process: path, base, architecture
                from the ELF header, and whether it came from the APK or was
                materialized at runtime
- blob_decode.py search (don't guess) the framing of a stored value, then re-encode the
                edited payload with the same parameters
- snap.py       bounded burst screenshots + control-tree capture, with a stall detector
                and an explicit verdict on whether the tree is usable

All four were exercised against a live device while writing: preflight correctly
flags multi-device ambiguity and detected the ARM translation layer; lib_map read real
ELF headers and identified the translation shim; blob_decode round-tripped a
rotation+deflate+base64 blob at realistic size in ~0.2s; snap detected a stalled screen
across three hashes.
2026-09-19 22:08:01 +08:00

204 lines
8.8 KiB
Python

#!/usr/bin/env python3
"""Capture what is actually on screen -- and say whether the control tree is usable.
Why this exists
---------------
Driving a UI blind (tap a coordinate, wait, tap again) is the single most
expensive habit in device work. A look at the screen resolves in one step what
coordinate guessing cannot resolve in five: the layout moved, a different dialog
is up, a countdown is frozen, a button is disabled, text says why.
Two independent kinds of evidence, and they are not interchangeable:
* the **image** -- always available, always truthful about what is rendered.
It is the only evidence for UI drawn by a runtime or a web view, where no
real controls exist.
* the **control tree** (`uiautomator dump`) -- precise, diffable, gives exact
bounds and text. Frequently EMPTY for canvas/webview/cross-platform UI.
This script grabs both, reports the size and whether the tree had content, so
you know which one is actually informative before you rely on it. It never
touches app state.
Usage
-----
# one look
python snap.py --out shots --tag before
# watch a wait: 6 samples, 3 s apart (bounded: hard cap of 20 samples)
python snap.py --out shots --tag waiting --count 6 --interval 3
# tree only, no images
python snap.py --out shots --tag tree --tree-only
# when nothing is on screen, capture and it will say so rather than guess
python snap.py --out shots --tag check --serial <serial>
Reading the output
------------------
* `bytes=0` after both capture paths -> the device refused; see the note in the
output rather than concluding the screen is blank.
* `tree=EMPTY` -> do not plan around accessibility bounds. Use the image and
reason from pixels, or drive the app through a non-UI path.
* Identical hashes across samples -> nothing is changing. Stop waiting and go
find out why (this is the stall detector).
"""
from __future__ import annotations
import argparse
import hashlib
import os
import re
import subprocess
import sys
import time
MAX_SAMPLES = 20 # hard cap so a typo cannot produce an unbounded loop
def run(cmd: list[str], timeout: int) -> tuple[int, str, str]:
try:
p = subprocess.run(cmd, capture_output=True, text=True, errors="replace", timeout=timeout)
return p.returncode, p.stdout or "", p.stderr or ""
except subprocess.TimeoutExpired:
return 124, "", "TIMEOUT after %ss: %s" % (timeout, " ".join(cmd[:4]))
except FileNotFoundError:
return 127, "", "not found: %s" % cmd[0]
class Dev:
def __init__(self, adb: str, serial: str | None):
self.adb = adb
self.base = [adb] + (["-s", serial] if serial else [])
self.serial = serial
def sh(self, cmd: str, timeout: int = 60) -> str:
return run(self.base + ["shell", cmd], timeout)[1]
def su(self, cmd: str, timeout: int = 90) -> str:
return run(self.base + ["shell", 'su -c "%s"' % cmd], timeout)[1]
def pull(self, remote: str, local: str, timeout: int = 120) -> bool:
rc, _, err = run(self.base + ["pull", remote, local], timeout)
return rc == 0 and os.path.exists(local) and os.path.getsize(local) > 0
def tree_stats(dev: Dev, workdir: str) -> tuple[str, int, int]:
"""Return (status, node_count, text_count). status in {OK, EMPTY, FAILED}."""
xml = "/data/local/tmp/_snap_ui.xml"
dev.sh("rm -f " + xml)
out = dev.su("uiautomator dump %s" % xml, timeout=90)
if "dumped to" not in out and "UI hierchary" not in out and os.sep not in out:
# some builds print nothing useful; fall through to the file check anyway
pass
local = os.path.join(workdir, "_snap_ui.xml")
if not dev.pull(xml, local, timeout=120):
return "FAILED", 0, 0
try:
data = open(local, encoding="utf-8", errors="replace").read()
except Exception:
return "FAILED", 0, 0
nodes = len(re.findall(r"<node", data))
texts = len([t for t in re.findall(r'text="([^"]*)"', data) if t.strip()])
if nodes == 0:
return "EMPTY", 0, 0
# A tree with nodes but almost no text is usually a shell with no real widgets.
return ("OK" if texts else "SPARSE"), nodes, texts
def capture_image(dev: Dev, workdir: str, name: str) -> tuple[int, str]:
remote = "/data/local/tmp/%s.png" % name
dev.su("rm -f " + remote)
# on-device write then pull: the streaming form returns 0 bytes on some ROMs
dev.su("screencap -p %s" % remote, timeout=90)
local = os.path.join(workdir, name + ".png")
if dev.pull(remote, local, timeout=120):
n = os.path.getsize(local)
if n > 0:
return n, hashlib.sha256(open(local, "rb").read()).hexdigest()[:12]
# fallback: stream to host
rc, out, err = run(dev.base + ["exec-out", "screencap -p"], timeout=120)
if rc == 0:
data = out.encode("latin1", "ignore") if isinstance(out, str) else b""
if data:
open(local, "wb").write(data)
return len(data), hashlib.sha256(data).hexdigest()[:12]
return 0, ""
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--adb", default=os.environ.get("ADB", "adb"))
ap.add_argument("--serial", default=None, help="device serial; required when several are attached")
ap.add_argument("--out", default="snapshots", help="output directory (created if missing)")
ap.add_argument("--tag", default="snap", help="label used in filenames")
ap.add_argument("--count", type=int, default=1, help="samples to take (capped at %d)" % MAX_SAMPLES)
ap.add_argument("--interval", type=float, default=2.0, help="seconds between samples")
ap.add_argument("--tree-only", action="store_true", help="skip images; only read the control tree")
ap.add_argument("--image-only", action="store_true", help="skip the control tree")
a = ap.parse_args()
if a.count > MAX_SAMPLES:
print("count capped at %d (asked for %d)" % (MAX_SAMPLES, a.count), file=sys.stderr)
a.count = MAX_SAMPLES
workdir = os.path.abspath(a.out)
os.makedirs(workdir, exist_ok=True)
dev = Dev(a.adb, a.serial)
# cheap reachability gate with a timeout, so we fail fast and informatively
rc, out, err = run(dev.base + ["shell", "echo __ok__"], timeout=30)
if "__ok__" not in out:
print("device not answering: %s%s" % (out.strip()[:120], (" / " + err.strip()[:120]) if err else ""),
file=sys.stderr)
print("check `adb devices`; if the emulator process is alive but nothing is listening on the adb port,",
file=sys.stderr)
print("restart the instance through the vendor console (see references/environment.md).", file=sys.stderr)
return 2
tree_status = "skipped"
nodes = texts = 0
if not a.image_only:
tree_status, nodes, texts = tree_stats(dev, workdir)
print("control tree: %s (nodes=%d, non-empty text=%d)" % (tree_status, nodes, texts))
if tree_status in ("EMPTY", "FAILED"):
print(" -> the accessibility tree is not usable here. Do NOT plan around bounds from it.")
print(" The image is the primary evidence for this UI. Cross-platform runtimes, web views")
print(" and canvas-drawn UI frequently expose no real controls at all.")
elif tree_status == "SPARSE":
print(" -> tree exists but carries almost no text; treat it as unreliable and read the image.")
prev_hash = None
same_run = 0
for i in range(a.count):
tag = a.tag if a.count == 1 else "%s_%02d" % (a.tag, i)
if a.tree_only:
print("%-14s tree-only sample" % tag)
else:
size, digest = capture_image(dev, workdir, tag)
if size == 0:
print("%-14s bytes=0 -> device refused the capture. Not evidence that the screen is blank." % tag)
else:
note = ""
if digest == prev_hash:
same_run += 1
note = " (identical to previous -> nothing changed)"
else:
same_run = 0
prev_hash = digest
print("%-14s bytes=%-8d sha=%s%s" % (tag, size, digest, note))
print("%-14s %s" % ("", os.path.join(workdir, tag + ".png")))
if same_run >= 2:
print(" -> %d identical samples in a row. This is a stall, not a slow operation." % (same_run + 1))
print(" Stop waiting: inspect the image, or check the app is still alive and in the foreground.")
if i < a.count - 1:
time.sleep(max(0.0, a.interval))
print("\nNow LOOK at the image(s) before deciding the next action.")
print("Driving blind and waiting is how rounds get wasted; the screen usually states the reason.")
return 0
if __name__ == "__main__":
sys.exit(main())