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:
@@ -0,0 +1,218 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Report which native libraries are ACTUALLY mapped into a live process.
|
||||
|
||||
`getprop ro.product.cpu.abi` tells you what the device claims to be. It does not
|
||||
tell you what is executing. Those differ more often than people expect:
|
||||
|
||||
* an x86_64 emulator running an app whose only native libraries are arm64,
|
||||
because an ARM translation layer is present;
|
||||
* a hardened app whose real libraries are materialized into its data
|
||||
directory at runtime, so the paths you see are not the paths inside the APK;
|
||||
* a fat APK where the package manager picked a different ABI than you assumed,
|
||||
so you patch a `.so` that never loads.
|
||||
|
||||
This script reads the live mapping table and reports, per library: the path, the
|
||||
base address, and the architecture of the actual ELF on disk. It classifies each
|
||||
path so the interesting ones stand out (system / from-APK / runtime-materialized).
|
||||
|
||||
Requires root to read another process's maps.
|
||||
|
||||
Usage:
|
||||
python lib_map.py --pkg com.example.app
|
||||
python lib_map.py --pkg com.example.app --app-only
|
||||
python lib_map.py --pid 1234 --all
|
||||
python lib_map.py --pkg com.example.app --json
|
||||
python lib_map.py --pkg com.example.app --grep ssl
|
||||
|
||||
Reading it:
|
||||
* A library whose path sits under the app's data dir (not under the APK's
|
||||
lib/<abi> dir) was written at runtime. That is a packer/dropper fingerprint
|
||||
and it will not be present in a repacked APK -- patch or replace with care.
|
||||
* If app libraries are 64-bit ARM but the device primary ABI is x86_64,
|
||||
a translator is in play. Timing and some native checks will differ.
|
||||
* If the library you patched does not appear here at all, your patch cannot
|
||||
matter. That is a plan problem, not a patch problem.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
|
||||
E_MACHINE = {
|
||||
0x03: "x86", 0x3E: "x86_64", 0x28: "arm", 0xB7: "aarch64",
|
||||
0x08: "mips", 0xF3: "riscv", 0x16: "s390", 0x15: "ppc",
|
||||
}
|
||||
TRANSLATOR_MARKERS = ("houdini", "libndk", "native_bridge", "libntv", "armtrans",
|
||||
"libhoudini", "ndk_translation", "libarm", "exagear", "box64")
|
||||
|
||||
|
||||
def run(cmd: list[str], timeout: int = 60) -> str:
|
||||
try:
|
||||
p = subprocess.run(cmd, capture_output=True, text=True, errors="replace", timeout=timeout)
|
||||
return (p.stdout or "") + (p.stderr or "")
|
||||
except Exception as e:
|
||||
return "ERROR: %s" % e
|
||||
|
||||
|
||||
def classify(path: str, pkg: str | None) -> str:
|
||||
if path.startswith(("/system/", "/apex/", "/vendor/", "/product/", "/odm/", "/system_ext/")):
|
||||
return "system"
|
||||
if re.search(r"/data/app/", path):
|
||||
return "apk-lib"
|
||||
if pkg and (("/data/data/%s" % pkg) in path or ("/data/user/0/%s" % pkg) in path):
|
||||
return "materialized"
|
||||
if "/data/" in path:
|
||||
return "data-other"
|
||||
return "other"
|
||||
|
||||
|
||||
def read_elf_arch(sh_fn, path: str) -> str:
|
||||
"""Read the ELF header on the device and name the machine. '?' means the read failed."""
|
||||
hdr = sh_fn("dd if='%s' bs=20 count=1 2>/dev/null | od -An -tx1 2>/dev/null" % path)
|
||||
hexs = re.sub(r"[^0-9a-fA-F]", "", hdr)
|
||||
if len(hexs) < 40:
|
||||
return "?"
|
||||
b = bytes.fromhex(hexs[:40])
|
||||
if b[:4] != b"\x7fELF":
|
||||
return "not-elf"
|
||||
cls = {1: 32, 2: 64}.get(b[4], 0)
|
||||
little = b[5] == 1
|
||||
mach = int.from_bytes(b[18:20], "little" if little else "big")
|
||||
if mach in E_MACHINE:
|
||||
return E_MACHINE[mach] # the machine name already implies the width
|
||||
return "0x%02x/%db" % (mach, cls or 0)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
tgt = ap.add_mutually_exclusive_group(required=True)
|
||||
tgt.add_argument("--pkg", help="target package name")
|
||||
tgt.add_argument("--pid", type=int, help="target pid")
|
||||
ap.add_argument("--adb", default=os.environ.get("ADB", "adb"))
|
||||
ap.add_argument("--serial", default=None)
|
||||
ap.add_argument("--all", action="store_true", help="include system libraries")
|
||||
ap.add_argument("--app-only", action="store_true", help="only APK libs and runtime-materialized libs")
|
||||
ap.add_argument("--grep", default=None, help="only paths matching this regex")
|
||||
ap.add_argument("--no-arch", action="store_true", help="skip reading ELF headers (faster)")
|
||||
ap.add_argument("--max-libs", type=int, default=120, help="cap how many libraries get an arch probe")
|
||||
ap.add_argument("--json", action="store_true", dest="as_json")
|
||||
a = ap.parse_args()
|
||||
|
||||
def sh(cmd: str, timeout: int = 60) -> str:
|
||||
base = [a.adb] + (["-s", a.serial] if a.serial else []) + ["shell", cmd]
|
||||
return run(base, timeout)
|
||||
|
||||
def su(cmd: str, timeout: int = 90) -> str:
|
||||
base = [a.adb] + (["-s", a.serial] if a.serial else []) + ["shell", 'su -c "%s"' % cmd]
|
||||
return run(base, timeout)
|
||||
|
||||
pid = a.pid
|
||||
if a.pkg and not pid:
|
||||
out = sh("pidof %s" % a.pkg).strip()
|
||||
if not out:
|
||||
print("no running process for %s -- launch it and let it settle first" % a.pkg, file=sys.stderr)
|
||||
return 2
|
||||
pid = int(out.split()[0])
|
||||
|
||||
maps = su("cat /proc/%d/maps" % pid)
|
||||
if not maps.strip() or "No such file" in maps:
|
||||
print("cannot read /proc/%d/maps (root required, or the process is gone)" % pid, file=sys.stderr)
|
||||
return 2
|
||||
|
||||
entries: dict[str, dict] = {}
|
||||
for line in maps.splitlines():
|
||||
m = re.match(r"^([0-9a-f]+)-([0-9a-f]+)\s+(\S+)\s+([0-9a-f]+)\s+\S+\s+\S+\s*(.*)$", line)
|
||||
if not m:
|
||||
continue
|
||||
start, end, perms, off, path = m.groups()
|
||||
path = path.strip()
|
||||
if not path or path.startswith("["):
|
||||
continue
|
||||
if not path.endswith(".so") and ".so." not in path:
|
||||
continue
|
||||
e = entries.setdefault(path, {"path": path, "perms": set(), "base": start, "count": 0})
|
||||
e["perms"].add(perms)
|
||||
e["count"] += 1
|
||||
if perms.startswith("r-x") or perms.startswith("r--"):
|
||||
e["base"] = min(e["base"], start)
|
||||
|
||||
dev_abi = sh("getprop ro.product.cpu.abi").strip()
|
||||
abilist = sh("getprop ro.product.cpu.abilist").strip()
|
||||
pkg = a.pkg
|
||||
if not pkg:
|
||||
cmdline = su("cat /proc/%d/cmdline" % pid).split("\x00")[0].strip()
|
||||
pkg = cmdline or None
|
||||
|
||||
rows = []
|
||||
probed = 0
|
||||
for path, e in sorted(entries.items()):
|
||||
kind = classify(path, pkg)
|
||||
if a.app_only and kind not in ("apk-lib", "materialized"):
|
||||
continue
|
||||
# --grep is an explicit narrow request, so honour it across all kinds
|
||||
if not a.all and not a.app_only and not a.grep and kind == "system":
|
||||
continue
|
||||
if a.grep and not re.search(a.grep, path):
|
||||
continue
|
||||
rows.append({"path": path, "base": e["base"], "kind": kind,
|
||||
"perms": "/".join(sorted(e["perms"])), "maps": e["count"], "arch": "?"})
|
||||
|
||||
if not a.no_arch:
|
||||
for r in rows[:a.max_libs]:
|
||||
r["arch"] = read_elf_arch(su, r["path"])
|
||||
probed += 1
|
||||
|
||||
# translation markers
|
||||
markers = sorted({p for p in entries if any(t in p.lower() for t in TRANSLATOR_MARKERS)})
|
||||
app_archs = sorted({r["arch"] for r in rows if r["kind"] in ("apk-lib", "materialized") and r["arch"] not in ("?", "not-elf")})
|
||||
host_is_x86 = "x86" in dev_abi
|
||||
translated = bool(markers) or (host_is_x86 and any(x.startswith(("arm", "aarch64")) for x in app_archs))
|
||||
|
||||
if a.as_json:
|
||||
print(json.dumps({"pid": pid, "pkg": pkg, "device_abi": dev_abi, "abilist": abilist,
|
||||
"translation_suspected": translated, "translator_markers": markers,
|
||||
"libs": rows}, indent=2, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
print("pid=%s pkg=%s" % (pid, pkg or "?"))
|
||||
print("device primary abi=%s abilist=[%s]" % (dev_abi, abilist))
|
||||
print("%-13s %-9s %-6s %-6s %s" % ("arch", "kind", "perms", "maps", "path"))
|
||||
print("-" * 100)
|
||||
for r in rows:
|
||||
print("%-13s %-9s %-6s %-6d %s" % (r["arch"], r["kind"], r["perms"], r["maps"], r["path"]))
|
||||
print("-" * 100)
|
||||
print("mapped libraries shown: %d" % len(rows))
|
||||
if not a.no_arch and probed < len(rows):
|
||||
print("(architecture probed for the first %d; raise --max-libs to cover the rest -- '?' means not probed)"
|
||||
% probed)
|
||||
|
||||
if markers:
|
||||
print("\nTRANSLATOR MARKERS PRESENT:")
|
||||
for mk in markers:
|
||||
print(" " + mk)
|
||||
print(" -> native ARM code is being translated. Timing differs from a real ARM device,")
|
||||
print(" and native integrity checks may behave differently. Verify the final artifact")
|
||||
print(" on the ABI the user actually runs.")
|
||||
if app_archs:
|
||||
print("\napp library architectures: %s" % ", ".join(app_archs))
|
||||
if translated:
|
||||
print("translation suspected: YES")
|
||||
mat = [r["path"] for r in rows if r["kind"] == "materialized"]
|
||||
if mat:
|
||||
print("\nRUNTIME-MATERIALIZED libraries (not inside the APK):")
|
||||
for p in mat[:20]:
|
||||
print(" " + p)
|
||||
print(" -> written at runtime by the app or its shell. A repacked APK will not contain")
|
||||
print(" them in this location; account for that before assuming your patch is loaded.")
|
||||
if not rows:
|
||||
print("\nnothing matched. Widen with --all, or check that the process is the one that draws the UI.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user