Files

225 lines
11 KiB
Python

#!/usr/bin/env python3
"""Spawn a target under a Frida probe, DETACH, and only then drive its UI.
Why this exists
---------------
Under spawn mode (`frida -f`, or `device.spawn()` + `resume()`) the target is paused
while the script loads and resumed by us. On several real targets the Activity stack
never comes up in that state: `dumpsys window | grep mCurrentFocus` stays `null`,
screenshots come back blank, and the app looks broken when it is merely unrendered.
The same app launched normally, after Frida has left, renders correctly.
The fix is an ordering rather than a different hook:
1. spawn (`device.spawn()` leaves the target **paused**),
2. attach and load the probe,
3. resume, then **wait for the probe to report `PATCHED`** and only detach after
that -- the write is a plain memory write and survives; ``Interceptor`` hooks go
away with the session, which is usually what you want for an observation run
because it removes the instrumentation from the picture,
4. start the Activity normally and capture.
**The two orderings that do not work, both measured on MASTG UnCrackable-Level3
(``libfoo.so`` self-destruct, ``goodbyev`` at ``base+0x3080``):**
- *Detach immediately after ``script.load()``.* The probe's ``Process.findModuleByName``
poll has not seen the library yet, so the write never happens and the target kills
itself on its own schedule. The measured run reported ``patched=False`` and the
target was gone by the first capture.
- *Hold the target paused until ``PATCHED``.* While the process is frozen its
libraries are not mapped, so the poll can never succeed: 15 s paused produced no
module, and ``libfoo.so base=0x764c0aa000`` appeared 0.11 s after resume.
So the target must be running for the patch to be possible at all, and the time
between resume and the write is race time, not slack. Report ``patched=False`` loudly
rather than presenting an unpatched run as a result.
What the probe may contain
--------------------------
Anything self-contained. A minimal probe that only neutralises a death site and
sends ``PATCHED`` when done is ``hook_patch_only.js`` beside this script; a probe
that also blocks a component or traces calls works too, but remember that its
``Interceptor`` hooks stop at detach while its memory writes do not.
Requirements
------------
A reachable frida-server (``frida-server -l 127.0.0.1:27099`` plus
``adb forward tcp:27099 tcp:27099``), and ``frida`` importable from the same
interpreter. ``adb`` is used only for the device-side steps and for screenshots.
"""
import argparse
import subprocess
import sys
import time
try:
import frida
except ImportError: # keep --help working on a machine without frida
frida = None
DEFAULT_ADB = "adb"
def build_parser():
p = argparse.ArgumentParser(
prog="spawn_patch_detach.py",
description="Spawn under a Frida probe, detach, then launch and capture the "
"app so the UI actually renders.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="Example:\n"
" spawn_patch_detach.py --package com.example.app --js probe.js \\\n"
" --activity com.example.app/.MainActivity --captures 3\n")
p.add_argument("--package", required=True, help="target package name")
p.add_argument("--js", required=True, help="Frida probe script to load before resume")
p.add_argument("--activity", default=None,
help="component to start after detach, as pkg/.Activity "
"(default: let the package start its own launcher)")
p.add_argument("--frida-host", default="127.0.0.1:27099",
help="host:port of the device frida-server (default %(default)s)")
p.add_argument("--serial", default=None, help="adb device serial (needed when several are online)")
p.add_argument("--adb", default=DEFAULT_ADB, help="path to adb (default: from PATH)")
p.add_argument("--wait-patched", type=float, default=15.0,
help="seconds to wait for the probe to report PATCHED after resume; "
"the detach happens only when it arrives (default %(default)s)")
p.add_argument("--rpc-timeout", type=float, default=20.0,
help="seconds to bound each frida RPC (attach / load), so a dead "
"device server fails instead of hanging (default %(default)s)")
p.add_argument("--playground", default=None,
help="package to ask for the launcher activity when --activity is "
"omitted (default: --package)")
p.add_argument("--launch-timeout", type=float, default=30.0,
help="seconds to bound the post-detach `am start` (default %(default)s)")
p.add_argument("--detach-settle", type=float, default=3.0,
help="pause after detach, before launching (default %(default)s)")
p.add_argument("--captures", type=int, default=3, help="number of screenshots (default %(default)s)")
p.add_argument("--interval", type=float, default=18.0,
help="seconds between captures (default %(default)s)")
p.add_argument("--out-prefix", default="capture",
help="local filename prefix for screenshots (default %(default)s)")
p.add_argument("--work-dir", default="/data/local/tmp",
help="device-side scratch directory (default %(default)s)")
p.add_argument("--no-screencap", action="store_true", help="skip screenshots")
return p
def adb_run(args, adb, serial, timeout=200):
cmd = [adb] + (["-s", serial] if serial else []) + list(args)
return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
def root_shell(cmd, adb, serial, timeout=200):
return adb_run(["shell", "su -c '%s'" % cmd], adb, serial, timeout)
def resolve_launcher(adb, serial, arg_activity, package, playground=None):
"""Return the component to start, asking the device when none was given.
`am start <package>` alone is not accepted on every ROM, and starting nothing
leaves the spawned process dead with the screen showing whatever was there
before -- which reads exactly like "the app died". Ask the package manager.
"""
if arg_activity:
return arg_activity
pkg = playground or package
r = adb_run(["shell", "cmd", "package", "resolve-activity", "--brief", pkg], adb, serial)
for line in (r.stdout or "").splitlines():
line = line.strip()
if "/" in line and not line.startswith("priority") and "No activity" not in line:
if line.count("/") == 1 and not line.startswith("Starting"):
return line
return None
def main(argv=None):
args = build_parser().parse_args(argv)
if frida is None:
print("[!] the 'frida' python package is not importable; install it with "
"'pip install frida' (host side)", file=sys.stderr)
return 2
patched = {"done": False}
failed = {"done": False}
def on_message(msg, data):
if msg.get("type") == "send":
payload = msg.get("payload")
print("[MSG] %s" % payload, flush=True)
if payload == "PATCHED":
patched["done"] = True
elif payload == "PATCHFAIL":
failed["done"] = True
elif msg.get("type") == "error":
print("[ERR] %s" % (msg.get("stack") or msg.get("description")), flush=True)
else:
print("[%s] %s" % (msg.get("type", "?").upper(), msg.get("payload")), flush=True)
adb_run(["shell", "su -c 'am force-stop %s'" % args.package], args.adb, args.serial)
time.sleep(1.5)
dev = frida.get_device_manager().add_remote_device(args.frida_host)
pid = dev.spawn([args.package])
print("[i] spawned pid=%d (paused)" % pid, flush=True)
session = dev.attach(pid)
script = session.create_script(open(args.js, encoding="utf-8").read())
script.on("message", on_message)
script.load()
# Resume, then wait for the probe to report. Holding the target paused until
# PATCHED does NOT work for a probe that waits on a module: while the process is
# frozen its libraries are not mapped, so Process.findModuleByName() never
# returns. Measured on MASTG UnCrackable-Level3: 15 s paused -> no module, then
# "libfoo.so base=0x764c0aa000" 0.11 s after resume. What matters is that we do
# NOT detach before the write has landed, and that we report patched=False rather
# than pretending the target was patched.
dev.resume(pid)
print("[i] resumed pid=%d" % pid, flush=True)
deadline = time.time() + args.wait_patched
while time.time() < deadline and not patched["done"] and not failed["done"]:
time.sleep(0.2)
if not patched["done"]:
print("[!] the probe did not report PATCHED in %.1fs. Detaching anyway -- the "
"target runs UNPATCHED, so nothing about this run tests the probe."
% args.wait_patched, flush=True)
print("[i] patched=%s" % patched["done"], flush=True)
time.sleep(0.5)
session.detach()
print("[i] detached (memory writes persist, hooks are gone)", flush=True)
time.sleep(args.detach_settle)
component = resolve_launcher(args.adb, args.serial, args.activity, args.package,
args.playground)
if component:
out = adb_run(["shell", "am start -n %s" % component], args.adb, args.serial,
timeout=args.launch_timeout)
print("[i] am start %s: %s" % (component,
(out.stdout or out.stderr or "").strip()), flush=True)
else:
print("[!] no launcher component resolved; the spawned process may be dead. "
"Pass --activity pkg/.Activity.", flush=True)
for i in range(max(1, args.captures)):
time.sleep(args.interval)
print("\n=== capture %d ===" % i, flush=True)
ps = adb_run(["shell", "ps -A -o PID,ETIME,ARGS | grep -i %s" % args.package.split(".")[-1]],
args.adb, args.serial)
print((ps.stdout or "").strip(), flush=True)
focus = adb_run(["shell", "dumpsys window | grep mCurrentFocus"], args.adb, args.serial)
print((focus.stdout or "").strip(), flush=True)
if not args.no_screencap:
remote = "%s/%s_%d.png" % (args.work_dir, args.out_prefix, i)
adb_run(["shell", "screencap -p %s" % remote], args.adb, args.serial)
local = "%s_%d.png" % (args.out_prefix, i)
adb_run(["pull", remote, local], args.adb, args.serial)
print("[i] screenshot -> %s" % local, flush=True)
print("\n[i] inspect the images; do not conclude from logcat alone.", flush=True)
return 0
if __name__ == "__main__":
sys.exit(main())