frida-server 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,73 @@
1
+ """frida-server — zero-config Frida server deployment for Android.
2
+
3
+ Detects a connected Android device, matches the ``frida-server`` binary to the
4
+ Frida version installed on your PC, downloads it, pushes it, and launches it —
5
+ all in one command.
6
+
7
+ Typical use is via the ``frida-server`` command-line tool. The object model can
8
+ also be driven programmatically::
9
+
10
+ from frida_server import Deployer
11
+
12
+ Deployer().run() # interactive: pick a device and deploy
13
+
14
+ or, for the common case, the module-level convenience wrapper::
15
+
16
+ from frida_server import setup
17
+
18
+ setup(serial="emulator-5554", update=False)
19
+
20
+ See :class:`frida_server.deployer.Deployer` for the orchestration entry point.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from .exceptions import (
26
+ AdbError,
27
+ DeviceError,
28
+ DownloadError,
29
+ FridaServerError,
30
+ RootAccessError,
31
+ )
32
+
33
+ __all__ = [
34
+ "__version__",
35
+ "setup",
36
+ "Deployer",
37
+ "FridaServerError",
38
+ "AdbError",
39
+ "DeviceError",
40
+ "DownloadError",
41
+ "RootAccessError",
42
+ ]
43
+
44
+ __version__ = "1.0.0"
45
+ __author__ = "S. SHAJON"
46
+ __license__ = "GPL-3.0-or-later"
47
+
48
+
49
+ def setup(
50
+ *,
51
+ serial: str | None = None,
52
+ update: bool = True,
53
+ port: int = 27042,
54
+ force: bool = False,
55
+ ) -> bool:
56
+ """Convenience wrapper around :meth:`frida_server.deployer.Deployer.run`.
57
+
58
+ Deploys and starts ``frida-server`` on a connected Android device, returning
59
+ ``True`` if the server ends up running. See :meth:`Deployer.run` for the
60
+ full argument reference.
61
+ """
62
+ from .deployer import Deployer
63
+
64
+ return Deployer().run(serial=serial, update=update, port=port, force=force)
65
+
66
+
67
+ def __getattr__(name: str):
68
+ # Lazily expose Deployer so ``import frida_server`` stays cheap.
69
+ if name == "Deployer":
70
+ from .deployer import Deployer
71
+
72
+ return Deployer
73
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,10 @@
1
+ """Enable ``python -m frida_server``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from .cli import main
8
+
9
+ if __name__ == "__main__":
10
+ sys.exit(main())
@@ -0,0 +1,71 @@
1
+ """Local cache of known-good ``frida-server`` SHA-256 digests.
2
+
3
+ Because several devices of different architectures may be used from the same
4
+ machine, the cache is **grouped by architecture** — each arch keeps its own map
5
+ of Frida version → digest::
6
+
7
+ {
8
+ "android-arm64": { "17.17.0": "55ef78c3…" },
9
+ "android-x86_64": { "17.17.0": "b34a33bd…" }
10
+ }
11
+
12
+ When a binary is downloaded and pushed, its SHA-256 is recorded under that
13
+ device's arch. On later runs the on-device binary's digest is compared against
14
+ the entry for its arch to detect a corrupt, tampered, or foreign server and
15
+ trigger a re-deploy — going beyond a plain version-string check.
16
+
17
+ The store lives at ``~/.frida-server/checksums.json`` and degrades gracefully:
18
+ a missing, corrupt, or legacy-format file is treated as empty.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import hashlib
24
+ import json
25
+ from pathlib import Path
26
+
27
+
28
+ class ChecksumStore:
29
+ """A JSON-backed map of ``arch`` → (``version`` → SHA-256 hex digest)."""
30
+
31
+ _PATH = Path.home() / ".frida-server" / "checksums.json"
32
+
33
+ def __init__(self, path: Path | None = None) -> None:
34
+ self._path = path or self._PATH
35
+
36
+ @staticmethod
37
+ def file_sha256(path: str | Path) -> str:
38
+ """Return the SHA-256 hex digest of a local file."""
39
+ digest = hashlib.sha256()
40
+ with open(path, "rb") as fh:
41
+ for block in iter(lambda: fh.read(1 << 20), b""):
42
+ digest.update(block)
43
+ return digest.hexdigest()
44
+
45
+ def _load(self) -> dict[str, dict[str, str]]:
46
+ try:
47
+ with open(self._path, encoding="utf-8") as fh:
48
+ data = json.load(fh)
49
+ except (FileNotFoundError, json.JSONDecodeError, OSError):
50
+ return {}
51
+ # Keep only well-formed ``arch -> {version: digest}`` buckets; anything
52
+ # else (e.g. a legacy flat layout) is ignored and re-established on use.
53
+ return {
54
+ arch: bucket
55
+ for arch, bucket in data.items()
56
+ if isinstance(bucket, dict)
57
+ } if isinstance(data, dict) else {}
58
+
59
+ def get(self, version: str, arch: str) -> str | None:
60
+ """Return the cached digest for ``version`` on ``arch``, or ``None``."""
61
+ return self._load().get(arch, {}).get(version)
62
+
63
+ def set(self, version: str, arch: str, digest: str) -> None:
64
+ """Record ``digest`` for ``version`` on ``arch``, creating the store if needed."""
65
+ data = self._load()
66
+ data.setdefault(arch, {})[version] = digest
67
+ self._path.parent.mkdir(parents=True, exist_ok=True)
68
+ tmp = self._path.with_suffix(".json.tmp")
69
+ with open(tmp, "w", encoding="utf-8") as fh:
70
+ json.dump(data, fh, indent=2, sort_keys=True)
71
+ tmp.replace(self._path) # atomic on the same filesystem
frida_server/cli.py ADDED
@@ -0,0 +1,101 @@
1
+ """Command-line interface for ``frida-server``.
2
+
3
+ :class:`Cli` is a thin wrapper around :class:`~frida_server.deployer.Deployer`:
4
+ parse arguments, translate :class:`~frida_server.exceptions.FridaServerError`
5
+ into a clean exit code, and keep ``Ctrl-C`` quiet.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import sys
12
+
13
+ from . import __version__
14
+ from .console import Console
15
+ from .deployer import Deployer
16
+ from .exceptions import FridaServerError
17
+
18
+ _DESCRIPTION = "Deploy and start frida-server on a connected Android device."
19
+ _EPILOG = (
20
+ "examples:\n"
21
+ " frida-server # pick a device and deploy\n"
22
+ " frida-server -s emulator-5554 # pick a device and deploy\n"
23
+ " frida-server --no-update --port 27043 # pick a device and deploy\n"
24
+ " frida-server --force # re-download even if versions match\n"
25
+ )
26
+
27
+
28
+ class Cli:
29
+ """Parses arguments and drives a :class:`Deployer` run."""
30
+
31
+ def __init__(self) -> None:
32
+ self._console = Console()
33
+
34
+ def build_parser(self) -> argparse.ArgumentParser:
35
+ """Construct the argument parser (kept separate for testing)."""
36
+ parser = argparse.ArgumentParser(
37
+ prog="frida-server",
38
+ description=_DESCRIPTION,
39
+ epilog=_EPILOG,
40
+ formatter_class=argparse.RawDescriptionHelpFormatter,
41
+ )
42
+ parser.add_argument(
43
+ "-s",
44
+ "--serial",
45
+ metavar="SERIAL",
46
+ help="target a specific device serial (default: prompt when several)",
47
+ )
48
+ parser.add_argument(
49
+ "-p",
50
+ "--port",
51
+ type=int,
52
+ default=27042,
53
+ metavar="PORT",
54
+ help="TCP port to forward after start (default: 27042)",
55
+ )
56
+ parser.add_argument(
57
+ "--no-update",
58
+ dest="update",
59
+ action="store_false",
60
+ help="skip the host-side Frida update check",
61
+ )
62
+ parser.add_argument(
63
+ "--force",
64
+ action="store_true",
65
+ help="re-download and re-push even if a matching server is present",
66
+ )
67
+ parser.add_argument(
68
+ "-V",
69
+ "--version",
70
+ action="version",
71
+ version=f"%(prog)s {__version__}",
72
+ )
73
+ return parser
74
+
75
+ def run(self, argv: list[str] | None = None) -> int:
76
+ """Entry point. Returns a process exit code."""
77
+ args = self.build_parser().parse_args(argv)
78
+ try:
79
+ ok = Deployer(self._console).run(
80
+ serial=args.serial,
81
+ update=args.update,
82
+ port=args.port,
83
+ force=args.force,
84
+ )
85
+ return 0 if ok else 1
86
+ except FridaServerError as exc:
87
+ self._console.error(str(exc))
88
+ return 1
89
+ except KeyboardInterrupt:
90
+ print()
91
+ self._console.info("Interrupted.")
92
+ return 130
93
+
94
+
95
+ def main(argv: list[str] | None = None) -> int:
96
+ """Console-script entry point: run the CLI and return an exit code."""
97
+ return Cli().run(argv)
98
+
99
+
100
+ if __name__ == "__main__": # pragma: no cover
101
+ sys.exit(main())
@@ -0,0 +1,54 @@
1
+ """Console output.
2
+
3
+ A single :class:`Console` object owns all terminal output for a run, keeping the
4
+ ``[*] / [+] / [!] / [-] / [?]`` status style in one place. ANSI colour
5
+ auto-disables when stdout is not a TTY (or when ``NO_COLOR`` is set), so piped
6
+ output stays clean.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import sys
13
+
14
+
15
+ class Console:
16
+ """Renders status lines with consistent markers and optional colour."""
17
+
18
+ _COLORS = {
19
+ "info": "36", # cyan
20
+ "success": "32", # green
21
+ "warn": "33", # yellow
22
+ "error": "31", # red
23
+ "prompt": "35", # magenta
24
+ }
25
+
26
+ def __init__(self, *, color: bool | None = None) -> None:
27
+ if color is None:
28
+ color = sys.stdout.isatty() and os.environ.get("NO_COLOR") is None
29
+ self._color = color
30
+
31
+ def _paint(self, kind: str, text: str) -> str:
32
+ if not self._color:
33
+ return text
34
+ return f"\033[{self._COLORS[kind]}m{text}\033[0m"
35
+
36
+ def info(self, message: str) -> None:
37
+ """Neutral progress line, e.g. ``[*] Doing a thing``."""
38
+ print(f"{self._paint('info', '[*]')} {message}")
39
+
40
+ def success(self, message: str) -> None:
41
+ """Positive outcome, e.g. ``[+] Done``."""
42
+ print(f"{self._paint('success', '[+]')} {message}")
43
+
44
+ def warn(self, message: str) -> None:
45
+ """Non-fatal warning, e.g. ``[!] Heads up``."""
46
+ print(f"{self._paint('warn', '[!]')} {message}")
47
+
48
+ def error(self, message: str) -> None:
49
+ """Failure line, written to stderr, e.g. ``[-] Broke``."""
50
+ print(f"{self._paint('error', '[-]')} {message}", file=sys.stderr)
51
+
52
+ def prompt(self, message: str) -> str:
53
+ """Ask the user a question and return the stripped answer."""
54
+ return input(f"{self._paint('prompt', '[?]')} {message}").strip()
@@ -0,0 +1,210 @@
1
+ """High-level orchestration: the full deploy-and-run flow.
2
+
3
+ :class:`Deployer` ties the layers together — resolve the host Frida version,
4
+ pick a device, compare against what is already on the device, download only when
5
+ needed, then push and launch. It is the single entry point the CLI and library
6
+ users share.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import sys
12
+ import tempfile
13
+
14
+ from .checksums import ChecksumStore
15
+ from .console import Console
16
+ from .device import Device, DeviceManager
17
+ from .downloader import Downloader
18
+ from .exceptions import DeviceError
19
+ from .host import FridaHost
20
+
21
+
22
+ class Deployer:
23
+ """Deploys and starts ``frida-server`` on a connected Android device."""
24
+
25
+ def __init__(self, console: Console | None = None) -> None:
26
+ self._console = console or Console()
27
+ self._host = FridaHost(self._console)
28
+ self._manager = DeviceManager(self._console)
29
+ self._checksums = ChecksumStore()
30
+
31
+ def run(
32
+ self,
33
+ *,
34
+ serial: str | None = None,
35
+ update: bool = True,
36
+ port: int = 27042,
37
+ force: bool = False,
38
+ ) -> bool:
39
+ """Deploy and start ``frida-server`` on a connected Android device.
40
+
41
+ Args:
42
+ serial: target a specific device serial; when ``None`` and several
43
+ are connected, the user is prompted to choose.
44
+ update: when ``True``, offer to update the host Frida install first.
45
+ port: TCP port to forward from PC to device after a successful start.
46
+ force: re-download and re-push even if a matching server exists.
47
+
48
+ Returns:
49
+ ``True`` if ``frida-server`` ends up running on the device.
50
+
51
+ Raises:
52
+ FridaServerError: (or a subclass) on any unrecoverable failure.
53
+ """
54
+ console = self._console
55
+ console.info("Starting Frida server setup...")
56
+
57
+ # 1. Host Frida version — the server binary must match it exactly.
58
+ version = self._host.maybe_update(interactive=update)
59
+
60
+ # 2. Device discovery / selection.
61
+ self._manager.ensure_adb()
62
+ serials = self._manager.serials()
63
+ if not serials:
64
+ raise DeviceError(
65
+ "No devices connected. Connect an Android device with USB debugging on."
66
+ )
67
+ if serial is not None:
68
+ device = self._manager.get(serial)
69
+ else:
70
+ console.info(f"Found {len(serials)} device(s): {', '.join(serials)}")
71
+ device = self._select_device(self._manager.devices())
72
+
73
+ console.info(f"Selected device: {device.describe()}")
74
+ console.info(f"Using Frida version: {version} (matched to host install).")
75
+
76
+ # 3. Root access up front — every later privileged step depends on it.
77
+ device.ensure_root()
78
+
79
+ # 4. Decide whether the on-device binary can be reused: the version must
80
+ # match AND its SHA-256 must match the digest we cached when we last
81
+ # deployed this version/arch. A mismatch (corrupt/tampered/foreign
82
+ # binary) forces a fresh deploy.
83
+ if not force and self._can_reuse(device, version):
84
+ console.info(f"frida-server v{version} already present — reusing it.")
85
+ return self._finish(device, port, ask_restart=True)
86
+
87
+ # 5. Download + push the matching binary, recording its checksum, then start.
88
+ with tempfile.TemporaryDirectory(prefix="frida-server-") as workdir:
89
+ binary = Downloader(console).download(version, device.arch, workdir)
90
+ digest = ChecksumStore.file_sha256(binary)
91
+ device.push_server(str(binary))
92
+ self._verify_pushed(device, digest)
93
+ self._checksums.set(version, device.arch, digest)
94
+
95
+ return self._finish(device, port)
96
+
97
+ def _can_reuse(self, device: Device, version: str) -> bool:
98
+ """Return True if the on-device server matches by version and checksum."""
99
+ console = self._console
100
+ existing = device.installed_server_version()
101
+ if existing is None:
102
+ console.info("frida-server not found on device.")
103
+ return False
104
+ if existing != version:
105
+ console.info(
106
+ f"On-device v{existing} differs from host v{version} — replacing."
107
+ )
108
+ return False
109
+
110
+ expected = self._checksums.get(version, device.arch)
111
+ if expected is None:
112
+ # No cached digest means we can't prove the on-device binary is
113
+ # genuine — re-deploy a fresh verified copy and establish the cache.
114
+ console.info(
115
+ "No cached checksum for this version — re-deploying to verify."
116
+ )
117
+ return False
118
+
119
+ actual = device.server_sha256()
120
+ if actual is None:
121
+ console.warn("Could not read on-device checksum — reusing by version.")
122
+ return True
123
+ if actual != expected:
124
+ console.warn(
125
+ f"On-device checksum mismatch (sha256:{actual[:12]}… vs "
126
+ f"{expected[:12]}…) — re-deploying."
127
+ )
128
+ return False
129
+ console.success(f"Checksum verified (sha256:{actual[:12]}…).")
130
+ return True
131
+
132
+ def _confirm_restart(self, pid: str) -> bool:
133
+ """Ask whether to restart an already-running server. Defaults to no."""
134
+ try:
135
+ answer = self._console.prompt(
136
+ f"frida-server is already running (PID {pid}). Restart? (y/N): "
137
+ ).lower()
138
+ except (KeyboardInterrupt, EOFError):
139
+ print()
140
+ return False
141
+ return answer == "y"
142
+
143
+ def _verify_pushed(self, device: Device, digest: str) -> None:
144
+ """Confirm the freshly pushed binary hashes to the expected digest."""
145
+ actual = device.server_sha256()
146
+ if actual is None:
147
+ return # sha256sum unavailable — cannot verify, but push reported OK
148
+ if actual == digest:
149
+ self._console.success(f"Pushed binary verified (sha256:{digest[:12]}…).")
150
+ else:
151
+ self._console.warn(
152
+ f"Pushed binary checksum mismatch (sha256:{actual[:12]}… vs "
153
+ f"{digest[:12]}…) — the transfer may be corrupt."
154
+ )
155
+
156
+ def _select_device(self, devices: list[Device]) -> Device:
157
+ """Return the chosen device, prompting the user when several are present."""
158
+ if len(devices) == 1:
159
+ return devices[0]
160
+
161
+ self._console.info("Multiple devices detected. Select one:\n")
162
+ for index, device in enumerate(devices, 1):
163
+ print(f" {index}. {device.describe()}")
164
+ print()
165
+
166
+ while True:
167
+ try:
168
+ choice = self._console.prompt(f"Select device (1-{len(devices)}): ")
169
+ idx = int(choice) - 1
170
+ if 0 <= idx < len(devices):
171
+ return devices[idx]
172
+ self._console.error(f"Enter a number between 1 and {len(devices)}.")
173
+ except ValueError:
174
+ self._console.error("Please enter a valid number.")
175
+ except (KeyboardInterrupt, EOFError):
176
+ self._console.info("Cancelled.")
177
+ sys.exit(0)
178
+
179
+ def _finish(self, device: Device, port: int, *, ask_restart: bool = False) -> bool:
180
+ """Start the server (or keep a running one), forward the port, and report.
181
+
182
+ When ``ask_restart`` is set and a ``frida-server`` is already running, the
183
+ user is asked whether to restart it. The default answer is *no* (an empty
184
+ line, EOF, or Ctrl-C all mean "keep the running server"), so the existing
185
+ process is left untouched and only the port forward is (re)applied.
186
+ """
187
+ console = self._console
188
+
189
+ if ask_restart:
190
+ running = device.running_pids()
191
+ if running and not self._confirm_restart(running[0]):
192
+ console.info("Keeping the running frida-server.")
193
+ device.forward_port(port)
194
+ console.success("Frida server is ready! You can now attach with Frida.")
195
+ return True
196
+
197
+ if device.start_server():
198
+ device.forward_port(port)
199
+ console.success("Frida server is ready! You can now attach with Frida.")
200
+ return True
201
+
202
+ console.error("frida-server may not have started correctly.")
203
+ log = device.read_server_log()
204
+ if log:
205
+ console.error(f"Server output:\n{log}")
206
+ console.error(
207
+ f"Try manually: adb -s {device.serial} shell "
208
+ "/data/local/tmp/frida-server (to see the error output)"
209
+ )
210
+ return False