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