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.
251 lines
12 KiB
Python
251 lines
12 KiB
Python
#!/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())
|