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..7fa1673 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, @@ -20,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 """ @@ -42,50 +40,94 @@ 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].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.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.codeblin.droidreavervault" + 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 + cfg, "com.codeblin.droidreavervault", spawn=False, serial=None, foremost=False ) assert target.pid == 14935 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 + cfg, "com.codeblin.droidreavervault.debug", spawn=False, serial=None, foremost=False ) - assert target.spawn is True - assert target.identifier == "com.example.apkrypt" + assert target.spawn is False + assert target.pid is None + assert target.identifier == "com.codeblin.droidreavervault.debug" + + +def test_run_bypass_launches_then_attaches_when_stopped(): + cfg = {} + 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): + 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.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() + argv = run_sess.call_args[0][0] + assert "-N" in argv and "com.codeblin.droidreavervault" in argv + assert "-f" not in argv + assert "-U" in argv 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 @@ -97,7 +139,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 @@ -110,33 +152,33 @@ 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 -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) + 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, @@ -150,12 +192,13 @@ 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(): 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, @@ -185,52 +228,56 @@ 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"): + 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"): 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", - "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.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._run_frida_session", - side_effect=[(1, 0.5), (0, 5.0)], - ) as run_sess: - code = run_bypass( - cfg, - "all", - "com.talentcards.android", - spawn=True, - ) + 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.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", + "com.codeblin.droidreavervault", + 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..e446c51 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): @@ -111,3 +114,45 @@ 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): + # 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): + 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):