From edfb903333d0b8e2c6e171e90048315c43ac1ec9 Mon Sep 17 00:00:00 2001 From: Codeblin Date: Tue, 3 Nov 2026 12:48:15 +0200 Subject: [PATCH 1/3] Improve Frida workflow reliability and CLI UX. Bump update frida to the latest PyPI runtime in an isolated venv, expand SSL unpinning for Frida 17, and stop nested progress bars from flickering during fix/ready. Co-authored-by: Cursor --- .gitignore | 5 + droidreaver/banner.py | 205 ++++++---- droidreaver/banner_art.txt | 52 +++ droidreaver/bypass.py | 243 +++++++---- droidreaver/cli.py | 49 ++- droidreaver/console.py | 107 ++++- droidreaver/device.py | 469 +++++++++++++++++----- droidreaver/doctor.py | 4 +- droidreaver/installer.py | 15 +- droidreaver/progress.py | 67 +++- droidreaver/scripts/ssl_pinning_bypass.js | 372 ++++++++++++++--- droidreaver/versions.py | 22 +- pyproject.toml | 2 +- tests/test_banner.py | 17 +- tests/test_bypass.py | 123 ++++-- tests/test_console.py | 40 ++ tests/test_device.py | 97 ++++- tests/test_help.py | 12 + tests/test_progress.py | 9 + tests/test_versions.py | 61 ++- 20 files changed, 1523 insertions(+), 448 deletions(-) create mode 100644 droidreaver/banner_art.txt diff --git a/.gitignore b/.gitignore index a3a3246..67e3810 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,8 @@ Thumbs.db *.p12 .env .env.* + +# DroidReaver - downloaded tools & caches (managed) +.droidreaver/tools/ +.droidreaver/cache/ +.droidreaver/venvs/ diff --git a/droidreaver/banner.py b/droidreaver/banner.py index 504a198..c3cd020 100644 --- a/droidreaver/banner.py +++ b/droidreaver/banner.py @@ -4,6 +4,7 @@ import os from functools import wraps +from pathlib import Path from typing import Callable, TypeVar from rich.align import Align @@ -26,34 +27,17 @@ C_DIM = "#3a6652" # muted green C_GLOW = "#39ff14" # neon -LOGO = r""" - ____ ____ ___ ___ ____ - | _ \| _ \ / _ \_ _| _ \ - | | | | |_) | | | | || | | | - | |_| | _ <| |_| | || |_| | - |____/|_| \_\\___/___|____/ - - ____ _____ _ __ _______ ____ - | _ \| ____| / \\ \ / / ____| _ \ - | |_) | _| / _ \\ \ / /| _| | |_) | - | _ <| |___ / ___ \\ V / | |___| _ < - |_| \_\_____/_/ \_\_/ |_____|_| \_\ -""" - -SIGNAL_PANEL = r""" - /----------------------\ - | apk -> map -> hook | - | adb :: frida :: js | - | jadx :: apktool | - \----------------------/ - \__ mobile lab __/ -""" +LOGO = Path(__file__).with_name("banner_art.txt").read_text(encoding="utf-8") CIRCUIT_FOOTER = ( "[ adb ]---[ frida ]---[ jadx ]---[ objection ]---[ apktool ]" ) TAGLINE = "ANDROID PENTEST ENVIRONMENT" +# Full ASCII plus panel chrome needs about this many columns; below that we +# crop, and below _COMPACT_WIDTH we switch to the one-line header. +_COMPACT_WIDTH = 56 +_PANEL_CHROME = 8 def banner_enabled() -> bool: @@ -61,67 +45,144 @@ def banner_enabled() -> bool: return val not in ("1", "true", "yes") -def _colorize_logo(art: str) -> Text: - result = Text() - lines = art.strip("\n").splitlines() +def _is_wordmark_line(line: str) -> bool: + stripped = line.lstrip() + if not stripped: + return False + if "!" in stripped: + return True + return stripped.startswith("@") and "#" not in stripped + + +def _style_for_face_char(ch: str) -> Style: + if ch in ":. ": + return Style(color=C_DIM) + if ch in "@%#": + return Style(color=C_PRIMARY, bold=True) + if ch in "*+=": + return Style(color=C_GLOW, bold=True) + return Style(color=C_PRIMARY) + + +def _style_for_wordmark_char(ch: str) -> Style: + if ch in "@": + return Style(color=C_PRIMARY, bold=True) + if ch in "!": + return Style(color=C_GLOW, bold=True) + if ch in ":": + return Style(color=C_PRIMARY) + return Style(color=C_DIM) + + +def _stamp(line: str, text: str, *, align: str = "center") -> tuple[str, int]: + width = max(len(line), len(text) + 4) + if align == "right": + start = max(0, width - len(text) - 2) + elif align == "left": + start = 2 if width > len(text) + 2 else 0 + else: + start = max(0, (width - len(text)) // 2) + base = line.ljust(width) + end = start + len(text) + if end > len(base): + base = base.ljust(end) + return base[:start] + text + base[end:], start + + +def _compose_logo( + art: str, + version: str, + *, + max_width: int | None = None, +) -> tuple[list[str], dict[int, tuple[int, str, Style]], int]: + lines = [line.rstrip() for line in art.strip("\n").splitlines()] + face_end = next((i for i, line in enumerate(lines) if _is_wordmark_line(line)), len(lines)) + stamps: dict[int, tuple[int, str, Style]] = {} + + def apply(index: int, text: str, style: Style, *, align: str = "center") -> None: + if max_width is not None and len(text) > max_width: + return + stamped, start = _stamp(lines[index], text, align=align) + if max_width is not None and len(stamped) > max_width: + stamped = stamped[:max_width] + if start >= max_width: + return + text = text[: max(0, max_width - start)] + lines[index] = stamped + stamps[index] = (start, text, style) + + apply(6, f"v{version}", Style(color=C_WARN, bold=True), align="right") + chin = [i for i in range(face_end - 2, face_end) if i >= 0] + if chin: + apply(chin[0], f":: {TAGLINE} ::", Style(color=C_GLOW, bold=True)) + if len(chin) > 1: + apply(chin[1], "APK WORKFLOW // MOBILE SECURITY", Style(color=C_SECONDARY, bold=True)) + + gaps = [i for i in range(face_end, len(lines)) if not lines[i].strip()] + if gaps: + apply(gaps[0], CIRCUIT_FOOTER, Style(color=C_DIM)) + + if max_width is not None: + lines = [line[:max_width] for line in lines] + + return lines, stamps, face_end + + +def _colorize_logo(art: str, *, version: str, max_width: int | None = None) -> Text: + result = Text(no_wrap=True, overflow="crop") + lines, stamps, face_end = _compose_logo(art, version, max_width=max_width) + for i, line in enumerate(lines): if i > 0: result.append("\n") - if not line: - continue - color = C_SECONDARY if i < 5 else C_PRIMARY - result.append(line, style=Style(color=color, bold=True)) + stamp = stamps.get(i) + stamp_range = range(stamp[0], stamp[0] + len(stamp[1])) if stamp else range(0) + wordmark = i >= face_end + for j, ch in enumerate(line): + if stamp and j in stamp_range: + result.append(ch, style=stamp[2]) + elif wordmark: + result.append(ch, style=_style_for_wordmark_char(ch)) + else: + result.append(ch, style=_style_for_face_char(ch)) return result -def _colorize_panel(art: str) -> Text: - result = Text() - lines = art.strip("\n").splitlines() - for i, line in enumerate(lines): - if i > 0: - result.append("\n") - style = Style(color=C_WARN if i in (1, 2, 3) else C_DIM, bold=i in (1, 2, 3)) - result.append(line, style=style) - return result +def _compact_banner(version: str) -> RenderableType: + line = Text() + line.append("[dr] ", style=Style(color=C_ACCENT, bold=True)) + line.append("Droid", style=Style(color=C_SECONDARY, bold=True)) + line.append("Reaver", style=Style(color=C_PRIMARY, bold=True)) + line.append(f" v{version} ", style=Style(color=C_DIM)) + line.append("|", style=Style(color=C_DIM)) + line.append(" android pentest environment ", style=Style(color=C_GLOW, italic=True)) + return Panel( + line, + border_style=Style(color=C_DIM), + padding=(0, 1), + ) -def render_banner(*, compact: bool = False, version: str = __version__) -> RenderableType: - if compact: - line = Text() - line.append("[dr] ", style=Style(color=C_ACCENT, bold=True)) - line.append("Droid", style=Style(color=C_SECONDARY, bold=True)) - line.append("Reaver", style=Style(color=C_PRIMARY, bold=True)) - line.append(f" v{version} ", style=Style(color=C_DIM)) - line.append("|", style=Style(color=C_DIM)) - line.append(" android pentest environment ", style=Style(color=C_GLOW, italic=True)) - return Panel( - line, - border_style=Style(color=C_DIM), - padding=(0, 1), - ) - - hero = Table.grid(padding=(0, 3)) - hero.add_column(no_wrap=True) - hero.add_column(no_wrap=True) - hero.add_row(_colorize_logo(LOGO), _colorize_panel(SIGNAL_PANEL)) - hero_lockup = Align(hero, align="center") - identity = Text(justify="center") - identity.append(":: ", style=Style(color=C_ACCENT, bold=True)) - identity.append(TAGLINE, style=Style(color=C_GLOW, bold=True)) - identity.append(" ::", style=Style(color=C_ACCENT, bold=True)) - metadata = Text(justify="center") - metadata.append(f"v{version}", style=Style(color=C_WARN, bold=True)) - metadata.append(" // ", style=Style(color=C_DIM)) - metadata.append("APK WORKFLOW", style=Style(color=C_SECONDARY)) - metadata.append(" // ", style=Style(color=C_DIM)) - metadata.append("MOBILE SECURITY", style=Style(color=C_PRIMARY)) - footer = Text(CIRCUIT_FOOTER, style=Style(color=C_DIM), justify="center") +def render_banner( + *, + compact: bool = False, + version: str = __version__, + width: int | None = None, +) -> RenderableType: + if compact or (width is not None and width < _COMPACT_WIDTH): + return _compact_banner(version) + inner = None if width is None else max(20, width - _PANEL_CHROME) + hero_lockup = Align( + _colorize_logo(LOGO, version=version, max_width=inner), + align="center", + ) + padding = (0, 1) if width is not None and width < 80 else (1, 2) stack = Group( Panel( - Group(hero_lockup, Text(""), identity, metadata, Text(""), footer), + hero_lockup, border_style=Style(color=C_PRIMARY), - padding=(1, 2), + padding=padding, title="[bold bright_green][ DROIDREAVER ][/]", title_align="center", ), @@ -134,7 +195,7 @@ def print_banner(console: Console | None = None, *, compact: bool = False) -> No if not banner_enabled(): return con = console or Console() - con.print(render_banner(compact=compact)) + con.print(render_banner(compact=compact, width=con.size.width)) con.print() diff --git a/droidreaver/banner_art.txt b/droidreaver/banner_art.txt new file mode 100644 index 0000000..024ed2a --- /dev/null +++ b/droidreaver/banner_art.txt @@ -0,0 +1,52 @@ +::::::::::::::.:::............::::::.::::::::::::::: +:::::::::::::::---*##*###%#%%%#-..:-:.:.:::::::::::: +:::::..::::..-%@@@@@@@@@@@@@@@@@@#+:..+:.::::::::::: +:::::..::.-#@@@@@@@@@@@@@@@@@@@%@@@%*=:..::::::::::: +:::::..--*@@@@@@@@@@@@@@@@@@@@@%%%%@@@*....::::::::: +:::::..=*@@@@@@@@@@@@@@@@@@@@@@@@@%@@%@+ ...:::::::: +::::...+#@@@@@@@@@@@@@@@@@@@@@@@@@%%%##+ ...::::::: +:::...-##@@@@@@@@@@@@@@@@@@@@@@@@@@@%##+. ...:::::: +:::..:%#%@@@@@@@@@@@@@@@@@@@@@@@@@@@%#*= ..:::::: +::...+%#%@@@@@@@@@@@@@@@@@@@@@@@@@@@%#+- .:::::: +::..:=%%@@@@@@@@@@@@@@@@@@@@@@@@@@@%##+. .:::::: +:... -*%@@@#- -%*@@@@@%%%#= :+#= .::::: +::.. :#+. .: --: : .::::: +::. +***: ::::: +:: .*++%- .:::: +:. *%-=%+ .:::: +: *+:= **=. : .:::: +: .-+- .+=.:=%%: -*+==: --. :*- .::: +: .##%%%+#%@@%%*= :*###*#%@@@@@@= .::: +: .-#**%%@@%%%%%#= :+**+-.-+##%%+ .::: +: -##@@@@@%@@@@@+: =+=+==+*#+++: .::: +: =#%@@@@@@@@@@: .##***-.:-:= .::: +:. =#%@@@@@@@@ =#%%%+*=. ::: +:. .#@@@@@@@@@-:*@@##%#-+ .::: +:: ..*%@@@@@+-*@@@+=*=:: . .::: +::. +.-*+*%%+ *... . .::: +::. : =%.@:-@+ @--.= : ::: +::. @%.*. = *.:.* .:: +::: :: +::. : +@@@@@@@ @@@@@@@ @@@@@@ @@@ @@@@@@@ +@@@@@@@@ @@@@@@@@ @@@@@@@@ @@@ @@@@@@@@ +@@! @@@ @@! @@@ @@! @@@ @@! @@! @@@ +!@! @!@ !@! @!@ !@! @!@ !@! !@! @!@ +@!@ !@! @!@!!@! @!@ !@! !!@ @!@ !@! +!@! !!! !!@!@! !@! !!! !!! !@! !!! +!!: !!! !!: :!! !!: !!! !!: !!: !!! +:!: !:! :!: !:! :!: !:! :!: :!: !:! + :::: :: :: ::: ::::: :: :: :::: :: +:: : : : : : : : : : :: : : + + +@@@@@@@ @@@@@@@@ @@@@@@ @@@ @@@ @@@@@@@@ @@@@@@@ +@@@@@@@@ @@@@@@@@ @@@@@@@@ @@@ @@@ @@@@@@@@ @@@@@@@@ +@@! @@@ @@! @@! @@@ @@! @@@ @@! @@! @@@ +!@! @!@ !@! !@! @!@ !@! @!@ !@! !@! @!@ +@!@!!@! @!!!:! @!@!@!@! @!@ !@! @!!!:! @!@!!@! +!!@!@! !!!!!: !!!@!!!! !@! !!! !!!!!: !!@!@! +!!: :!! !!: !!: !!! :!: !!: !!: !!: :!! +:!: !:! :!: :!: !:! ::!!:! :!: :!: !:! +:: ::: :: :::: :: ::: :::: :: :::: :: ::: + : : : : :: :: : : : : : :: :: : : : \ No newline at end of file diff --git a/droidreaver/bypass.py b/droidreaver/bypass.py index 0e6cd66..4ddc6a6 100644 --- a/droidreaver/bypass.py +++ b/droidreaver/bypass.py @@ -11,7 +11,16 @@ from rich.console import Console -from droidreaver.device import force_stop_app, frida_server_status, launch_app, print_frida_attach_troubleshooting +from droidreaver.device import ( + force_stop_app, + frida_server_status, + launch_app, + list_installed_packages, + pidof_package, + print_frida_attach_troubleshooting, + process_name_pids, + relax_selinux, +) from droidreaver.medusa_modules import MEDUSA_ROOT_SPEC, medusa_install_dir, resolve_bypass_script from droidreaver.registry import get_tool from droidreaver.runtime import RunError, build_run_env, resolve_executable @@ -62,7 +71,7 @@ class BypassRecipe: "ssl": BypassRecipe( id="ssl", title="SSL Pinning Bypass", - summary="Disable TLS certificate pinning so HTTPS traffic can be intercepted.", + summary="Java + OkHttp/TrustKit/NSC/WebView + native libssl verify hooks.", objection_commands=("android sslpinning disable",), frida_scripts=("ssl_pinning_bypass.js",), ), @@ -90,27 +99,9 @@ class BypassError(Exception): """Bypass preset could not run.""" -def list_android_apps(config: dict, *, serial: str | None = None) -> list[AndroidApp]: - """Parse `frida-ps -Uai` into structured app rows.""" - if not _tool_ready(config, "frida-tools", "frida-ps"): - return [] - - env = build_run_env(config) - argv = [resolve_executable(config, get_tool("frida-tools"), "frida-ps")[0], "-Uai"] - if serial: - argv = [ - resolve_executable(config, get_tool("frida-tools"), "frida-ps")[0], - "-D", - serial, - "-ai", - ] - - result = run_cmd(argv, env=env) - if result.returncode != 0: - return [] - +def _parse_frida_ps_apps(stdout: str) -> list[AndroidApp]: apps: list[AndroidApp] = [] - for line in (result.stdout or "").splitlines(): + for line in stdout.splitlines(): if line.startswith("PID") or line.startswith("---") or not line.strip(): continue match = _FRIDA_PS_LINE.match(line) @@ -128,6 +119,47 @@ def list_android_apps(config: dict, *, serial: str | None = None) -> list[Androi return apps +def _list_apps_adb(config: dict, serial: str | None, query: str | None) -> list[AndroidApp]: + packages = list_installed_packages(config, serial, query) + if not packages: + return [] + pids = process_name_pids(config, serial) + apps: list[AndroidApp] = [] + for pkg in packages: + pid = pids.get(pkg) + apps.append(AndroidApp(pid=pid, name=pkg.rsplit(".", 1)[-1], identifier=pkg)) + return apps + + +def _list_apps_frida(config: dict, serial: str | None) -> list[AndroidApp]: + if not _tool_ready(config, "frida-tools", "frida-ps"): + return [] + env = build_run_env(config) + exe = resolve_executable(config, get_tool("frida-tools"), "frida-ps")[0] + argv = [exe, "-D", serial, "-ai"] if serial else [exe, "-Uai"] + result = run_cmd(argv, env=env) + if result.returncode != 0: + return [] + return _parse_frida_ps_apps(result.stdout or "") + + +def list_android_apps( + config: dict, + *, + serial: str | None = None, + query: str | None = None, +) -> list[AndroidApp]: + """List apps via adb (fast). Fall back to frida-ps only if pm list is empty.""" + apps = _list_apps_adb(config, serial, query) + if apps: + return apps + apps = _list_apps_frida(config, serial) + if query: + q = query.lower() + apps = [a for a in apps if q in a.identifier.lower() or q in a.name.lower()] + return apps + + def _format_running_apps(apps: list[AndroidApp], limit: int = 8) -> str: running = [a for a in apps if a.running] if not running: @@ -158,21 +190,28 @@ def resolve_android_target( spawn=False, ) - apps = list_android_apps(config, serial=serial) q = query.strip() q_lc = q.lower() + if "." in q: + pid = pidof_package(config, q, serial) + display = q.rsplit(".", 1)[-1] + if spawn: + return ResolvedTarget(q, q, pid, display, spawn=True) + return ResolvedTarget(q, q, pid, display, spawn=False) + + apps = list_android_apps(config, serial=serial, query=q) by_identifier = [a for a in apps if a.identifier.lower() == q_lc] if len(by_identifier) == 1: app = by_identifier[0] - if spawn or not app.running: + if spawn: return ResolvedTarget(q, app.identifier, None, app.name, spawn=True) return ResolvedTarget(q, app.identifier, app.pid, app.name, spawn=False) by_name = [a for a in apps if a.name.lower() == q_lc] if len(by_name) == 1: app = by_name[0] - if spawn or not app.running: + if spawn: return ResolvedTarget(q, app.identifier, None, app.name, spawn=True) return ResolvedTarget(q, app.identifier, app.pid, app.name, spawn=False) @@ -183,7 +222,7 @@ def resolve_android_target( ] if len(partial) == 1: app = partial[0] - if spawn or not app.running: + if spawn: return ResolvedTarget(q, app.identifier, None, app.name, spawn=True) return ResolvedTarget(q, app.identifier, app.pid, app.name, spawn=False) @@ -277,25 +316,16 @@ def preflight(config: dict, *, network: bool, serial: str | None = None) -> None ) elif status.running and not status.device_rooted: console.print( - "[yellow]Warning:[/] device does not appear rooted — Frida attach to " - "third-party apps usually fails.\n" + "[yellow]Warning:[/] no root path yet (need Magisk [cyan]su[/] or emulator " + "[cyan]adb root[/]) — Frida attach to third-party apps usually fails.\n" + " [dim]Fix:[/] [cyan]droidreaver device ready[/]\n" ) - env = build_run_env(config) - frida_ps = None - if _tool_ready(config, "frida-tools", "frida-ps"): - try: - frida_ps = resolve_executable(config, get_tool("frida-tools"), "frida-ps")[0] - except RunError: - pass - if frida_ps: - transport = ["-D", serial] if serial else ["-U"] - result = run_cmd([frida_ps, *transport], env=env) - if result.returncode != 0: - console.print( - "[yellow]Warning:[/] frida-ps failed — is frida-server running?\n" - " [dim]Try:[/] [cyan]droidreaver push-server[/]" - ) + if not status.running: + console.print( + "[yellow]Warning:[/] frida-server is not running.\n" + " [dim]Fix:[/] [cyan]droidreaver device ready[/]\n" + ) def build_objection_argv( @@ -348,10 +378,9 @@ def build_frida_argv( if foremost: argv.append("-F") elif target.spawn: - # Frida 17+: auto-resumes after spawn; --no-pause was removed (--pause opts in). argv.extend(["-f", target.identifier]) elif target.identifier and target.identifier != "frontmost": - # Android: package identifier beats PID (PIDs churn; wrong process is common). + # Package attach lets Frida pick the 32/64-bit world that matches the app. argv.extend(["-N", target.identifier]) elif target.pid is not None: argv.extend(["-p", str(target.pid)]) @@ -393,27 +422,15 @@ def _wait_for_running_app( package: str, *, serial: str | None, - timeout_s: float = 18.0, + timeout_s: float = 30.0, ) -> ResolvedTarget | None: - """Poll frida-ps until the package is running, then return an attach target.""" + """Poll pidof until the package is running, then return an attach target.""" deadline = time.monotonic() + timeout_s while time.monotonic() < deadline: - try: - target = resolve_android_target( - config, package, spawn=False, serial=serial, foremost=False - ) - except BypassError: - target = None - if target and target.pid is not None: - # Attach by package (-N), not PID — more reliable after adb launch. - return ResolvedTarget( - target.query, - target.identifier, - None, - target.display_name, - spawn=False, - ) - time.sleep(1.0) + pid = pidof_package(config, package, serial) + if pid is not None: + return ResolvedTarget(package, package, pid, package.rsplit(".", 1)[-1], spawn=False) + time.sleep(0.5) return None @@ -422,20 +439,49 @@ def _print_attach_failure_help(package: str, serial: str | None, config: dict) - def _print_spawn_failure_help(package: str, serial: str | None) -> None: - serial_flag = f" -S {serial}" if serial else "" + serial_flag = f" --device {serial}" if serial else "" console.print( - "\n[yellow]Spawn failed[/] — this app may block Frida cold start.\n\n" - "[bold]Try attach mode[/] (open the app on the device first, no --spawn):\n" - f" [cyan]droidreaver hook -n {package}{serial_flag}[/]\n\n" - "[bold]Or retry spawn[/] after a clean stop:\n" - f" [cyan]adb shell am force-stop {package}[/]\n" - f" [cyan]droidreaver hook -n {package} --spawn{serial_flag}[/]\n\n" - "[bold]Or Objection[/] (sometimes works when Frida spawn does not):\n" - f" [cyan]droidreaver hook -n {package} --objection --spawn{serial_flag}[/]\n\n" - "[dim]Physical devices and hardened apps often need attach, not spawn.[/]" + "\n[yellow]Frida spawn (-f) failed.[/] Open the app on the device, then attach:\n" + f" [cyan]droidreaver hook -n {package}{serial_flag}[/]\n" + "[dim]Only use [cyan]--spawn[/] when you need a Frida cold start.[/]" ) +def _ensure_running_for_attach( + config: dict, + target: ResolvedTarget, + *, + serial: str | None, +) -> ResolvedTarget: + """If the app is stopped, start it with adb and wait for a PID. Never Frida -f.""" + if target.pid is not None: + console.print(f"[dim]App is running (pid {target.pid}).[/]") + return target + console.print( + "[yellow]App is not running.[/] Launching with adb, then attaching " + "(Frida [cyan]-f[/] spawn is not used unless you pass [cyan]--spawn[/])." + ) + try: + launch_app(config, target.identifier, serial=serial) + except Exception as e: # noqa: BLE001 + raise BypassError(f"Could not launch {target.identifier}: {e}") from e + waited = _wait_for_running_app(config, target.identifier, serial=serial) + if waited is None: + raise BypassError( + f"{target.identifier} did not start.\n" + "Tap the app on the emulator so it stays in the foreground, then run [cyan]hook[/] again." + ) + console.print("[dim]Waiting 2s for the process to settle before Frida attach…[/]") + time.sleep(2.0) + pid = pidof_package(config, target.identifier, serial) + if pid is not None: + waited = ResolvedTarget( + waited.query, waited.identifier, pid, waited.display_name, spawn=False + ) + console.print(f"[green]✓[/] App running as pid [bold]{waited.pid}[/]") + return waited + + def _run_frida_session(argv: list[str], env: dict) -> tuple[int, float]: """Run frida interactively; return exit code and elapsed seconds.""" start = time.monotonic() @@ -452,9 +498,7 @@ def _attempt_attach_after_spawn_failure( network: bool, ) -> int | None: """Launch the app via adb and attach with the same scripts. Returns exit code or None.""" - console.print( - "[yellow]Spawn failed[/] — switching to attach mode (package name, not PID)…" - ) + console.print("[yellow]Spawn failed[/] — launching the app with adb and attaching by PID…") apps = list_android_apps(config, serial=serial) running = next((a for a in apps if a.identifier == package and a.running), None) @@ -527,8 +571,10 @@ def run_bypass( if chosen == "frida" and not _tool_ready(config, "frida", "frida"): raise BypassError("Frida not installed. Run: droidreaver install frida") + console.print("[dim]1/4 Checking frida-server / root path…[/]") preflight(config, network=network, serial=serial) + console.print("[dim]2/4 Resolving target PID…[/]") if foremost: target = resolve_android_target( config, package, spawn=False, serial=serial, foremost=True @@ -537,13 +583,11 @@ def run_bypass( target = resolve_android_target( config, package, spawn=spawn, serial=serial, foremost=False ) - if target.spawn and not spawn: - console.print( - "[yellow]App not running — switching to spawn mode.[/] " - "[dim](Use [cyan]--spawn[/] explicitly next time.)[/]" - ) + if not spawn: + target = _ensure_running_for_attach(config, target, serial=serial) print_recipe_banner(recipe, target, chosen) + console.print("[dim]3/4 Building Frida command…[/]") env = build_run_env(config) if chosen == "objection": @@ -579,6 +623,16 @@ def run_bypass( except Exception as e: # noqa: BLE001 console.print(f"[yellow]Warning:[/] could not force-stop app: {e}") + console.print("[dim]4/4 Attaching Frida (session stays in this window)…[/]") + relax_selinux(config, serial) + live_pid = pidof_package(config, target.identifier, serial) + if live_pid and live_pid != target.pid and not target.spawn: + target = ResolvedTarget( + target.query, target.identifier, live_pid, target.display_name, False + ) + argv = build_frida_argv( + config, recipe, target, serial=serial, network=network, foremost=foremost + ) console.print(f"[dim]Running:[/] {' '.join(argv)}\n") try: @@ -602,6 +656,33 @@ def run_bypass( return attach_code if attach_code is not None else code return code code, elapsed = _run_frida_session(argv, env) + if code != 0 and elapsed < 15 and recipe.id == "all": + console.print( + "[yellow]SSL+root scripts dropped the session — retrying SSL-only attach…[/]" + ) + time.sleep(1.5) + pid = pidof_package(config, target.identifier, serial) + if pid is None: + console.print("[dim]App died; relaunching…[/]") + launch_app(config, target.identifier, serial=serial) + recovered = _wait_for_running_app(config, target.identifier, serial=serial) + pid = recovered.pid if recovered else None + if pid is not None: + ssl_target = ResolvedTarget( + target.query, target.identifier, pid, target.display_name, False + ) + ssl_argv = build_frida_argv( + config, + RECIPES["ssl"], + ssl_target, + serial=serial, + network=network, + foremost=False, + ) + console.print(f"[dim]Running:[/] {' '.join(ssl_argv)}\n") + code, elapsed = _run_frida_session(ssl_argv, env) + if code == 0: + return code if code != 0 and elapsed < 15: print_frida_attach_troubleshooting( config, serial=serial, package=target.identifier diff --git a/droidreaver/cli.py b/droidreaver/cli.py index 57e9fa3..37d4edc 100644 --- a/droidreaver/cli.py +++ b/droidreaver/cli.py @@ -175,7 +175,9 @@ def main(ctx: click.Context, no_banner: bool) -> None: if ctx.invoked_subcommand is None: print_banner(console, compact=False) - click.echo(ctx.get_help()) + console.print( + "[dim]Type[/] [cyan]droidreaver --help[/] [dim]for more commands.[/]" + ) @main.command() @@ -398,26 +400,25 @@ def update_cmd(tool: str | None, update_all: bool, force: bool) -> None: if t.pin_with: members = [m for m in get_pin_group(tool) if get_tool(m).install_method != "manual"] - with work_spinner(f"[cyan]Resolving target frida runtime…[/]", console=console): - target = resolve_frida_target(cfg, None) + with work_spinner(f"[cyan]Resolving latest frida runtime…[/]", console=console): + target = resolve_frida_target(cfg, "latest") console.print( Panel( - f"[bold]Target frida runtime:[/] {target}\n\n" - "• [bold]frida[/] → installed at exactly this version\n" - "• [bold]frida-tools, objection, r2frida[/] → upgraded to pip-compatible releases " + f"[bold]Target frida runtime:[/] {target} [dim](latest on PyPI)[/]\n\n" + "• [bold]frida[/] → isolated venv at exactly this version\n" + "• [bold]frida-tools, objection[/] → installed in that venv " "(they use [italic]different[/] version numbers)\n" - "• [bold]medusa[/] → optional, best-effort\n\n" + "• Pin and active runtime are updated to this version\n\n" f"[dim]Members:[/] {', '.join(members)}\n\n" - "Prefer nvm-style venvs? Use [cyan]droidreaver use {target}[/] instead.".format( - target=target - ), - title="Sync frida pin group", + "Stay on a pinned version: [cyan]droidreaver sync frida[/] · " + "Pick one: [cyan]droidreaver use {target}[/]".format(target=target), + title="Update frida pin group", border_style="#3a6652", ) ) try: - with work_spinner(f"[cyan]Syncing frida stack…[/]", console=console): - results = sync_pin_group(tool, cfg, force=force) + with work_spinner(f"[cyan]Updating frida stack to {target}…[/]", console=console): + results = update_pin_group(tool, cfg, force=force) for n, old, new in results: if old == new: console.print(f"[dim]–[/] {n}: already OK ({old or '—'})") @@ -549,6 +550,7 @@ def shell_cmd() -> None: console.print(shell_path_hint(cfg)) sys.exit(1) + print_banner(console, compact=False) shell_argv = pick_user_shell() shell_name = os.path.basename(shell_argv[0]) console.print( @@ -1471,26 +1473,33 @@ def device_list() -> None: @device_group.command("root-server") @click.option("--device", "-d", "serial", default=None, help="ADB device serial") def device_root_server(serial: str | None) -> None: - """Restart frida-server as root (required for attach on rooted phones).""" + """Restart frida-server as root (Magisk su or emulator adb root).""" from droidreaver.device import ( DEFAULT_FRIDA_SERVER_PATH, device_has_su, + device_is_rooted, frida_server_status, print_frida_attach_troubleshooting, restart_frida_server, + try_adb_root, ) cfg = _load_cfg() - if not device_has_su(cfg, serial): + if not device_is_rooted(cfg, serial) and not try_adb_root(cfg, serial): console.print( - "[red]adb shell su failed[/] — device does not appear rooted, or Magisk denied su.\n" - "Grant root to [bold]Shell[/] or [bold]ADB[/] in Magisk Superuser settings." + "[red]No root path[/] — `adb shell su` failed and `adb root` did not make " + "the shell uid 0.\n" + "Emulator: use a Google APIs/AOSP image (not Play Store) and [cyan]adb root[/].\n" + "Phone: Magisk — grant root to [bold]Shell[/] or [bold]ADB[/]." ) sys.exit(1) - console.print( - "[dim]Watch your phone — approve the Magisk/superuser prompt if it appears.[/]\n" - ) + if device_has_su(cfg, serial): + console.print( + "[dim]Watch your phone — approve the Magisk/superuser prompt if it appears.[/]\n" + ) + else: + console.print("[dim]Using emulator/userdebug adbd root (no Magisk su).[/]\n") status = restart_frida_server(cfg, serial=serial, verbose=True) if status.running and status.runs_as_root: console.print("[bold green]◈ frida-server is running as root.[/] You can hook now.") diff --git a/droidreaver/console.py b/droidreaver/console.py index 1779a08..b20b38e 100644 --- a/droidreaver/console.py +++ b/droidreaver/console.py @@ -11,7 +11,9 @@ from __future__ import annotations import cmd +import os import shlex +import shutil import subprocess import sys from pathlib import Path @@ -20,6 +22,7 @@ from rich.panel import Panel from rich.table import Table +from droidreaver.banner import print_banner from droidreaver.bypass import BypassError, list_android_apps, run_bypass from droidreaver.device import ( DeviceError, @@ -28,20 +31,57 @@ list_devices, push_frida_server, ) +from droidreaver.progress import work_spinner from droidreaver.runtime import RunError, build_run_env, run_invocation from droidreaver.versions import get_active_frida_version console = Console() -BANNER = r""" -[bold #00ff41]⚔ DroidReaver Console[/] [dim]— interactive Android pentest shell[/] -""" - _GREEN = "\033[92m" _DIM = "\033[2m" _RESET = "\033[0m" +def _resolve_exe(executable: str) -> str: + path = Path(executable) + if path.is_file(): + return str(path.resolve()) + found = shutil.which(executable) + return str(Path(found).resolve()) if found else executable + + +def spawn_in_new_window(argv: list[str], *, env: dict | None, title: str) -> bool: + """Open argv in a separate terminal so the REPL stays usable. Returns False if not possible.""" + if not argv: + return False + argv = [_resolve_exe(argv[0]), *argv[1:]] + safe_title = title.replace('"', "'") + if sys.platform == "win32": + quoted = subprocess.list2cmdline(argv) + # `start` is a cmd builtin; first quoted token is the window title. + # `/k` keeps the window open if adb exits so errors are visible. + cmdline = f'start "{safe_title}" cmd /k {quoted}' + subprocess.Popen(cmdline, env=env, shell=True, cwd=os.getcwd()) + return True + if sys.platform == "darwin": + script = " ".join(shlex.quote(part) for part in argv) + escaped = script.replace("\\", "\\\\").replace('"', '\\"') + subprocess.Popen( + ["osascript", "-e", f'tell application "Terminal" to do script "{escaped}"'] + ) + return True + for launcher in ( + ["x-terminal-emulator", "-e"], + ["gnome-terminal", "--"], + ["konsole", "-e"], + ["xterm", "-e"], + ): + if shutil.which(launcher[0]): + subprocess.Popen([*launcher, *argv], env=env) + return True + return False + + class DroidReaverConsole(cmd.Cmd): """Interactive pentest REPL with live device + target state.""" @@ -88,7 +128,7 @@ def cmdloop(self, intro=None): # noqa: ANN001 - match base signature console.print("\n[dim]^C — type[/] [cyan]exit[/] [dim]to quit the console.[/]") def _print_intro(self) -> None: - console.print(Panel.fit(BANNER.strip(), border_style="#00ff41")) + print_banner(console, compact=False) if self.serial: console.print(f"[green]✓[/] Device: [bold]{self.serial}[/]") else: @@ -192,16 +232,20 @@ def do_use(self, arg: str) -> None: def do_apps(self, arg: str) -> None: """apps [filter] — list installed apps (running ones are marked).""" filt = arg.strip().lower() - apps = list_android_apps(self.config, serial=self.serial) - if not apps: - console.print( - "[yellow]No apps listed.[/] Is frida-server running? Try [cyan]ready[/] first." + with work_spinner( + "[cyan]Listing packages via adb[/] [dim](pm list + ps, not frida-ps)…[/]", + console=console, + ): + apps = list_android_apps( + self.config, serial=self.serial, query=filt or None ) - return - if filt: - apps = [a for a in apps if filt in a.identifier.lower() or filt in a.name.lower()] if not apps: - console.print(f"[yellow]No apps match[/] '{filt}'.") + if filt: + console.print(f"[yellow]No apps match[/] '{filt}'.") + else: + console.print( + "[yellow]No apps listed.[/] Check [cyan]adb[/] / USB debugging." + ) return table = Table(header_style="bold #00ff41", border_style="#3a6652") table.add_column("State") @@ -222,7 +266,15 @@ def do_target(self, arg: str) -> None: else: console.print("[yellow]Usage:[/] target ") return - apps = list_android_apps(self.config, serial=self.serial) + if "." in query: + self.package = query + console.print(f"[green]✓[/] Target: [bold]{self.package}[/] [dim](exact package, no app scan)[/]") + return + with work_spinner( + f"[cyan]Matching[/] [bold]{query}[/] [dim]via adb pm list…[/]", + console=console, + ): + apps = list_android_apps(self.config, serial=self.serial, query=query) exact = [a for a in apps if a.identifier.lower() == query.lower()] if exact: self.package = exact[0].identifier @@ -294,9 +346,22 @@ def do_ps(self, arg: str) -> None: # ------------------------------------------------------------------ # # attacks # ------------------------------------------------------------------ # + def _ensure_frida_server(self) -> None: + if not self.serial: + return + try: + running = check_frida_server_running(self.config, self.serial) + except Exception: # noqa: BLE001 + running = False + if running: + return + console.print("[dim]frida-server is not running — starting it (`ready`)…[/]") + self.do_ready("") + def _run_bypass(self, recipe: str, spawn: bool) -> None: if not self._require_target(): return + self._ensure_frida_server() try: run_bypass( self.config, @@ -378,13 +443,21 @@ def do_adbshell(self, arg: str) -> None: subprocess.run(self._adb_base() + ["shell"], env=build_run_env(self.config)) def do_logcat(self, arg: str) -> None: - """logcat [filter] — stream device logs (Ctrl+C to stop).""" + """logcat [filter] — stream device logs in a new window (console stays usable).""" if not self._require_device(): return args = self._split(arg) - console.print("[dim]Streaming logcat — press Ctrl+C to stop.[/]") + argv = self._adb_base() + ["logcat", *args] + title = f"droidreaver logcat {self.serial}" + if spawn_in_new_window(argv, env=build_run_env(self.config), title=title): + console.print( + f"[green]✓[/] logcat opened in a new window ([bold]{title}[/]). " + "Close that window to stop. This console stays free." + ) + return + console.print("[dim]No extra terminal available — streaming here. Ctrl+C to stop.[/]") try: - subprocess.run(self._adb_base() + ["logcat", *args], env=build_run_env(self.config)) + subprocess.run(argv, env=build_run_env(self.config)) except KeyboardInterrupt: console.print("\n[dim]logcat stopped.[/]") diff --git a/droidreaver/device.py b/droidreaver/device.py index 2984ec3..88a05fb 100644 --- a/droidreaver/device.py +++ b/droidreaver/device.py @@ -4,7 +4,6 @@ import os import stat -import tempfile import time from dataclasses import dataclass from pathlib import Path @@ -20,6 +19,9 @@ console = Console() DEFAULT_FRIDA_SERVER_PATH = "/data/local/tmp/frida-server" +FRIDA_TCP_PORT = 27042 +FRIDA_LISTEN = f"0.0.0.0:{FRIDA_TCP_PORT}" +FRIDA_HOST = f"127.0.0.1:{FRIDA_TCP_PORT}" # Map adb ABI names to frida-server arch suffixes ABI_MAP = { "arm64-v8a": "arm64", @@ -28,6 +30,8 @@ "x86": "x86", "x86_64": "x86_64", } +# 64-bit Android still runs 32-bit apps; those need a matching frida-server. +_COMPANION_ARCH = {"x86_64": "x86", "arm64": "arm"} class DeviceError(Exception): @@ -85,9 +89,23 @@ def force_stop_app(config: dict, package: str, *, serial: str | None = None) -> def launch_app(config: dict, package: str, *, serial: str | None = None) -> None: - """Launch an app via adb (MAIN/LAUNCHER intent).""" + """Launch an app via adb (MAIN/LAUNCHER), preferring `am start` over monkey.""" + base = _adb_cmd(config, serial) + resolved = run_cmd( + base + ["shell", "cmd", "package", "resolve-activity", "--brief", package], + timeout=PROBE_CMD_TIMEOUT, + ) + component = "" + for line in reversed((resolved.stdout or "").splitlines()): + line = line.strip() + if "/" in line and not line.startswith("priority="): + component = line + break + if component: + run_cmd(base + ["shell", "am", "start", "-W", "-n", component], timeout=30) + return run_cmd( - _adb_cmd(config, serial) + base + [ "shell", "monkey", @@ -96,10 +114,68 @@ def launch_app(config: dict, package: str, *, serial: str | None = None) -> None "-c", "android.intent.category.LAUNCHER", "1", - ] + ], + timeout=PROBE_CMD_TIMEOUT, ) +def list_installed_packages( + config: dict, + serial: str | None = None, + query: str | None = None, +) -> list[str]: + """Fast `pm list packages` (optional substring filter).""" + cmd = _adb_cmd(config, serial) + ["shell", "pm", "list", "packages"] + if query: + cmd.append(query) + result = run_cmd(cmd, timeout=PROBE_CMD_TIMEOUT) + if result.returncode != 0: + return [] + packages: list[str] = [] + for line in (result.stdout or "").splitlines(): + line = line.strip() + if line.startswith("package:"): + packages.append(line.split(":", 1)[1].strip()) + return packages + + +def pidof_package(config: dict, package: str, serial: str | None = None) -> int | None: + """Return the app PID. Avoid `pidof -s` — some images reject that flag.""" + result = run_cmd( + _adb_cmd(config, serial) + ["shell", "pidof", package], + timeout=PROBE_CMD_TIMEOUT, + ) + token = (result.stdout or "").strip().split() + if result.returncode == 0 and token: + try: + return int(token[0]) + except ValueError: + pass + return process_name_pids(config, serial).get(package) + + +def process_name_pids(config: dict, serial: str | None = None) -> dict[str, int]: + """Map process NAME -> PID from a single `ps -A`.""" + result = run_cmd( + _adb_cmd(config, serial) + ["shell", "ps", "-A"], + timeout=PROBE_CMD_TIMEOUT, + ) + if result.returncode != 0: + return {} + pids: dict[str, int] = {} + for line in (result.stdout or "").splitlines()[1:]: + parts = line.split() + if len(parts) < 2: + continue + name = parts[-1] + try: + pid = int(parts[1]) + except ValueError: + continue + pids[name] = pid + return pids + + def list_devices(config: dict) -> list[str]: adb = adb_path(config) result = run_cmd([adb, "devices"], timeout=PROBE_CMD_TIMEOUT) @@ -135,6 +211,26 @@ def get_device_arch(config: dict, serial: str | None = None) -> str: return arch +def get_cpu_abilist(config: dict, serial: str | None = None) -> list[str]: + result = run_cmd( + _adb_cmd(config, serial) + ["shell", "getprop", "ro.product.cpu.abilist"], + timeout=PROBE_CMD_TIMEOUT, + ) + return [part.strip() for part in (result.stdout or "").split(",") if part.strip()] + + +def get_needed_frida_arches(config: dict, serial: str | None = None) -> list[str]: + """Primary ABI plus a 32-bit companion only when the image actually has that ABI.""" + primary = get_device_arch(config, serial) + abilist = get_cpu_abilist(config, serial) + arches = [primary] + companion = _COMPANION_ARCH.get(primary) + native_32 = {"x86", "armeabi-v7a", "armeabi"} + if companion and any(abi in native_32 for abi in abilist): + arches.append(companion) + return arches + + def get_local_frida_version(config: dict | None = None) -> str: env = None if config is not None: @@ -153,8 +249,20 @@ def get_local_frida_version(config: dict | None = None) -> str: return ver +def cached_frida_server_path(version: str, arch: str) -> Path: + return ( + Path.home() + / ".droidreaver" + / "cache" + / "frida-server" + / f"frida-server-{version}-android-{arch}" + ) + + def download_frida_server(version: str, arch: str, dest: Path) -> Path: - """Download frida-server binary matching version and arch.""" + """Download frida-server binary matching version and arch. Reuses dest if present.""" + if dest.is_file() and dest.stat().st_size > 1024: + return dest filename = f"frida-server-{version}-android-{arch}" urls = [ f"https://github.com/frida/frida/releases/download/{version}/{filename}.xz", @@ -223,58 +331,71 @@ def push_frida_server( for dev in targets: with work_spinner("[cyan]Detecting device architecture…[/]", console=console) as update: - arch = get_device_arch(config, dev) - update(f"[cyan]Device[/] [bold]{dev}[/] — [green]{arch}[/], frida [yellow]{frida_ver}[/]") - - with tempfile.TemporaryDirectory() as tmp: - local_bin = Path(tmp) / "frida-server" - with work_spinner( - f"[cyan]Downloading frida-server {frida_ver} ({arch})…[/]", - console=console, - ): - download_frida_server(frida_ver, arch, local_bin) + arches = get_needed_frida_arches(config, dev) + update( + f"[cyan]Device[/] [bold]{dev}[/] — [green]{' + '.join(arches)}[/], " + f"frida [yellow]{frida_ver}[/]" + ) + extra = ( + " [dim](32-bit apps need the 32-bit server)[/]" + if len(arches) > 1 + else "" + ) + console.print( + f"[dim]Installing frida-server for[/] [bold]{' + '.join(arches)}[/]{extra}" + ) + cmd_base = [adb] + if dev: + cmd_base.extend(["-s", dev]) + + remote_paths: list[str] = [] + for arch in arches: + dest = ( + remote_path + if arch == arches[0] + else f"{remote_path}-{arch}" + ) + local_bin = cached_frida_server_path(frida_ver, arch) + if local_bin.is_file() and local_bin.stat().st_size > 1024: + console.print(f"[dim]Using cached frida-server {frida_ver} ({arch})[/]") + else: + with work_spinner( + f"[cyan]Downloading frida-server {frida_ver} ({arch})…[/]", + console=console, + ): + download_frida_server(frida_ver, arch, local_bin) if os.name != "nt": local_bin.chmod(local_bin.stat().st_mode | stat.S_IEXEC) + if _remote_file_size(cmd_base, dest) != local_bin.stat().st_size: + with work_spinner(f"[cyan]Pushing {arch} to {dest}…[/]", console=console): + result = run_cmd(cmd_base + ["push", str(local_bin), dest]) + if result.returncode != 0: + raise DeviceError(f"adb push failed ({arch}): {result.stderr}") + run_cmd(cmd_base + ["shell", f"chmod 755 {dest}"]) + console.print(f"[green]✓[/] Pushed frida-server {frida_ver} ({arch})") + else: + console.print(f"[dim]Device already has {arch} frida-server — skip push[/]") + remote_paths.append(dest) - cmd_base = [adb] - if dev: - cmd_base.extend(["-s", dev]) - - # Push - push_cmd = cmd_base + ["push", str(local_bin), remote_path] - with work_spinner(f"[cyan]Pushing to {remote_path}…[/]", console=console): - result = run_cmd(push_cmd) - if result.returncode != 0: - raise DeviceError(f"adb push failed: {result.stderr}") - - # chmod - chmod_cmd = cmd_base + ["shell", f"chmod 755 {remote_path}"] - run_cmd(chmod_cmd) - - console.print(f"[green]✓[/] Pushed frida-server {frida_ver} ({arch}) to {remote_path}") - - if start: - with work_spinner("[cyan]Starting frida-server on device…[/]", console=console): - status = restart_frida_server( - config, serial=dev, remote_path=remote_path - ) - if not status.running: - raise DeviceError("frida-server did not start on device") - if status.runs_as_root: - console.print("[green]✓[/] Started frida-server [bold]as root[/]") - elif status.device_rooted: - console.print( - "[yellow]⚠[/] frida-server started but [bold]not as root[/] — " - "attach to apps will likely fail.\n" - f" Run: [cyan]adb -s {dev} shell su -c " - f"'pkill frida-server; {remote_path} -D &'[/]" - ) - else: - console.print( - "[green]✓[/] Started frida-server [dim](device not rooted — " - "attach may only work on debuggable apps)[/]" - ) + if start: + with work_spinner( + "[cyan]Starting frida-server(s) as root…[/]", + console=console, + ): + status = restart_frida_server( + config, serial=dev, remote_paths=remote_paths + ) + if not status.running: + raise DeviceError("frida-server did not start on device") + if status.runs_as_root: + console.print("[green]✓[/] Started frida-server [bold]as root[/]") + elif status.device_rooted: + console.print( + "[yellow]⚠[/] frida-server started but [bold]not as root[/]" + ) + else: + console.print("[green]✓[/] Started frida-server") def get_remote_frida_server_version(config: dict, serial: str | None = None) -> str | None: """Try to detect frida-server version on device via frida-ps.""" @@ -291,6 +412,22 @@ def get_remote_frida_server_version(config: dict, serial: str | None = None) -> return parse_version(result.stdout) if result.returncode == 0 else None +def _remote_file_size(cmd_base: list[str], remote_path: str) -> int | None: + result = run_cmd( + cmd_base + ["shell", f"wc -c < {remote_path} 2>/dev/null || echo"], + timeout=PROBE_CMD_TIMEOUT, + ) + if result.returncode != 0: + return None + token = (result.stdout or "").strip().split() + if not token: + return None + try: + return int(token[0]) + except ValueError: + return None + + def check_frida_server_running(config: dict, serial: str | None = None) -> bool: adb = adb_path(config) cmd = [adb] @@ -301,13 +438,41 @@ def check_frida_server_running(config: dict, serial: str | None = None) -> bool: return result.returncode == 0 and bool((result.stdout or "").strip()) +def _stdout_has_uid0(stdout: str | None) -> bool: + return "uid=0" in (stdout or "") + + def device_has_su(config: dict, serial: str | None = None) -> bool: - """True when adb shell su can get uid 0 (device is rooted).""" + """True when Magisk-style `adb shell su` can get uid 0.""" result = run_cmd( _adb_cmd(config, serial) + ["shell", "su", "-c", "id"], timeout=PROBE_CMD_TIMEOUT, ) - return result.returncode == 0 and "uid=0" in (result.stdout or "") + return result.returncode == 0 and _stdout_has_uid0(result.stdout) + + +def adbd_runs_as_root(config: dict, serial: str | None = None) -> bool: + """True when `adb shell` itself is uid 0 (`adb root` on userdebug/emulator).""" + result = run_cmd( + _adb_cmd(config, serial) + ["shell", "id"], + timeout=PROBE_CMD_TIMEOUT, + ) + return result.returncode == 0 and _stdout_has_uid0(result.stdout) + + +def try_adb_root(config: dict, serial: str | None = None) -> bool: + """Best-effort `adb root` for AVDs / userdebug images. Harmless on production.""" + if adbd_runs_as_root(config, serial): + return True + run_cmd(_adb_cmd(config, serial) + ["root"], timeout=PROBE_CMD_TIMEOUT) + time.sleep(0.8) + run_cmd(_adb_cmd(config, serial) + ["wait-for-device"], timeout=PROBE_CMD_TIMEOUT) + return adbd_runs_as_root(config, serial) + + +def device_is_rooted(config: dict, serial: str | None = None) -> bool: + """True if Magisk su works or adbd is already uid 0 (emulator `adb root`).""" + return device_has_su(config, serial) or adbd_runs_as_root(config, serial) def _kill_frida_server(base: list[str]) -> None: @@ -320,18 +485,77 @@ def _kill_frida_server(base: list[str]) -> None: "pkill -9 frida-server 2>/dev/null; killall frida-server 2>/dev/null; true", ] ) - run_cmd(base + ["shell", "pkill -9 frida-server 2>/dev/null || true"]) + run_cmd(base + ["shell", "pkill -9 -f frida-server 2>/dev/null || true"]) + + +def wait_for_frida_tcp(*, timeout: float = 8.0) -> bool: + """True when the host can connect to the forwarded frida-server port.""" + import socket + + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + with socket.create_connection(("127.0.0.1", FRIDA_TCP_PORT), timeout=0.4): + return True + except OSError: + time.sleep(0.25) + return False + + +def ensure_frida_tcp_forward(config: dict, serial: str | None = None) -> str: + """Map host 127.0.0.1:27042 → device :27042. USB Frida on Windows often drops.""" + base = _adb_cmd(config, serial) + run_cmd(base + ["forward", "--remove", f"tcp:{FRIDA_TCP_PORT}"], timeout=PROBE_CMD_TIMEOUT) + result = run_cmd( + base + ["forward", f"tcp:{FRIDA_TCP_PORT}", f"tcp:{FRIDA_TCP_PORT}"], + timeout=PROBE_CMD_TIMEOUT, + ) + if result.returncode != 0: + console.print( + f"[yellow]adb forward tcp:{FRIDA_TCP_PORT} failed:[/] " + f"{(result.stderr or result.stdout or '').strip()}" + ) + if not wait_for_frida_tcp(): + console.print( + f"[yellow]Nothing is listening on {FRIDA_HOST} yet.[/] " + "frida-server may have crashed — check [cyan]ready[/] output." + ) + return FRIDA_HOST + + +def relax_selinux(config: dict, serial: str | None = None) -> None: + run_cmd(_adb_cmd(config, serial) + ["shell", "setenforce", "0"], timeout=PROBE_CMD_TIMEOUT) + + +def _start_frida_daemon(base: list[str], remote_path: str) -> None: + """Start one frida-server over USB (Frida's default Android transport).""" + probe = run_cmd(base + ["shell", "ls", remote_path], timeout=PROBE_CMD_TIMEOUT) + out = f"{probe.stdout or ''}{probe.stderr or ''}" + if (isinstance(probe.returncode, int) and probe.returncode != 0) or "No such file" in out: + console.print(f"[dim]Skip {remote_path} — binary is not on the device.[/]") + return + run_cmd(base + ["shell", "setenforce 0"], timeout=PROBE_CMD_TIMEOUT) + log = "/data/local/tmp/frida-server.log" + run_cmd( + base + + [ + "shell", + f"nohup {remote_path} >>{log} 2>&1 list[list[str]]: """ADB shell argv suffixes to try when starting frida-server as root.""" return [ - ["su", "-c", f"chmod 755 {remote_path}; exec {remote_path} -D &"], - ["su", "-c", f"nohup {remote_path} -D >/dev/null 2>&1 &"], - ["su", "-mm", "-c", f"{remote_path} -D &"], - ["su", "0", "sh", "-c", f"{remote_path} -D &"], - ["su", "-c", f"({remote_path} -D &) &"], - ["su", "-c", f"setsid {remote_path} -D &"], + ["su", "-c", f"chmod 755 {remote_path}; exec {remote_path} &"], + ["su", "-c", f"nohup {remote_path} >/dev/null 2>&1 &"], + ["su", "-mm", "-c", f"{remote_path} &"], + ["su", "0", "sh", "-c", f"{remote_path} &"], + ["su", "-c", f"({remote_path} &) &"], + ["su", "-c", f"setsid {remote_path} &"], ] @@ -376,7 +600,7 @@ def frida_server_status(config: dict, serial: str | None = None) -> FridaServerS return FridaServerStatus( running=running, user=user, - device_rooted=device_has_su(config, serial), + device_rooted=device_is_rooted(config, serial), ) @@ -385,32 +609,39 @@ def restart_frida_server( *, serial: str | None = None, remote_path: str = DEFAULT_FRIDA_SERVER_PATH, + remote_paths: list[str] | None = None, verbose: bool = False, ) -> FridaServerStatus: - """Kill any frida-server and start it (as root when su is available).""" + """Kill existing servers and start each binary as root when possible.""" base = _adb_cmd(config, serial) - rooted = device_has_su(config, serial) + paths = remote_paths or [remote_path] + adbd_root = try_adb_root(config, serial) + has_su = device_has_su(config, serial) _kill_frida_server(base) - if not rooted: - run_cmd(base + ["shell", f"{remote_path} -D &"]) - time.sleep(1.0) - return frida_server_status(config, serial) - - for idx, strat in enumerate(_root_frida_start_strategies(remote_path), start=1): - if verbose: - console.print(f"[dim]Trying root start strategy {idx}/{len(_root_frida_start_strategies(remote_path))}…[/]") - run_cmd(base + ["shell"] + strat) - time.sleep(1.5) - status = frida_server_status(config, serial) - if status.running and status.runs_as_root: - return status - _kill_frida_server(base) - - # Last resort — may only work for debuggable apps. - run_cmd(base + ["shell", f"{remote_path} -D &"]) - time.sleep(1.0) + primary = paths[0] + if has_su: + for idx, strat in enumerate(_root_frida_start_strategies(primary), start=1): + if verbose: + console.print( + f"[dim]Trying Magisk su start strategy " + f"{idx}/{len(_root_frida_start_strategies(primary))}…[/]" + ) + run_cmd(base + ["shell"] + strat) + time.sleep(1.5) + status = frida_server_status(config, serial) + if status.running and status.runs_as_root: + for extra in paths[1:]: + _start_frida_daemon(base, extra) + return status + _kill_frida_server(base) + + if verbose: + console.print("[dim]Starting frida-server via adb shell…[/]") + for path in paths: + _start_frida_daemon(base, path) + time.sleep(0.8) return frida_server_status(config, serial) @@ -420,14 +651,13 @@ def print_frida_attach_troubleshooting( serial: str | None = None, package: str | None = None, ) -> None: - """Explain common causes of 'unable to access process' attach failures.""" + """Explain common causes of Frida attach failures.""" status = frida_server_status(config, serial) serial_flag = f" -s {serial}" if serial else "" pkg = package or "" + has_su = device_has_su(config, serial) - console.print( - "\n[bold red]Frida attach failed[/] — [dim]unable to access process[/]\n" - ) + console.print("\n[bold red]Frida attach failed[/]\n") if not status.running: console.print("[yellow]frida-server is not running.[/]") @@ -436,37 +666,56 @@ def print_frida_attach_troubleshooting( if status.device_rooted and not status.runs_as_root: console.print( - "[yellow]Device is rooted but frida-server is NOT running as root.[/]\n" + "[yellow]Privilege is available but frida-server is NOT running as root.[/]\n" "This is the usual cause of [bold]unable to access process[/].\n" ) - console.print( - "[bold]On the phone:[/] approve the [bold]Magisk/superuser[/] prompt when starting " - "frida-server (set Magisk to prompt or grant permanent su to shell/adb).\n" - ) console.print("[bold]Fix — restart frida-server as root:[/]") console.print( - f" [cyan]droidreaver device root-server{serial_flag}[/] " - "[dim](tries multiple su strategies)[/]\n" - f" [cyan]adb{serial_flag} shell su -c " - f"'pkill frida-server; {DEFAULT_FRIDA_SERVER_PATH} -D &'[/]\n" - " Manual check:\n" - f" [cyan]adb{serial_flag} shell su -c " - f"\"cat /proc/$(pidof frida-server)/status | grep Uid\"[/] " - "[dim]→ must show Uid: 0[/]" + f" [cyan]droidreaver device ready{serial_flag}[/] " + "[dim]or[/] [cyan]droidreaver device root-server{serial_flag}[/]\n" ) + if has_su: + console.print( + "[dim]On a Magisk phone, approve the superuser prompt for Shell/ADB.[/]\n" + f" [cyan]adb{serial_flag} shell su -c " + f"'pkill frida-server; {DEFAULT_FRIDA_SERVER_PATH} -D &'[/]" + ) + else: + console.print( + "[dim]On an emulator, `adb root` then start the server via adb shell:[/]\n" + f" [cyan]adb{serial_flag} root[/]\n" + f" [cyan]adb{serial_flag} shell " + f"'pkill frida-server; {DEFAULT_FRIDA_SERVER_PATH} -D &'[/]" + ) elif not status.device_rooted: console.print( - "[yellow]Device does not appear rooted[/] (adb shell su failed).\n" - "Frida can list apps but usually [bold]cannot attach[/] on a stock phone.\n\n" - "[bold]Options:[/]\n" - " • Rooted device with Magisk\n" - " • Rooted emulator (Android Studio AVD / Genymotion)\n" - " • frida-gadget patched APK (advanced)\n" + "[yellow]No root path detected[/] (`su` missing and `adb shell` is not uid 0).\n" + "Frida can list apps but usually [bold]cannot attach[/] to third-party processes.\n\n" + "[bold]Try, in order:[/]\n" + f" • Emulator (Google APIs / AOSP image): [cyan]adb{serial_flag} root[/] " + f"then [cyan]droidreaver device ready{serial_flag}[/]\n" + " • Phone: Magisk, grant su to Shell/ADB, then [cyan]droidreaver device ready[/]\n" + " • Debuggable debug build of the target app (attach may work without root)\n" ) else: console.print( - "[yellow]frida-server runs as root but attach still failed.[/]\n" - "Try opening the app first, then attach without [cyan]--spawn[/]:\n" - f" [cyan]droidreaver hook -n {pkg}{serial_flag}[/]\n" - f" [cyan]droidreaver objection -g {pkg} explore{serial_flag}[/]" + "[yellow]Attach closed while frida-server stayed up.[/]\n" + "Often a [bold]frida client / frida-server version mismatch[/]. " + "Run [cyan]droidreaver update frida[/] then [cyan]ready[/] so both match.\n" + "If versions already match, try [cyan]hook --spawn[/] or check [cyan]logcat[/].\n" + ) + log = run_cmd( + _adb_cmd(config, serial) + ["shell", "cat", "/data/local/tmp/frida-server.log"], + timeout=PROBE_CMD_TIMEOUT, + ) + text = (log.stdout or "").strip() + if text: + console.print("[dim]frida-server.log:[/]") + console.print(text) + alive = run_cmd( + _adb_cmd(config, serial) + ["shell", "pidof", "frida-server"], + timeout=PROBE_CMD_TIMEOUT, + ) + console.print( + f"[dim]frida-server pidof:[/] {(alive.stdout or alive.stderr or '').strip() or 'not running'}" ) diff --git a/droidreaver/doctor.py b/droidreaver/doctor.py index 0abf069..154842a 100644 --- a/droidreaver/doctor.py +++ b/droidreaver/doctor.py @@ -198,8 +198,8 @@ def check_frida_server_match(config: dict) -> CheckResult: return CheckResult( "Frida server", "warn", - "Device not rooted — Frida attach to third-party apps usually fails", - "Use a rooted device/emulator or frida-gadget", + "No root path (Magisk su or adb root) — Frida attach usually fails", + "adb root on a Google APIs AVD, or Magisk on a phone, then droidreaver ready", ) # Best-effort: if frida-ps works, versions likely match diff --git a/droidreaver/installer.py b/droidreaver/installer.py index a8cc387..6d55451 100644 --- a/droidreaver/installer.py +++ b/droidreaver/installer.py @@ -1125,14 +1125,23 @@ def update_pin_group( *, force: bool = False, ) -> list[tuple[str, str | None, str | None]]: - """Update all members of a pin group atomically.""" - from droidreaver.versions import PinGroupSyncError, print_sync_error + """Bump the frida pin group to the latest PyPI runtime (isolated venv).""" + from droidreaver.versions import ( + PinGroupSyncError, + get_active_frida_version, + print_sync_error, + resolve_frida_target, + use_frida_version, + ) + old = get_active_frida_version(cfg) + target = resolve_frida_target(cfg, "latest") try: - return sync_pin_group(tool_name, cfg, force=force) + use_frida_version(cfg, target, force=force) except PinGroupSyncError as e: print_sync_error(e) raise InstallError(str(e)) from e + return [("frida", old, target)] def get_tool_status(tool: Tool, cfg: dict, *, fetch_latest: bool = True) -> dict: diff --git a/droidreaver/progress.py b/droidreaver/progress.py index 52856eb..15922f9 100644 --- a/droidreaver/progress.py +++ b/droidreaver/progress.py @@ -2,6 +2,7 @@ from __future__ import annotations +import threading from contextlib import contextmanager from typing import Callable, Generator @@ -18,6 +19,20 @@ UpdateFn = Callable[[str | None], None] AdvanceFn = Callable[[int], None] +_tls = threading.local() + + +def _live_depth() -> int: + return getattr(_tls, "depth", 0) + + +def _enter_live() -> None: + _tls.depth = _live_depth() + 1 + + +def _exit_live() -> None: + _tls.depth = max(0, _live_depth() - 1) + def _spinner_columns(*, show_bar: bool) -> list: cols: list = [ @@ -40,19 +55,37 @@ def work_spinner( message: str, console: Console | None = None, ) -> Generator[UpdateFn, None, None]: - """Indeterminate spinner for a single blocking operation.""" + """Indeterminate spinner for a single blocking operation. + + Nested calls (e.g. push-server inside ``fix``) must not start a second + Rich Live display — that flickers two spinners hundreds of times a second. + """ con = console or Console() + if _live_depth() > 0: + con.print(f"[dim]{message}[/]") + + def nested_update(description: str | None = None) -> None: + if description and description != message: + con.print(f"[dim]{description}[/]") + + yield nested_update + return + def update(description: str | None = None) -> None: progress.update(task, description=description or message) - with Progress( - *_spinner_columns(show_bar=False), - console=con, - transient=True, - ) as progress: - task = progress.add_task(message, total=None) - yield update + _enter_live() + try: + with Progress( + *_spinner_columns(show_bar=False), + console=con, + transient=True, + ) as progress: + task = progress.add_task(message, total=None) + yield update + finally: + _exit_live() @contextmanager @@ -70,10 +103,14 @@ def update(description: str | None = None) -> None: def advance(step: int = 1) -> None: progress.advance(task, step) - with Progress( - *_spinner_columns(show_bar=True), - console=con, - transient=True, - ) as progress: - task = progress.add_task(message, total=total) - yield update, advance + _enter_live() + try: + with Progress( + *_spinner_columns(show_bar=True), + console=con, + transient=True, + ) as progress: + task = progress.add_task(message, total=total) + yield update, advance + finally: + _exit_live() diff --git a/droidreaver/scripts/ssl_pinning_bypass.js b/droidreaver/scripts/ssl_pinning_bypass.js index ff4d5f5..57f5e95 100644 --- a/droidreaver/scripts/ssl_pinning_bypass.js +++ b/droidreaver/scripts/ssl_pinning_bypass.js @@ -1,84 +1,328 @@ /** - * Universal Android SSL pinning bypass (Frida fallback). - * Hooks common TrustManager, OkHttp, and Conscrypt paths. + * Android SSL pinning bypass for authorized lab / MAPT use. + * + * Java + common third-party stacks, then best-effort libssl verify callbacks. + * Each hook is isolated: a missing class must not kill the Frida session. + * Spawn the app so these run before TLS is initialized. */ -Java.perform(function () { - console.log("[droidreaver] SSL pinning bypass active"); - - function trustAllManagers() { - var X509TrustManager = Java.use("javax.net.ssl.X509TrustManager"); - var TrustManager = Java.registerClass({ - name: "com.droidreaver.ssl.TrustAllManager", - implements: [X509TrustManager], - methods: { - checkClientTrusted: function () {}, - checkServerTrusted: function () {}, - getAcceptedIssuers: function () { - return []; - }, - }, - }); - return [TrustManager.$new()]; +var TAG = "[droidreaver] "; +var hooked = []; +var nativeKeepAlive = []; + +function log(msg) { + console.log(TAG + msg); +} + +function tryHook(name, fn) { + try { + fn(); + hooked.push(name); + log("hooked " + name); + } catch (e) { + /* class or overload not in this process */ } +} +function uniqueClassName(base) { + return base + "." + Date.now() + "." + Math.floor(Math.random() * 1e6); +} + +function findExport(modName, expName) { + try { + if (typeof Module.findExportByName === "function") { + return Module.findExportByName(modName, expName); + } + } catch (e) {} try { + var mod = + typeof Process.findModuleByName === "function" + ? Process.findModuleByName(modName) + : Process.getModuleByName(modName); + if (!mod) { + return null; + } + if (typeof mod.findExportByName === "function") { + return mod.findExportByName(expName); + } + return mod.getExportByName(expName); + } catch (e) { + return null; + } +} + +function hookNativeTls() { + var customOk = new NativeCallback( + function (_ssl, _outAlert) { + return 0; + }, + "int", + ["pointer", "pointer"] + ); + var opensslOk = new NativeCallback( + function (_preverifyOk, _ctx) { + return 1; + }, + "int", + ["int", "pointer"] + ); + nativeKeepAlive.push(customOk, opensslOk); + + var names = [ + "SSL_CTX_set_custom_verify", + "SSL_set_custom_verify", + "SSL_CTX_set_verify", + "SSL_set_verify", + ]; + var modules = ["libssl.so", "libboringssl.so", "libconscrypt_jni.so"]; + names.forEach(function (exp) { + modules.forEach(function (mod) { + try { + var addr = findExport(mod, exp); + if (!addr) { + return; + } + var cb = exp.indexOf("custom_verify") !== -1 ? customOk : opensslOk; + Interceptor.attach(addr, { + onEnter: function (args) { + args[2] = cb; + }, + }); + hooked.push(mod + "!" + exp); + log("hooked " + mod + "!" + exp); + } catch (e) { + log("native " + exp + " skipped: " + e); + } + }); + }); +} + +function hookJava() { + var X509TrustManager = Java.use("javax.net.ssl.X509TrustManager"); + var HostnameVerifier = Java.use("javax.net.ssl.HostnameVerifier"); + + var TrustAll = Java.registerClass({ + name: uniqueClassName("com.droidreaver.ssl.TrustAll"), + implements: [X509TrustManager], + methods: { + checkClientTrusted: function () {}, + checkServerTrusted: function () {}, + getAcceptedIssuers: function () { + return []; + }, + }, + }); + var AllowAllHosts = Java.registerClass({ + name: uniqueClassName("com.droidreaver.ssl.AllowAllHosts"), + implements: [HostnameVerifier], + methods: { + verify: function (_hostname, _session) { + return true; + }, + }, + }); + var trustAll = [TrustAll.$new()]; + var allowHosts = AllowAllHosts.$new(); + + tryHook("javax.net.ssl.SSLContext.init", function () { var SSLContext = Java.use("javax.net.ssl.SSLContext"); var init = SSLContext.init.overload( "[Ljavax.net.ssl.KeyManager;", "[Ljavax.net.ssl.TrustManager;", "java.security.SecureRandom" ); - var managers = trustAllManagers(); - init.implementation = function (km, tm, sr) { - init.call(this, km, managers, sr); + init.implementation = function (km, _tm, sr) { + init.call(this, km, trustAll, sr); }; - } catch (e) { - console.log("[droidreaver] SSLContext.init hook skipped: " + e); - } + }); - try { - var TrustManagerImpl = Java.use("com.android.org.conscrypt.TrustManagerImpl"); - TrustManagerImpl.verifyChain.implementation = function ( - untrustedChain, - trustAnchorChain, - host, - clientAuth, - ocspData, - tlsSctData - ) { - console.log("[droidreaver] TrustManagerImpl bypass: " + host); - return untrustedChain; + tryHook("javax.net.ssl.HttpsURLConnection", function () { + var Conn = Java.use("javax.net.ssl.HttpsURLConnection"); + var setDefault = Conn.setDefaultHostnameVerifier.overload( + "javax.net.ssl.HostnameVerifier" + ); + setDefault.implementation = function (_v) { + setDefault.call(Conn, allowHosts); }; - } catch (e) {} - - try { - var OkHttpPinner = Java.use("okhttp3.CertificatePinner"); - OkHttpPinner.check.overload("java.lang.String", "java.util.List").implementation = function ( - hostname, - peerCertificates - ) { - console.log("[droidreaver] OkHttp3 pinner bypass: " + hostname); + var setInst = Conn.setHostnameVerifier.overload("javax.net.ssl.HostnameVerifier"); + setInst.implementation = function (_v) { + setInst.call(this, allowHosts); }; - } catch (e) {} + Conn.getHostnameVerifier.implementation = function () { + return allowHosts; + }; + }); - try { - var OkHttpPinner2 = Java.use("okhttp3.CertificatePinner"); - OkHttpPinner2.check.overload("java.lang.String", "[Ljava.security.cert.Certificate;").implementation = - function (hostname, certs) { - console.log("[droidreaver] OkHttp3 pinner bypass (certs): " + hostname); + tryHook("javax.net.ssl.X509TrustManager", function () { + ["checkServerTrusted", "checkClientTrusted"].forEach(function (method) { + X509TrustManager[method].overloads.forEach(function (ovl) { + ovl.implementation = function () {}; + }); + }); + }); + + var conscryptTrust = [ + "com.android.org.conscrypt.TrustManagerImpl", + "org.conscrypt.TrustManagerImpl", + "com.google.org.conscrypt.TrustManagerImpl", + ]; + conscryptTrust.forEach(function (clsName) { + tryHook(clsName + ".verifyChain", function () { + var TMI = Java.use(clsName); + TMI.verifyChain.implementation = function (untrustedChain) { + return untrustedChain; }; - } catch (e) {} + }); + tryHook(clsName + ".checkTrustedRecursive", function () { + var TMI = Java.use(clsName); + TMI.checkTrustedRecursive.implementation = function ( + certs, + _ocsp, + _tlsSct, + _host, + _clientAuth, + _untrustedChain, + _trustAnchorChain, + _used + ) { + return certs; + }; + }); + tryHook(clsName + ".checkTrusted", function () { + var TMI = Java.use(clsName); + TMI.checkTrusted.overloads.forEach(function (ovl) { + ovl.implementation = function () { + var ArrayList = Java.use("java.util.ArrayList"); + return ArrayList.$new(); + }; + }); + }); + }); - try { - var HttpsURLConnection = Java.use("javax.net.ssl.HttpsURLConnection"); - HttpsURLConnection.setDefaultHostnameVerifier.implementation = function (verifier) { - return null; - }; - HttpsURLConnection.setSSLSocketFactory.implementation = function (factory) { - return null; + tryHook("com.android.org.conscrypt.Platform.checkServerTrusted", function () { + var Platform = Java.use("com.android.org.conscrypt.Platform"); + Platform.checkServerTrusted.overloads.forEach(function (ovl) { + ovl.implementation = function () {}; + }); + }); + + tryHook("android.security.net.config.NetworkSecurityTrustManager", function () { + var NSTM = Java.use("android.security.net.config.NetworkSecurityTrustManager"); + if (NSTM.checkPins) { + NSTM.checkPins.implementation = function () {}; + } + NSTM.checkServerTrusted.overloads.forEach(function (ovl) { + ovl.implementation = function () {}; + }); + }); + + tryHook("android.security.net.config.RootTrustManager", function () { + var RTM = Java.use("android.security.net.config.RootTrustManager"); + RTM.checkServerTrusted.overloads.forEach(function (ovl) { + ovl.implementation = function () {}; + }); + }); + + function hookOkHttpPinner(clsName) { + tryHook(clsName, function () { + var Pinner = Java.use(clsName); + Pinner.check.overloads.forEach(function (ovl) { + ovl.implementation = function () {}; + }); + if (Pinner["check$okhttp"]) { + Pinner["check$okhttp"].overloads.forEach(function (ovl) { + ovl.implementation = function () {}; + }); + } + if (Pinner.findMatchingPins) { + Pinner.findMatchingPins.implementation = function () { + return Java.use("java.util.ArrayList").$new(); + }; + } + }); + } + hookOkHttpPinner("okhttp3.CertificatePinner"); + hookOkHttpPinner("com.squareup.okhttp.CertificatePinner"); + hookOkHttpPinner("okhttp3.internal.tls.CertificatePinner"); + + [ + "okhttp3.internal.tls.OkHostnameVerifier", + "okhttp3.internal.hostname.OkHostnameVerifier", + "com.squareup.okhttp.internal.tls.OkHostnameVerifier", + ].forEach(function (clsName) { + tryHook(clsName, function () { + var V = Java.use(clsName); + V.verify.overloads.forEach(function (ovl) { + ovl.implementation = function () { + return true; + }; + }); + }); + }); + + [ + "com.datatheorem.android.trustkit.pinning.OkHttp3Helper", + "com.datatheorem.android.trustkit.pinning.PinningTrustManager", + "com.datatheorem.android.trustkit.pinning.TrustKitPinningTrustManager", + ].forEach(function (clsName) { + tryHook(clsName, function () { + var C = Java.use(clsName); + C.class.getDeclaredMethods().forEach(function (m) { + var n = m.getName(); + if (n.indexOf("check") === -1 && n.indexOf("verify") === -1) { + return; + } + try { + C[n].overloads.forEach(function (ovl) { + ovl.implementation = function () { + var ret = ovl.returnType.className; + if (ret === "boolean") { + return true; + } + }; + }); + } catch (e) {} + }); + }); + }); + + tryHook("org.apache.http.conn.ssl.SSLSocketFactory", function () { + var F = Java.use("org.apache.http.conn.ssl.SSLSocketFactory"); + F.isSecure.implementation = function () { + return true; }; - HttpsURLConnection.setHostnameVerifier.implementation = function (verifier) { - return null; + }); + tryHook("org.apache.http.conn.ssl.AbstractVerifier", function () { + var V = Java.use("org.apache.http.conn.ssl.AbstractVerifier"); + V.verify.overloads.forEach(function (ovl) { + ovl.implementation = function () {}; + }); + }); + + tryHook("android.webkit.WebViewClient.onReceivedSslError", function () { + var WVC = Java.use("android.webkit.WebViewClient"); + WVC.onReceivedSslError.implementation = function (_view, handler, _error) { + handler.proceed(); }; - } catch (e) {} -}); + }); + + tryHook("org.chromium.net.X509Util", function () { + var U = Java.use("org.chromium.net.X509Util"); + if (U.verifyServerCertificates) { + U.verifyServerCertificates.overloads.forEach(function (ovl) { + ovl.implementation = function () {}; + }); + } + }); + + log("Java SSL hooks installed (" + hooked.length + ")"); +} + +hookNativeTls(); + +if (Java.available) { + Java.perform(hookJava); +} else { + log("no Java VM — native TLS hooks only"); +} + +log("SSL pinning bypass ready (" + hooked.length + " hooks)"); diff --git a/droidreaver/versions.py b/droidreaver/versions.py index 02e526d..07e6c41 100644 --- a/droidreaver/versions.py +++ b/droidreaver/versions.py @@ -163,7 +163,8 @@ def fetch_frida_pypi_versions(limit: int = 15) -> list[str]: def resolve_frida_target(config: dict, explicit: str | None = None) -> str: - if explicit and explicit.lower() != "latest": + want_latest = bool(explicit) and explicit.lower() == "latest" + if explicit and not want_latest: target = explicit.lstrip("v") if not pypi_version_exists("frida", target): latest = fetch_pypi_latest_version("frida") @@ -175,14 +176,15 @@ def resolve_frida_target(config: dict, explicit: str | None = None) -> str: ) return target - pinned = get_pinned_version(config, "frida") - if pinned: - pinned = pinned.lstrip("v") - if pypi_version_exists("frida", pinned): - return pinned - console.print( - f"[yellow]Pinned frida {pinned} is not on PyPI — using latest instead.[/]" - ) + if not want_latest: + pinned = get_pinned_version(config, "frida") + if pinned: + pinned = pinned.lstrip("v") + if pypi_version_exists("frida", pinned): + return pinned + console.print( + f"[yellow]Pinned frida {pinned} is not on PyPI — using latest instead.[/]" + ) latest = fetch_pypi_latest_version("frida") if not latest: @@ -264,7 +266,7 @@ def use_frida_version( ) -> Path: """Like nvm use: select frida version, create venv if needed, pin in config.""" if version.lower() == "latest": - version = resolve_frida_target(config, None) + version = resolve_frida_target(config, "latest") else: version = version.lstrip("v") diff --git a/pyproject.toml b/pyproject.toml index a13db19..9c49816 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,7 +61,7 @@ where = ["."] include = ["droidreaver*"] [tool.setuptools.package-data] -droidreaver = ["../droidreaver.toml.default", "scripts/*.js"] +droidreaver = ["../droidreaver.toml.default", "scripts/*.js", "banner_art.txt"] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/tests/test_banner.py b/tests/test_banner.py index 653de6d..98251a4 100644 --- a/tests/test_banner.py +++ b/tests/test_banner.py @@ -30,11 +30,24 @@ def test_render_banner_compact(): def test_render_banner_full(): - rendered = render_banner(compact=False) - console = Console(record=True, color_system=None, width=100) + rendered = render_banner(compact=False, width=160) + console = Console(record=True, color_system=None, width=160) console.print(rendered) output = console.export_text() assert "ANDROID PENTEST ENVIRONMENT" in output assert "DROIDREAVER" in output assert "APK WORKFLOW" in output + assert "MOBILE SECURITY" in output + assert "frida" in output + assert "@@@@@@@@@@@@@@@@" in output assert "DROIDFORGE" not in output + + +def test_render_banner_narrow_falls_back_to_compact(): + rendered = render_banner(compact=False, width=40) + console = Console(record=True, color_system=None, width=40) + console.print(rendered) + output = console.export_text() + assert "DroidReaver" in output + assert "ANDROID PENTEST ENVIRONMENT" not in output + assert "@@@@@@@@@@@@@@@@" not in output diff --git a/tests/test_bypass.py b/tests/test_bypass.py index 707505c..c2021df 100644 --- a/tests/test_bypass.py +++ b/tests/test_bypass.py @@ -3,8 +3,6 @@ from pathlib import Path from unittest.mock import patch -import pytest - from droidreaver.bypass import ( RECIPES, AndroidApp, @@ -42,14 +40,27 @@ def test_bundled_scripts_exist(): assert script_path(MEDUSA_ROOT_SPEC, cfg).is_file() +def test_ssl_script_covers_common_stacks(): + text = Path(__file__).resolve().parents[1].joinpath( + "droidreaver", "scripts", "ssl_pinning_bypass.js" + ).read_text(encoding="utf-8") + for marker in ( + "SSLContext", + "TrustManagerImpl", + "CertificatePinner", + "NetworkSecurityTrustManager", + "WebViewClient", + "SSL_CTX_set_custom_verify", + "findExport", + "trustkit", + ): + assert marker in text, marker + + def test_list_android_apps_parses_frida_ps(): - cfg = {} - with patch("droidreaver.bypass.run_cmd") as mock_run: - mock_run.return_value.returncode = 0 - mock_run.return_value.stdout = FRIDA_PS_SAMPLE - with patch("droidreaver.bypass.resolve_executable", return_value=["frida-ps"]): - with patch("droidreaver.bypass._tool_ready", return_value=True): - apps = list_android_apps(cfg) + from droidreaver.bypass import _parse_frida_ps_apps + + apps = _parse_frida_ps_apps(FRIDA_PS_SAMPLE) assert len(apps) == 2 assert apps[0].identifier == "com.example.anchored" assert apps[0].pid == 14935 @@ -57,13 +68,18 @@ def test_list_android_apps_parses_frida_ps(): assert apps[1].pid is None +def test_list_android_apps_uses_adb_pm_and_ps(): + cfg = {} + with patch("droidreaver.bypass.list_installed_packages", return_value=["com.example.anchored"]): + with patch("droidreaver.bypass.process_name_pids", return_value={"com.example.anchored": 42}): + apps = list_android_apps(cfg) + assert apps[0].identifier == "com.example.anchored" + assert apps[0].pid == 42 + + def test_resolve_target_attach_running(): cfg = {} - apps = [ - AndroidApp(14935, "Anchored", "com.example.anchored"), - AndroidApp(None, "APKrypt", "com.example.apkrypt"), - ] - with patch("droidreaver.bypass.list_android_apps", return_value=apps): + with patch("droidreaver.bypass.pidof_package", return_value=14935): target = resolve_android_target( cfg, "com.example.anchored", spawn=False, serial=None, foremost=False ) @@ -71,17 +87,42 @@ def test_resolve_target_attach_running(): assert target.spawn is False -def test_resolve_target_auto_spawn_when_stopped(): +def test_resolve_target_stays_attach_when_stopped(): cfg = {} - apps = [AndroidApp(None, "APKrypt", "com.example.apkrypt")] - with patch("droidreaver.bypass.list_android_apps", return_value=apps): + with patch("droidreaver.bypass.pidof_package", return_value=None): target = resolve_android_target( cfg, "com.example.apkrypt", spawn=False, serial=None, foremost=False ) - assert target.spawn is True + assert target.spawn is False + assert target.pid is None assert target.identifier == "com.example.apkrypt" +def test_run_bypass_launches_then_attaches_when_stopped(): + cfg = {} + running = ResolvedTarget("com.example.app", "com.example.app", 99, "app", False) + with patch("droidreaver.bypass.pidof_package", return_value=None): + with patch("droidreaver.bypass.choose_engine", return_value="frida"): + with patch("droidreaver.bypass._tool_ready", return_value=True): + with patch("droidreaver.bypass.preflight"): + with patch("droidreaver.bypass.launch_app") as launch: + with patch( + "droidreaver.bypass._wait_for_running_app", + return_value=running, + ): + with patch( + "droidreaver.bypass._run_frida_session", + return_value=(0, 5.0), + ) as run_sess: + code = run_bypass(cfg, "all", "com.example.app", spawn=False) + assert code == 0 + launch.assert_called_once() + argv = run_sess.call_args[0][0] + assert "-N" in argv and "com.example.app" in argv + assert "-f" not in argv + assert "-U" in argv + + def test_build_objection_argv_uses_pid_for_attach(): cfg = {} recipe = RECIPES["ssl"] @@ -121,7 +162,7 @@ def test_build_frida_argv_attach_by_identifier(): assert len(script_flags) == 2 -def test_build_frida_argv_prefers_identifier_over_pid(): +def test_build_frida_argv_prefers_package_over_pid(): cfg = {} recipe = RECIPES["ssl"] target = ResolvedTarget("com.example.app", "com.example.app", 1234, "App", False) @@ -150,6 +191,7 @@ def test_build_frida_argv_serial_uses_only_device_id_transport(): assert "device-123" in argv assert "-U" not in argv assert "-R" not in argv + assert "-H" not in argv def test_build_frida_argv_network_uses_remote_transport_only(): @@ -186,8 +228,9 @@ def test_choose_engine_uses_frida_for_root(): def test_run_bypass_force_stops_before_spawn(): cfg = {} apps = [AndroidApp(None, "TalentCards", "com.talentcards.android")] - with patch("droidreaver.bypass.list_android_apps", return_value=apps): - with patch("droidreaver.bypass.choose_engine", return_value="frida"): + with patch("droidreaver.bypass.pidof_package", return_value=None): + with patch("droidreaver.bypass.list_android_apps", return_value=apps): + with patch("droidreaver.bypass.choose_engine", return_value="frida"): with patch("droidreaver.bypass._tool_ready", return_value=True): with patch("droidreaver.bypass.preflight"): with patch("droidreaver.bypass.force_stop_app") as stop: @@ -212,25 +255,25 @@ def test_run_bypass_spawn_failure_falls_back_to_attach(): "TalentCards", False, ) - with patch("droidreaver.bypass.list_android_apps", return_value=apps_stopped): - with patch("droidreaver.bypass.choose_engine", return_value="frida"): - with patch("droidreaver.bypass._tool_ready", return_value=True): - with patch("droidreaver.bypass.preflight"): - with patch("droidreaver.bypass.force_stop_app"): - with patch("droidreaver.bypass.launch_app"): - with patch( - "droidreaver.bypass._wait_for_running_app", - return_value=attach_target, - ): + with patch("droidreaver.bypass.pidof_package", return_value=None): + with patch("droidreaver.bypass.choose_engine", return_value="frida"): + with patch("droidreaver.bypass._tool_ready", return_value=True): + with patch("droidreaver.bypass.preflight"): + with patch("droidreaver.bypass.force_stop_app"): + with patch("droidreaver.bypass.launch_app"): with patch( - "droidreaver.bypass._run_frida_session", - side_effect=[(1, 0.5), (0, 5.0)], - ) as run_sess: - code = run_bypass( - cfg, - "all", - "com.talentcards.android", - spawn=True, - ) + "droidreaver.bypass._wait_for_running_app", + return_value=attach_target, + ): + with patch( + "droidreaver.bypass._run_frida_session", + side_effect=[(1, 0.5), (0, 5.0)], + ) as run_sess: + code = run_bypass( + cfg, + "all", + "com.talentcards.android", + spawn=True, + ) assert code == 0 assert run_sess.call_count == 2 diff --git a/tests/test_console.py b/tests/test_console.py index cad1da7..b90eefc 100644 --- a/tests/test_console.py +++ b/tests/test_console.py @@ -111,3 +111,43 @@ def timeout(argv, **kwargs): monkeypatch.setattr(console_mod.subprocess, "run", timeout) monkeypatch.setattr(console_mod, "adb_path", lambda cfg: "adb") assert c.do_adb("reverse tcp:8080 tcp:8080") is None + + +def test_spawn_in_new_window_windows_uses_start_cmd_k(monkeypatch): + monkeypatch.setattr(console_mod.sys, "platform", "win32") + calls = [] + + def fake_popen(*args, **kwargs): + calls.append((args, kwargs)) + return SimpleNamespace(pid=1) + + monkeypatch.setattr(console_mod.subprocess, "Popen", fake_popen) + ok = console_mod.spawn_in_new_window( + [r"C:\platform-tools\adb.exe", "-s", "emulator-5554", "logcat"], + env={}, + title='droidreaver logcat emulator-5554', + ) + assert ok is True + cmdline = calls[0][0][0] + assert 'start "droidreaver logcat emulator-5554"' in cmdline + assert "cmd /k" in cmdline + assert "adb.exe" in cmdline + assert calls[0][1]["shell"] is True + + +def test_logcat_opens_new_window(monkeypatch): + c = _make(monkeypatch, devices=["emulator-5554"]) + c.serial = "emulator-5554" + captured = {} + + def fake_spawn(argv, *, env, title): + captured["argv"] = argv + captured["title"] = title + return True + + monkeypatch.setattr(console_mod, "spawn_in_new_window", fake_spawn) + monkeypatch.setattr(console_mod, "adb_path", lambda cfg: "adb") + c.do_logcat("*:S AndroidRuntime:D") + assert captured["argv"][:4] == ["adb", "-s", "emulator-5554", "logcat"] + assert "AndroidRuntime:D" in captured["argv"] + assert "emulator-5554" in captured["title"] diff --git a/tests/test_device.py b/tests/test_device.py index 31e2cf7..3d46723 100644 --- a/tests/test_device.py +++ b/tests/test_device.py @@ -6,6 +6,9 @@ FridaServerStatus, adb_path, device_has_su, + device_is_rooted, + download_frida_server, + get_needed_frida_arches, get_local_frida_version, frida_server_user, restart_frida_server, @@ -25,6 +28,35 @@ def test_adb_path_finds_managed_platform_tools(tmp_path, monkeypatch): assert adb_path(cfg) == str(adb) +def test_needed_frida_arches_includes_32bit_companion(): + cfg = {"settings": {"adb_path": "adb"}} + with patch("droidreaver.device.get_device_arch", return_value="x86_64"): + with patch( + "droidreaver.device.get_cpu_abilist", + return_value=["x86_64", "x86"], + ): + assert get_needed_frida_arches(cfg) == ["x86_64", "x86"] + + +def test_needed_frida_arches_skips_x86_on_64bit_plus_arm_translation(): + cfg = {"settings": {"adb_path": "adb"}} + with patch("droidreaver.device.get_device_arch", return_value="x86_64"): + with patch( + "droidreaver.device.get_cpu_abilist", + return_value=["x86_64", "arm64-v8a"], + ): + assert get_needed_frida_arches(cfg) == ["x86_64"] + + +def test_download_frida_server_reuses_cached_file(tmp_path): + dest = tmp_path / "frida-server-17.25.1-android-x86_64" + dest.write_bytes(b"x" * 2048) + with patch("droidreaver.device.requests.get") as get: + result = download_frida_server("17.25.1", "x86_64", dest) + assert result == dest + get.assert_not_called() + + def test_device_has_su_true(): cfg = {"settings": {"adb_path": "adb"}} with patch("droidreaver.device.adb_path", return_value="adb"): @@ -34,6 +66,27 @@ def test_device_has_su_true(): assert device_has_su(cfg) is True +def test_device_is_rooted_via_adbd_without_su(): + cfg = {"settings": {"adb_path": "adb"}} + + def fake_run(cmd, **kwargs): + class Result: + returncode = 1 + stdout = "" + + result = Result() + if "su" in cmd: + return result + result.returncode = 0 + result.stdout = "uid=0(root) gid=0(root)" + return result + + with patch("droidreaver.device.adb_path", return_value="adb"): + with patch("droidreaver.device.run_cmd", side_effect=fake_run): + assert device_has_su(cfg) is False + assert device_is_rooted(cfg) is True + + def test_get_local_frida_version_uses_droidreaver_env(monkeypatch): captured = {} @@ -72,17 +125,43 @@ def test_frida_server_user_reads_root_uid(): def test_restart_frida_server_uses_su_when_rooted(): cfg = {"settings": {"adb_path": "adb"}} + rooted = FridaServerStatus(running=True, user="root", device_rooted=True) with patch("droidreaver.device.adb_path", return_value="adb"): - with patch("droidreaver.device.device_has_su", return_value=True): - with patch("droidreaver.device._kill_frida_server"): - with patch("droidreaver.device.run_cmd") as mock_run: - with patch("droidreaver.device.frida_server_status") as status: - status.side_effect = [ - FridaServerStatus(running=True, user="shell", device_rooted=True), - FridaServerStatus(running=True, user="root", device_rooted=True), - ] - result = restart_frida_server(cfg) + with patch("droidreaver.device.try_adb_root", return_value=False): + with patch("droidreaver.device.device_has_su", return_value=True): + with patch("droidreaver.device._kill_frida_server"): + with patch("droidreaver.device.ensure_frida_tcp_forward", return_value="127.0.0.1:27042"): + with patch("droidreaver.device.run_cmd") as mock_run: + with patch("droidreaver.device.frida_server_status") as status: + status.side_effect = [ + FridaServerStatus(running=True, user="shell", device_rooted=True), + rooted, + rooted, + ] + result = restart_frida_server(cfg) assert result.runs_as_root su_calls = [c for c in mock_run.call_args_list if "su" in c.args[0]] assert su_calls + + +def test_restart_frida_server_uses_adbd_root_without_su(): + cfg = {"settings": {"adb_path": "adb"}} + with patch("droidreaver.device.adb_path", return_value="adb"): + with patch("droidreaver.device.try_adb_root", return_value=True): + with patch("droidreaver.device.device_has_su", return_value=False): + with patch("droidreaver.device._kill_frida_server"): + with patch("droidreaver.device.ensure_frida_tcp_forward", return_value="127.0.0.1:27042"): + with patch("droidreaver.device.run_cmd") as mock_run: + with patch( + "droidreaver.device.frida_server_status", + return_value=FridaServerStatus( + running=True, user="root", device_rooted=True + ), + ): + result = restart_frida_server(cfg) + + assert result.runs_as_root + started = [c.args[0] for c in mock_run.call_args_list] + assert any("frida-server" in " ".join(cmd) for cmd in started) + assert not any("su" in cmd for cmd in started) diff --git a/tests/test_help.py b/tests/test_help.py index 7e09d6e..aab9b8e 100644 --- a/tests/test_help.py +++ b/tests/test_help.py @@ -21,6 +21,18 @@ def test_help_has_sections(): assert "Arsenal — Static Analysis" in result.output assert "status" in result.output assert "frida" in result.output + assert "ANDROID PENTEST ENVIRONMENT" not in result.output + assert "environment online" not in result.output + assert "Type droidreaver --help" not in result.output + + +def test_no_args_shows_banner_and_help_hint(): + runner = CliRunner() + result = runner.invoke(main, []) + assert result.exit_code == 0 + assert "droidreaver --help" in result.output + assert "Arsenal — Dynamic Analysis" not in result.output + assert "Get started" not in result.output def test_run_passes_flags_to_tool(): diff --git a/tests/test_progress.py b/tests/test_progress.py index 606c961..bf0409d 100644 --- a/tests/test_progress.py +++ b/tests/test_progress.py @@ -13,3 +13,12 @@ def test_work_progress_runs(): for i in range(3): update(f"Step {i + 1}") advance() + + +def test_nested_spinner_does_not_start_second_live(): + """Inner spinner must not fight the outer bar (fix → push-server).""" + with work_progress("Outer…", total=1) as (update, advance): + update("Fixing: Push and start frida-server…") + with work_spinner("Downloading frida-server…") as inner: + inner("still downloading") + advance() diff --git a/tests/test_versions.py b/tests/test_versions.py index 42372ad..35c6a5c 100644 --- a/tests/test_versions.py +++ b/tests/test_versions.py @@ -22,12 +22,69 @@ def test_frida_exact_vs_companions(): def test_resolve_frida_target_pinned(): cfg = {"pins": {"frida": "16.1.4"}, "settings": {}, "ignored": {"tools": []}, "active": {}} - assert resolve_frida_target(cfg, None) == "16.1.4" + with patch("droidreaver.versions.pypi_version_exists", return_value=True): + assert resolve_frida_target(cfg, None) == "16.1.4" def test_resolve_frida_target_explicit(): cfg = {"pins": {}, "settings": {}, "ignored": {"tools": []}, "active": {}} - assert resolve_frida_target(cfg, "17.11.0") == "17.11.0" + with patch("droidreaver.versions.pypi_version_exists", return_value=True): + assert resolve_frida_target(cfg, "17.11.0") == "17.11.0" + + +def test_resolve_frida_target_latest_ignores_pin(): + cfg = { + "pins": {"frida": "17.25.1"}, + "settings": {}, + "ignored": {"tools": []}, + "active": {"frida": "17.25.1"}, + } + with patch("droidreaver.versions.pypi_version_exists", return_value=True): + with patch( + "droidreaver.versions.fetch_pypi_latest_version", + return_value="17.25.2", + ): + assert resolve_frida_target(cfg, "latest") == "17.25.2" + assert resolve_frida_target(cfg, None) == "17.25.1" + + +def test_use_frida_version_latest_ignores_pin(): + cfg = { + "pins": {"frida": "17.25.1"}, + "settings": {}, + "ignored": {"tools": []}, + "active": {"frida": "17.25.1"}, + } + with patch( + "droidreaver.versions.fetch_pypi_latest_version", + return_value="17.25.2", + ): + with patch("droidreaver.versions.ensure_frida_venv") as ensure: + with patch("droidreaver.versions.save_config"): + with patch("droidreaver.versions.pin_tool", side_effect=lambda c, *_a, **_k: c): + versions.use_frida_version(cfg, "latest") + ensure.assert_called_once_with("17.25.2", force=False) + + +def test_update_pin_group_uses_latest_venv_not_pin(): + from droidreaver.installer import update_pin_group + + cfg = { + "pins": {"frida": "17.25.1"}, + "settings": {}, + "ignored": {"tools": []}, + "active": {"frida": "17.25.1"}, + } + with patch( + "droidreaver.versions.resolve_frida_target", + return_value="17.25.2", + ): + with patch("droidreaver.versions.use_frida_version") as use: + results = update_pin_group("frida", cfg) + + use.assert_called_once() + assert use.call_args.args[1] == "17.25.2" + assert results == [("frida", "17.25.1", "17.25.2")] def test_frida_environment_ready_requires_complete_stack(tmp_path, monkeypatch): From 9ced14078450512d9c1fc0f5054b011b13ba7fce Mon Sep 17 00:00:00 2001 From: Codeblin Date: Tue, 3 Nov 2026 12:58:05 +0200 Subject: [PATCH 2/3] Use DroidReaver Vault as the test target package. Replace TalentCards and other third-party app identifiers in unit tests with com.codeblin.droidreavervault. Co-authored-by: Cursor --- tests/test_bypass.py | 62 +++++++++++++++++++++---------------------- tests/test_console.py | 13 +++++---- 2 files changed, 39 insertions(+), 36 deletions(-) diff --git a/tests/test_bypass.py b/tests/test_bypass.py index c2021df..3a8863f 100644 --- a/tests/test_bypass.py +++ b/tests/test_bypass.py @@ -18,10 +18,10 @@ ) FRIDA_PS_SAMPLE = """\ - PID Name Identifier ------ --------- ------------------- -14935 Anchored com.example.anchored - - APKrypt com.example.apkrypt + PID Name Identifier +----- ---------------- -------------------------------- +14935 droidreavervault com.codeblin.droidreavervault + - droidreavervault com.codeblin.droidreavervault.debug """ @@ -62,18 +62,18 @@ def test_list_android_apps_parses_frida_ps(): apps = _parse_frida_ps_apps(FRIDA_PS_SAMPLE) assert len(apps) == 2 - assert apps[0].identifier == "com.example.anchored" + assert apps[0].identifier == "com.codeblin.droidreavervault" assert apps[0].pid == 14935 - assert apps[1].identifier == "com.example.apkrypt" + assert apps[1].identifier == "com.codeblin.droidreavervault.debug" assert apps[1].pid is None def test_list_android_apps_uses_adb_pm_and_ps(): cfg = {} - with patch("droidreaver.bypass.list_installed_packages", return_value=["com.example.anchored"]): - with patch("droidreaver.bypass.process_name_pids", return_value={"com.example.anchored": 42}): + with patch("droidreaver.bypass.list_installed_packages", return_value=["com.codeblin.droidreavervault"]): + with patch("droidreaver.bypass.process_name_pids", return_value={"com.codeblin.droidreavervault": 42}): apps = list_android_apps(cfg) - assert apps[0].identifier == "com.example.anchored" + assert apps[0].identifier == "com.codeblin.droidreavervault" assert apps[0].pid == 42 @@ -81,7 +81,7 @@ def test_resolve_target_attach_running(): cfg = {} with patch("droidreaver.bypass.pidof_package", return_value=14935): target = resolve_android_target( - cfg, "com.example.anchored", spawn=False, serial=None, foremost=False + cfg, "com.codeblin.droidreavervault", spawn=False, serial=None, foremost=False ) assert target.pid == 14935 assert target.spawn is False @@ -91,16 +91,16 @@ def test_resolve_target_stays_attach_when_stopped(): cfg = {} with patch("droidreaver.bypass.pidof_package", return_value=None): target = resolve_android_target( - cfg, "com.example.apkrypt", spawn=False, serial=None, foremost=False + cfg, "com.codeblin.droidreavervault.debug", spawn=False, serial=None, foremost=False ) assert target.spawn is False assert target.pid is None - assert target.identifier == "com.example.apkrypt" + assert target.identifier == "com.codeblin.droidreavervault.debug" def test_run_bypass_launches_then_attaches_when_stopped(): cfg = {} - running = ResolvedTarget("com.example.app", "com.example.app", 99, "app", False) + running = ResolvedTarget("com.codeblin.droidreavervault", "com.codeblin.droidreavervault", 99, "app", False) with patch("droidreaver.bypass.pidof_package", return_value=None): with patch("droidreaver.bypass.choose_engine", return_value="frida"): with patch("droidreaver.bypass._tool_ready", return_value=True): @@ -114,11 +114,11 @@ def test_run_bypass_launches_then_attaches_when_stopped(): "droidreaver.bypass._run_frida_session", return_value=(0, 5.0), ) as run_sess: - code = run_bypass(cfg, "all", "com.example.app", spawn=False) + code = run_bypass(cfg, "all", "com.codeblin.droidreavervault", spawn=False) assert code == 0 launch.assert_called_once() argv = run_sess.call_args[0][0] - assert "-N" in argv and "com.example.app" in argv + assert "-N" in argv and "com.codeblin.droidreavervault" in argv assert "-f" not in argv assert "-U" in argv @@ -126,7 +126,7 @@ def test_run_bypass_launches_then_attaches_when_stopped(): def test_build_objection_argv_uses_pid_for_attach(): cfg = {} recipe = RECIPES["ssl"] - target = ResolvedTarget("com.example.app", "com.example.app", 1234, "App", False) + target = ResolvedTarget("com.codeblin.droidreavervault", "com.codeblin.droidreavervault", 1234, "App", False) with patch("droidreaver.bypass.resolve_executable", return_value=["objection"]): argv = build_objection_argv( cfg, recipe, target, serial=None, network=False, foremost=False @@ -138,7 +138,7 @@ def test_build_objection_argv_uses_pid_for_attach(): def test_build_objection_argv_spawn(): cfg = {} recipe = RECIPES["ssl"] - target = ResolvedTarget("com.example.app", "com.example.app", None, "App", True) + target = ResolvedTarget("com.codeblin.droidreavervault", "com.codeblin.droidreavervault", None, "App", True) with patch("droidreaver.bypass.resolve_executable", return_value=["objection"]): argv = build_objection_argv( cfg, recipe, target, serial="emulator-5554", network=False, foremost=False @@ -151,12 +151,12 @@ def test_build_objection_argv_spawn(): def test_build_frida_argv_attach_by_identifier(): cfg = {} recipe = RECIPES["all"] - target = ResolvedTarget("com.example.app", "com.example.app", None, "App", False) + target = ResolvedTarget("com.codeblin.droidreavervault", "com.codeblin.droidreavervault", None, "App", False) with patch("droidreaver.bypass.resolve_executable", return_value=["frida"]): argv = build_frida_argv( cfg, recipe, target, serial=None, network=False, foremost=False ) - assert "-N" in argv and "com.example.app" in argv + assert "-N" in argv and "com.codeblin.droidreavervault" in argv assert "-n" not in argv script_flags = [Path(p) for i, p in enumerate(argv) if argv[i - 1] == "-l"] assert len(script_flags) == 2 @@ -165,19 +165,19 @@ def test_build_frida_argv_attach_by_identifier(): def test_build_frida_argv_prefers_package_over_pid(): cfg = {} recipe = RECIPES["ssl"] - target = ResolvedTarget("com.example.app", "com.example.app", 1234, "App", False) + target = ResolvedTarget("com.codeblin.droidreavervault", "com.codeblin.droidreavervault", 1234, "App", False) with patch("droidreaver.bypass.resolve_executable", return_value=["frida"]): argv = build_frida_argv( cfg, recipe, target, serial=None, network=False, foremost=False ) - assert "-N" in argv and "com.example.app" in argv + assert "-N" in argv and "com.codeblin.droidreavervault" in argv assert "-p" not in argv def test_build_frida_argv_serial_uses_only_device_id_transport(): cfg = {} recipe = RECIPES["ssl"] - target = ResolvedTarget("com.example.app", "com.example.app", None, "App", True) + target = ResolvedTarget("com.codeblin.droidreavervault", "com.codeblin.droidreavervault", None, "App", True) with patch("droidreaver.bypass.resolve_executable", return_value=["frida"]): argv = build_frida_argv( cfg, @@ -197,7 +197,7 @@ def test_build_frida_argv_serial_uses_only_device_id_transport(): def test_build_frida_argv_network_uses_remote_transport_only(): cfg = {} recipe = RECIPES["ssl"] - target = ResolvedTarget("com.example.app", "com.example.app", None, "App", True) + target = ResolvedTarget("com.codeblin.droidreavervault", "com.codeblin.droidreavervault", None, "App", True) with patch("droidreaver.bypass.resolve_executable", return_value=["frida"]): argv = build_frida_argv( cfg, @@ -227,7 +227,7 @@ def test_choose_engine_uses_frida_for_root(): def test_run_bypass_force_stops_before_spawn(): cfg = {} - apps = [AndroidApp(None, "TalentCards", "com.talentcards.android")] + apps = [AndroidApp(None, "droidreavervault", "com.codeblin.droidreavervault")] with patch("droidreaver.bypass.pidof_package", return_value=None): with patch("droidreaver.bypass.list_android_apps", return_value=apps): with patch("droidreaver.bypass.choose_engine", return_value="frida"): @@ -238,21 +238,21 @@ def test_run_bypass_force_stops_before_spawn(): code = run_bypass( cfg, "all", - "com.talentcards.android", + "com.codeblin.droidreavervault", spawn=True, ) assert code == 0 - stop.assert_called_once_with(cfg, "com.talentcards.android", serial=None) + stop.assert_called_once_with(cfg, "com.codeblin.droidreavervault", serial=None) def test_run_bypass_spawn_failure_falls_back_to_attach(): cfg = {} - apps_stopped = [AndroidApp(None, "TalentCards", "com.talentcards.android")] + apps_stopped = [AndroidApp(None, "droidreavervault", "com.codeblin.droidreavervault")] attach_target = ResolvedTarget( - "com.talentcards.android", - "com.talentcards.android", + "com.codeblin.droidreavervault", + "com.codeblin.droidreavervault", None, - "TalentCards", + "droidreavervault", False, ) with patch("droidreaver.bypass.pidof_package", return_value=None): @@ -272,7 +272,7 @@ def test_run_bypass_spawn_failure_falls_back_to_attach(): code = run_bypass( cfg, "all", - "com.talentcards.android", + "com.codeblin.droidreavervault", spawn=True, ) assert code == 0 diff --git a/tests/test_console.py b/tests/test_console.py index b90eefc..5bd08cc 100644 --- a/tests/test_console.py +++ b/tests/test_console.py @@ -21,10 +21,10 @@ def test_prompt_reflects_state(monkeypatch): assert "no-device" in c.prompt assert "no-target" in c.prompt c.serial = "emulator-5554" - c.package = "com.example.app" + c.package = "com.codeblin.droidreavervault" c._update_prompt() assert "emulator-5554" in c.prompt - assert "com.example.app" in c.prompt + assert "com.codeblin.droidreavervault" in c.prompt def test_empty_line_is_noop(monkeypatch): @@ -70,10 +70,13 @@ def __init__(self, ident, name, pid=None): def running(self): return self.pid is not None - apps = [App("com.example.app", "Example"), App("com.other.app", "Other")] + apps = [ + App("com.codeblin.droidreavervault", "droidreavervault"), + App("com.codeblin.droidreavervault.debug", "droidreavervault"), + ] c = _make(monkeypatch, apps=apps) - c.do_target("com.example.app") - assert c.package == "com.example.app" + c.do_target("com.codeblin.droidreavervault") + assert c.package == "com.codeblin.droidreavervault" def test_target_falls_back_to_literal_when_no_apps(monkeypatch): From 1b3cb2657624e1cbd621842b5480b364a3e92d7e Mon Sep 17 00:00:00 2001 From: Codeblin Date: Tue, 3 Nov 2026 13:02:30 +0200 Subject: [PATCH 3/3] Stop unit tests from calling real adb and Win32 pathlib. Mock relax_selinux in hook tests, and stub sys.platform without flipping the process onto Windows Path APIs. Co-authored-by: Cursor --- tests/test_bypass.py | 32 ++++++++++++++++++-------------- tests/test_console.py | 4 +++- 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/tests/test_bypass.py b/tests/test_bypass.py index 3a8863f..7fa1673 100644 --- a/tests/test_bypass.py +++ b/tests/test_bypass.py @@ -110,10 +110,11 @@ def test_run_bypass_launches_then_attaches_when_stopped(): "droidreaver.bypass._wait_for_running_app", return_value=running, ): - with patch( - "droidreaver.bypass._run_frida_session", - return_value=(0, 5.0), - ) as run_sess: + with patch("droidreaver.bypass.relax_selinux"): + with patch( + "droidreaver.bypass._run_frida_session", + return_value=(0, 5.0), + ) as run_sess: code = run_bypass(cfg, "all", "com.codeblin.droidreavervault", spawn=False) assert code == 0 launch.assert_called_once() @@ -234,7 +235,8 @@ def test_run_bypass_force_stops_before_spawn(): with patch("droidreaver.bypass._tool_ready", return_value=True): with patch("droidreaver.bypass.preflight"): with patch("droidreaver.bypass.force_stop_app") as stop: - with patch("droidreaver.bypass._run_frida_session", return_value=(0, 1.0)): + with patch("droidreaver.bypass.relax_selinux"): + with patch("droidreaver.bypass._run_frida_session", return_value=(0, 1.0)): code = run_bypass( cfg, "all", @@ -260,15 +262,17 @@ def test_run_bypass_spawn_failure_falls_back_to_attach(): with patch("droidreaver.bypass._tool_ready", return_value=True): with patch("droidreaver.bypass.preflight"): with patch("droidreaver.bypass.force_stop_app"): - with patch("droidreaver.bypass.launch_app"): - with patch( - "droidreaver.bypass._wait_for_running_app", - return_value=attach_target, - ): - with patch( - "droidreaver.bypass._run_frida_session", - side_effect=[(1, 0.5), (0, 5.0)], - ) as run_sess: + with patch("droidreaver.bypass.relax_selinux"): + with patch("droidreaver.bypass.list_android_apps", return_value=apps_stopped): + with patch("droidreaver.bypass.launch_app"): + with patch( + "droidreaver.bypass._wait_for_running_app", + return_value=attach_target, + ): + with patch( + "droidreaver.bypass._run_frida_session", + side_effect=[(1, 0.5), (0, 5.0)], + ) as run_sess: code = run_bypass( cfg, "all", diff --git a/tests/test_console.py b/tests/test_console.py index 5bd08cc..e446c51 100644 --- a/tests/test_console.py +++ b/tests/test_console.py @@ -117,7 +117,9 @@ def timeout(argv, **kwargs): def test_spawn_in_new_window_windows_uses_start_cmd_k(monkeypatch): - monkeypatch.setattr(console_mod.sys, "platform", "win32") + # Patch only this module's sys so pathlib does not switch to Win32 APIs on Linux/macOS. + monkeypatch.setattr(console_mod, "sys", SimpleNamespace(platform="win32")) + monkeypatch.setattr(console_mod, "_resolve_exe", lambda exe: exe) calls = [] def fake_popen(*args, **kwargs):