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.
This commit is contained in:
inliver233
2026-09-19 22:08:01 +08:00
parent 55432780fc
commit 0ea42265b7
12 changed files with 1537 additions and 34 deletions
+250
View File
@@ -0,0 +1,250 @@
#!/usr/bin/env python3
"""Preflight: prove the environment is sane BEFORE you blame your patch.
Run this at the start of every experiment block, and again whenever something
fails in a way you did not expect. A large share of "my patch broke the app"
turns out to be device state, a dead device server, a leftover proxy setting, or
a clock skew -- all of which look exactly like a broken artifact.
It is deliberately read-only: it changes nothing except optional cleanup of a
device-wide proxy setting and port forwards it created itself.
Usage:
python preflight.py
python preflight.py --serial <serial> --pkg com.example.app
python preflight.py --pkg com.example.app --expect-root
python preflight.py --cleanup # remove proxy + frida port forwards it finds
python preflight.py --json # machine-readable summary
Exit code is 0 when no BLOCKER was found, 1 otherwise. WARN never fails the run.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import shutil
import subprocess
import sys
BLOCKER, WARN, OK, INFO = "BLOCKER", "WARN", "OK", "INFO"
class Report:
def __init__(self) -> None:
self.rows: list[tuple[str, str, str, str]] = []
def add(self, level: str, area: str, detail: str, fix: str = "") -> None:
self.rows.append((level, area, detail, fix))
def worst(self) -> str:
for lv in (BLOCKER, WARN, OK, INFO):
if any(r[0] == lv for r in self.rows):
return lv
return OK
def run(cmd: list[str], timeout: int = 60) -> tuple[int, 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 FileNotFoundError:
return 127, "not found: %s" % cmd[0]
except subprocess.TimeoutExpired:
return 124, "TIMEOUT after %ss: %s" % (timeout, " ".join(cmd[:4]))
except Exception as e: # pragma: no cover - defensive
return 1, "error: %s" % e
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--adb", default=os.environ.get("ADB", "adb"), help="adb executable (default: $ADB or 'adb')")
ap.add_argument("--serial", default=None, help="device serial; required when more than one is attached")
ap.add_argument("--pkg", default=None, help="target package to check (optional)")
ap.add_argument("--expect-root", action="store_true", help="treat missing root as a BLOCKER")
ap.add_argument("--cleanup", action="store_true", help="remove leftover proxy setting and frida forwards")
ap.add_argument("--json", action="store_true", dest="as_json")
a = ap.parse_args()
r = Report()
ADB = a.adb
# ---- host tools -------------------------------------------------------
for tool, why in (
("java", "dexlib2 patcher, apksigner"),
("python", "all scripts"),
):
found = shutil.which(tool)
r.add(OK if found else WARN, "host:" + tool, found or "not on PATH", "" if found else "install it if you need " + why)
for tool in ("apksigner", "zipalign", "aapt", "aapt2"):
found = shutil.which(tool)
r.add(OK if found else INFO, "host:" + tool, found or "not on PATH",
"" if found else "only needed for signing/manifest work; build-tools on PATH helps")
# ---- adb + device selection ------------------------------------------
if shutil.which(ADB) is None and not os.path.exists(ADB):
r.add(BLOCKER, "adb", "executable not found: %s" % ADB, "pass --adb <path> or set $ADB")
return finish(r, a.as_json)
rc, out = run([ADB, "devices", "-l"])
if rc != 0:
r.add(BLOCKER, "adb", "adb devices failed: %s" % out.strip()[:160], "start the adb server; check USB/emulator")
return finish(r, a.as_json)
devices = []
for line in out.splitlines()[1:]:
line = line.strip()
if not line:
continue
parts = line.split()
if len(parts) >= 2:
devices.append((parts[0], parts[1], line))
online = [d for d in devices if d[1] == "device"]
if not devices:
r.add(BLOCKER, "device", "no device attached", "boot the emulator/connect the device, then re-run")
return finish(r, a.as_json)
if len(online) > 1 and not a.serial:
r.add(BLOCKER, "device", "%d devices online but no --serial given" % len(online),
"pass --serial explicitly, or adb will pick one at random and you will debug the wrong target")
if not online:
bad = ", ".join("%s(%s)" % (d[0], d[1]) for d in devices)
r.add(BLOCKER, "device", "attached but not usable: %s" % bad,
"unauthorized -> accept the USB debugging prompt; offline -> reconnect/reboot the emulator")
return finish(r, a.as_json)
serial = a.serial or online[0][0]
r.add(OK, "device", "using %s (of %d online)" % (serial, len(online)), "")
def sh(cmd: str, timeout: int = 60) -> str:
return run([ADB, "-s", serial, "shell", cmd], timeout=timeout)[1]
def su(cmd: str, timeout: int = 90) -> str:
return run([ADB, "-s", serial, "shell", 'su -c "%s"' % cmd], timeout=timeout)[1]
# ---- device facts -----------------------------------------------------
abi = sh("getprop ro.product.cpu.abi").strip()
abilist = sh("getprop ro.product.cpu.abilist").strip()
rel = sh("getprop ro.build.version.release").strip()
sdk = sh("getprop ro.build.version.sdk").strip()
model = sh("getprop ro.product.model").strip()
sec = sh("getenforce").strip()
r.add(OK, "device:os", "Android %s (sdk %s) %s" % (rel, sdk, model), "")
r.add(OK, "device:abi", "%s abilist=[%s]" % (abi, abilist), "")
r.add(OK if sec.lower() in ("permissive", "") else INFO, "device:selinux", sec or "?", "")
# translation layer / emulator hints -- these change how native code behaves
trans = []
for probe in ("ro.dalvik.vm.native.bridge", "ro.enable.native.bridge.exec", "ro.boot.native_bridge"):
v = sh("getprop " + probe).strip()
if v and v != "0":
trans.append("%s=%s" % (probe, v))
r.add(WARN if trans else OK, "device:translation",
"; ".join(trans) if trans else "no ARM translation declared",
"native arm libraries run through a translator here: timing differs and some native checks misbehave" if trans else "")
if re.search(r"^1$", sh("getprop ro.kernel.qemu").strip()):
r.add(INFO, "device:type", "emulator (ro.kernel.qemu=1)",
"final verification belongs on the real ABI the user will run")
# ---- root -------------------------------------------------------------
idout = su("id")
has_root = "uid=0" in idout
lvl = OK if has_root else (BLOCKER if a.expect_root else WARN)
r.add(lvl, "root", "available" if has_root else "NOT available (%s)" % idout.strip()[:60],
"" if has_root else "anything beyond static analysis needs root: data dirs, Frida, file locks")
# ---- clock (silently breaks TLS work) --------------------------------
dev_epoch = sh("date +%s").strip()
if dev_epoch.isdigit():
drift = abs(int(dev_epoch) - int(__import__("time").time()))
r.add(OK if drift < 120 else WARN, "clock",
"device/host drift %ds" % drift,
"" if drift < 120 else "an expired/wrong certificate can be nothing but this clock; fix before TLS triage")
# ---- leftover state that fakes a failure -----------------------------
proxy = sh("settings get global http_proxy").strip()
if proxy and proxy not in ("null", ":0"):
r.add(WARN, "state:proxy", "device-wide http_proxy=%s" % proxy,
"a leftover proxy makes every request fail; clear with: settings put global http_proxy :0")
if a.cleanup:
sh("settings put global http_proxy :0")
r.add(INFO, "state:proxy", "cleared (--cleanup)", "")
else:
r.add(OK, "state:proxy", "not set", "")
rc_f, fwd = run([ADB, "-s", serial, "forward", "--list"])
if fwd.strip():
r.add(INFO, "state:forwards", fwd.strip().replace("\n", " | ")[:200],
"stale forwards point frida at the wrong place; 'adb forward --remove-all' resets them")
if a.cleanup:
run([ADB, "-s", serial, "forward", "--remove-all"])
r.add(INFO, "state:forwards", "removed (--cleanup)", "")
# ---- frida server reachability ---------------------------------------
srv = su("pgrep -f frida-server || pgrep -f kwork")
if srv.strip().isdigit() or srv.strip().split("\n")[0].strip().isdigit():
r.add(OK, "frida:server", "device server process alive (pid %s)" % srv.strip().split("\n")[0].strip(), "")
else:
r.add(INFO, "frida:server", "no device server process found",
"only needed for dynamic work; see references/dynamic-frida.md (name it out of obvious paths)")
# ---- target package ---------------------------------------------------
if a.pkg:
pm = sh("pm list packages | grep -F %s" % a.pkg)
installed = ("package:" + a.pkg) in pm or a.pkg in pm
r.add(OK if installed else INFO, "target", "installed" if installed else "not installed", "")
if installed:
path = sh("pm path %s" % a.pkg).strip()
uid = su("dumpsys package %s | grep -m1 userId=" % a.pkg).strip()
r.add(INFO, "target:apk", path[:160] or "?", "")
r.add(INFO, "target:uid", uid or "?", "re-check after every reinstall: uid increments")
# the native library dir the package manager will use for this device
nat = sh("dumpsys package %s | grep -m3 primaryCpuAbi" % a.pkg).strip()
if nat:
r.add(INFO, "target:abi", nat.replace("\n", " | ")[:160],
"this is the ABI the package manager chose for THIS device")
maps = su("cat /proc/$(pidof %s | awk '{print $1}')/maps 2>/dev/null | head -1" % a.pkg)
if maps.strip():
r.add(INFO, "target:running", "process is running", "prefer a clean cold start before measuring anything")
else:
r.add(OK, "target:running", "not running", "")
# ---- free space -------------------------------------------------------
# Report by device node, not by the mount-point column: a block device can be
# mounted at more than one path (an ARM translation shim does exactly this on
# some emulators), and `df` will then label /data with an unrelated path.
space = ""
for line in sh("df -h /data 2>/dev/null").splitlines()[1:]:
cols = line.split()
if len(cols) >= 5:
space = "%s: %s available of %s (%s used)" % (cols[0], cols[3], cols[1], cols[4])
break
if space:
r.add(INFO, "device:space", space, "")
return finish(r, a.as_json)
def finish(r: Report, as_json: bool) -> int:
if as_json:
print(json.dumps({"worst": r.worst(),
"rows": [dict(level=l, area=ar, detail=d, fix=f) for l, ar, d, f in r.rows]},
indent=2, ensure_ascii=False))
return 1 if r.worst() == BLOCKER else 0
width = max(len(x[1]) for x in r.rows) if r.rows else 10
for level, area, detail, fix in r.rows:
print("%-8s %-*s %s" % ("[" + level + "]", width, area, detail))
if fix:
print(" " * 9 + "%-*s -> %s" % (width, "", fix))
print("\nworst: %s" % r.worst())
if r.worst() == BLOCKER:
print("Fix every BLOCKER before running another experiment. Do not attribute a failure to your patch yet.")
return 1
return 0
if __name__ == "__main__":
sys.exit(main())