diff --git a/docs/frida-instrumentation.md b/docs/frida-instrumentation.md new file mode 100644 index 000000000..c93c76465 --- /dev/null +++ b/docs/frida-instrumentation.md @@ -0,0 +1,13 @@ +# Frida instrumentation + +REA exposes Frida device and process discovery, attach or spawn sessions, and caller-authored JavaScript instrumentation through the CLI and MCP. The Node bindings are an optional package dependency and load only when a Frida operation runs. REA does not install Frida server on a target. + +For remote devices, pass a host with an optional port (`host[:port]`; bracket IPv6 addresses) and any token, certificate, origin, or keepalive options on that operation. The keepalive interval is measured in seconds, matching Frida Core 17.23.2. Authentication values are held only for the active call or session and are excluded from Evidence. Do not embed credentials in the endpoint address. + +MCP exposes `list_frida_devices`, `list_frida_processes`, `start_frida_session`, `load_frida_script`, `resume_frida_session`, `unload_frida_script`, `frida_session_status`, and `close_frida_session`. A spawned process starts paused so scripts can load first. Closing unloads session scripts and detaches. If a spawn remains paused, REA resumes it only after script cleanup or session detach confirms the instrumentation is released; if both cleanup paths fail, the target stays paused and the cleanup error is retained. REA never kills the target. MCP sessions end with the server process. The one-shot CLI command `instrument-with-frida` accepts one inline script or script file, observes messages for a bounded duration, and returns Evidence with the source hash and captured messages. + +Failed startup cleanup retains acquired Frida handles, including a paused spawn that has not attached, for later cleanup retries. Its remote connection stays leased until session detach and target resume complete. CLI commands await provider cleanup before returning; MCP retains these resources for cleanup when its server closes. A failed cleanup remains reported rather than treating an unregistered session as fully released. + +Frida scripts execute inside the selected target with that process's memory, filesystem, and network authority. Message capture is bounded to 16 MiB and reports truncation. Script source is not stored in Evidence; its selected path and SHA-256 digest are. Evidence retains non-authentication target selection such as device or endpoint, mode, program or PID, and spawn arguments. Authentication values remain excluded. Results report the observed session and messages, while unsupported platform metadata remains unknown. A request cancelled before a Frida operation starts has no provider effects. If cancellation arrives during a native Frida call, REA waits for that call to return, then closes any session it created and returns the observed session as a partial observation. Cancellation Evidence separately reports whether a target resume was observed, whether instrumentation cleanup completed, and that final target liveness remains unknown. REA does not kill the target. Remote discovery retains its observations and reports a separate cleanup error if the temporary connection cannot be removed. + +The [Frida Core API](https://frida.re/docs/frida-core-api/) documents device management, sessions, and script loading; the [JavaScript API](https://frida.re/docs/javascript-api/) documents target-side instrumentation. [Luma](https://luma.frida.re/) is Frida's standalone GUI; REA does not automate or control it. `npm run verify:frida` requires a Linux host with the optional Frida binding and uses a short-lived local Node.js fixture created by the verifier. Add `-- --address HOST:PORT --pid PID` to run the separate remote attach lane against a caller-selected target. Mock tests do not establish real device/server compatibility. diff --git a/docs/index.md b/docs/index.md index 42ae8f680..17fed2a95 100644 --- a/docs/index.md +++ b/docs/index.md @@ -43,4 +43,6 @@ npx -y rea-agents@latest analyze-javascript-application /absolute/path/to/app -- See [installation and setup](./installation.md) for requirements and configuration. +For runtime instrumentation, see the [Frida guide](./frida-instrumentation.md). + ![REA inspecting a native binary in Hopper](./assets/rea-hopper-analysis.png) diff --git a/docs/testing.md b/docs/testing.md index 9aaeb8464..5df9c4d9e 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1414,6 +1414,18 @@ npm run evidence:check The records preserve unsupported and unverified coverage as explicit unknowns. Run the matching real-tool prerequisites described in this guide. +## Frida instrumentation + +`npm run verify:frida` builds REA, spawns a short-lived local Node.js fixture, +loads a harmless marker script before resuming it, captures the message, and +detaches. This lane requires Linux on the REA host and the optional Frida +binding; it does not need a Frida server or a caller-selected process. To run +the separate remote attach lane, pass `-- --address HOST:PORT --pid PID` for an +explicitly selected process. Optional remote flags are `--token`, +`--certificate`, `--origin`, and `--keepalive-interval` (seconds); authentication values +are not written to files or included in the report. Mock tests do not establish +real device/server compatibility. + ### Offline WASM artifacts `npm run verify:wasm:artifact -- --require-tools` uses an absolute diff --git a/knip.json b/knip.json index 8d38b4ef1..72d093397 100644 --- a/knip.json +++ b/knip.json @@ -7,6 +7,7 @@ "src/contracts/errorSchemas.ts", "src/evaluation/AgentEvaluationFixtures.ts", "src/application/RealHopperAssertions.ts", + "scripts/verify/fridaBindingsCompatibility.ts", "scripts/verify/termux/browser.mjs", "scripts/verify-real-ghidra-noreturn.mjs", "tests/processOwnershipRunner.ts" diff --git a/package-lock.json b/package-lock.json index de9c141f5..749f4614f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -73,6 +73,9 @@ }, "engines": { "node": "^22.19.0 || ^24.11.0 || >=26.0.0" + }, + "optionalDependencies": { + "frida": "17.23.2" } }, "node_modules/@algolia/abtesting": { @@ -742,9 +745,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -903,9 +903,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -923,9 +920,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -943,9 +937,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -963,9 +954,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -983,9 +971,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1003,9 +988,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1023,9 +1005,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1043,9 +1022,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1239,9 +1215,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1256,9 +1229,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1273,9 +1243,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1290,9 +1257,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1307,9 +1271,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1324,9 +1285,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1341,9 +1299,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1358,9 +1313,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1878,9 +1830,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1898,9 +1847,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1918,9 +1864,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1938,9 +1881,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1958,9 +1898,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1978,9 +1915,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1998,9 +1932,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2018,9 +1949,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2214,9 +2142,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2234,9 +2159,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2254,9 +2176,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2274,9 +2193,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2294,9 +2210,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2314,9 +2227,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2476,9 +2386,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2493,9 +2400,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2510,9 +2414,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2527,9 +2428,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2544,9 +2442,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2561,9 +2456,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2578,9 +2470,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2595,9 +2484,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2612,9 +2498,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2629,9 +2512,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2646,9 +2526,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2663,9 +2540,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2680,9 +2554,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4142,6 +4013,16 @@ ], "license": "MIT" }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, "node_modules/birpc": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", @@ -4152,6 +4033,58 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "optional": true, + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bl/node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/bl/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "optional": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/brace-expansion": { "version": "5.0.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", @@ -4290,6 +4223,13 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC", + "optional": true + }, "node_modules/clean-git-ref": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/clean-git-ref/-/clean-git-ref-2.0.1.tgz", @@ -4418,6 +4358,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -4449,9 +4399,8 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8" } @@ -4513,6 +4462,16 @@ "dev": true, "license": "MIT" }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "optional": true, + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/entities": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", @@ -4644,6 +4603,16 @@ "node": ">=22" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "optional": true, + "engines": { + "node": ">=6" + } + }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -4751,6 +4720,13 @@ } } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT", + "optional": true + }, "node_modules/focus-trap": { "version": "7.8.0", "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.8.0.tgz", @@ -4793,6 +4769,29 @@ "node": ">=18.3.0" } }, + "node_modules/frida": { + "version": "17.23.2", + "resolved": "https://registry.npmjs.org/frida/-/frida-17.23.2.tgz", + "integrity": "sha512-gRCmmTUFdEILyHRN1b66erRoXafwsfWTqXyoBBqkSCFX0Ue7Jhvs/KhA3JO0XrgITNlPd91QAGsFvWzh0mhVgg==", + "hasInstallScript": true, + "license": "LGPL-2.0 WITH WxWindows-exception-3.1", + "optional": true, + "dependencies": { + "bindings": "^1.5.0", + "minimatch": "^10.0.1", + "prebuild-install": "^7.1.3" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT", + "optional": true + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -4880,6 +4879,13 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT", + "optional": true + }, "node_modules/glob": { "version": "13.0.6", "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", @@ -5119,6 +5125,13 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC", + "optional": true + }, "node_modules/is-callable": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", @@ -5555,9 +5568,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5580,9 +5590,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5605,9 +5612,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5630,9 +5634,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -6070,6 +6071,13 @@ "dev": true, "license": "MIT" }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT", + "optional": true + }, "node_modules/nanoid": { "version": "3.3.20", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", @@ -6089,6 +6097,26 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT", + "optional": true + }, + "node_modules/node-abi": { + "version": "3.98.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.98.0.tgz", + "integrity": "sha512-Llh8noNw7s15m+rmIrZnZQbe4gLyasL9loT74rC92PkmwWKeEedKvc7o6pjp8B3MhrYT/Cl846NOYZbz1wmddw==", + "license": "MIT", + "optional": true, + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/obug": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", @@ -6554,6 +6582,34 @@ } } }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/process": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", @@ -6590,6 +6646,17 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "optional": true, + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/pure-rand": { "version": "8.4.2", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz", @@ -6613,6 +6680,32 @@ "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", "license": "MIT" }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "optional": true, + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/readable-stream": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", @@ -7178,6 +7271,51 @@ "dev": true, "license": "MIT" }, + "node_modules/tar-fs": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", + "license": "MIT", + "optional": true, + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tar-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "optional": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/thread-stream": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", @@ -7286,6 +7424,19 @@ "license": "0BSD", "optional": true }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/turbo": { "version": "2.11.7", "resolved": "https://registry.npmjs.org/turbo/-/turbo-2.11.7.tgz", @@ -7458,6 +7609,13 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT", + "optional": true + }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", diff --git a/package.json b/package.json index da611da56..0393f84c3 100644 --- a/package.json +++ b/package.json @@ -181,6 +181,8 @@ "verify:ghidra:cross-format": "npm run build:cached && node scripts/verify-real-ghidra.mjs --cross-format", "verify:ghidra:windows": "npm run build:cached && npm run build:ghidra-windows-fixture && node scripts/verify-real-ghidra-windows.mjs", "verify:browser": "npm run build:cached && node scripts/verify-real-browser.mjs", + "verify:frida": "npm run build:cached && node scripts/verify-frida.mjs", + "verify:frida:bindings": "tsc --noEmit -p tsconfig.frida-verification.json", "verify:browser:dom": "node scripts/verify-browser-dom-destinations.mjs", "verify:browser:network": "npm run build:cached && node scripts/verify-browser-network.mjs", "verify:electron": "npm run build:cached && node scripts/verify-real-electron.mjs", @@ -341,5 +343,11 @@ "typescript-termux": "npm:typescript@5.9.3", "vitepress": "1.6.4", "vitest": "5.0.3" + }, + "optionalDependencies": { + "frida": "17.23.2" + }, + "allowScripts": { + "frida@17.23.2": true } } diff --git a/scripts/lib/catalog-builders.mjs b/scripts/lib/catalog-builders.mjs index f9e0c0cef..787d6255d 100644 --- a/scripts/lib/catalog-builders.mjs +++ b/scripts/lib/catalog-builders.mjs @@ -49,6 +49,11 @@ export const toolFamilyCatalog = (sources) => { surface: "android-provider", contracts: sources.androidContracts.ANDROID_TOOL_CONTRACTS, }, + { + id: "frida", + surface: "instrumentation-provider", + contracts: sources.fridaContracts.FRIDA_TOOL_CONTRACTS, + }, { id: "apktool", surface: "apktool-provider", @@ -145,6 +150,10 @@ export const providerCatalog = (sources) => { activeContracts, } = electronContractSlices(sources); return [ + { + identity: sources.fridaProvider.FRIDA_PROVIDER_IDENTITY, + contracts: sources.fridaContracts.FRIDA_TOOL_CONTRACTS, + }, { identity: sources.hopperProvider.HOPPER_PROVIDER_IDENTITY, contracts: declaredContracts( diff --git a/scripts/lib/catalog-core.mjs b/scripts/lib/catalog-core.mjs index 56ffd544c..546b7359c 100644 --- a/scripts/lib/catalog-core.mjs +++ b/scripts/lib/catalog-core.mjs @@ -43,6 +43,8 @@ const SOURCE_PATHS = { sessionContracts: "dist/contracts/sessionToolContracts.js", binaryDiagnosticsContracts: "dist/contracts/native/binaryDiagnosticsToolContracts.js", + fridaContracts: "dist/contracts/fridaToolContracts.js", + fridaProvider: "dist/frida/FridaInstrumentationManager.js", pwntoolsProvider: "dist/native/pwntools/PwntoolsRelease.js", nativeContracts: "dist/contracts/native/nativeToolContracts.js", wasmContracts: "dist/contracts/wasm/wasmToolContracts.js", diff --git a/scripts/verify-frida.mjs b/scripts/verify-frida.mjs new file mode 100644 index 000000000..ece121a14 --- /dev/null +++ b/scripts/verify-frida.mjs @@ -0,0 +1,198 @@ +import { basename } from "node:path"; + +import { FridaInstrumentationService } from "../dist/application/frida/FridaInstrumentationService.js"; +import { FridaInstrumentationManager } from "../dist/frida/FridaInstrumentationManager.js"; +import { isValidFridaRemoteAddress } from "../dist/contracts/fridaRemoteAddress.js"; + +const options = parseArguments(process.argv.slice(2)); +if (options.help) { + process.stdout.write( + "Usage: npm run verify:frida [-- --address HOST:PORT --pid PID [remote auth flags]]\n" + + "Without flags, verifies a local Node.js process created by this script.\n", + ); + process.exit(0); +} + +const manager = new FridaInstrumentationManager(); +const service = new FridaInstrumentationService(manager); +const remote = + options.address === undefined + ? undefined + : { + address: options.address, + ...(options.token === undefined ? {} : { token: options.token }), + ...(options.certificate === undefined + ? {} + : { certificate: options.certificate }), + ...(options.origin === undefined ? {} : { origin: options.origin }), + ...(options.keepaliveInterval === undefined + ? {} + : { keepaliveInterval: options.keepaliveInterval }), + }; + +try { + let targetProcess; + let input; + if (remote === undefined) { + input = { + mode: "spawn", + deviceId: "local", + program: process.execPath, + argv: ["-e", "setTimeout(() => {}, 1500)"], + source: { + sourceKind: "inline", + source: "send({ rea_frida_verification: 'ok', pid: Process.id });", + }, + durationMs: 500, + }; + targetProcess = { pid: undefined, name: basename(process.execPath) }; + } else { + const inventory = await service.listProcesses({ remote }); + if (!inventory.ok) throw new Error(inventory.error.message); + targetProcess = inventory.value.processes.find( + ({ pid }) => pid === options.pid, + ); + if (targetProcess === undefined) + throw new Error( + `Selected PID ${String(options.pid)} was not observed remotely`, + ); + input = { + mode: "attach", + remote, + pid: options.pid, + source: { + sourceKind: "inline", + source: "send({ rea_frida_verification: 'ok', pid: Process.id });", + }, + durationMs: 500, + }; + } + + const result = await service.instrument(input); + if (!result.ok) throw new Error(result.error.message); + const observed = result.value.evidence.normalized_result; + if ( + typeof observed !== "object" || + observed === null || + Array.isArray(observed) || + !Array.isArray(observed.messages) + ) + throw new Error("Frida returned no message observation"); + const messageObserved = observed.messages.some( + (message) => + typeof message === "object" && + message !== null && + !Array.isArray(message) && + message.type === "send" && + typeof message.payload === "object" && + message.payload !== null && + !Array.isArray(message.payload) && + message.payload.rea_frida_verification === "ok" && + message.payload.pid === observed.pid, + ); + if (!messageObserved) + throw new Error("Selected target did not return the Frida marker"); + const report = { + ok: result.value.cleanupError === null, + provider: "frida", + host_platform: process.platform, + target_mode: remote === undefined ? "local_spawn" : "remote_attach", + target_pid: observed.pid, + target_name: targetProcess.name, + ...(remote === undefined ? {} : { remote_target_observed: true }), + script_message_observed: true, + cleanup_error: result.value.cleanupError, + }; + process.stdout.write(`${JSON.stringify(report)}\n`); + if (!report.ok) process.exitCode = 1; +} catch (cause) { + process.stderr.write( + `Frida real-provider verification failed: ${cause instanceof Error ? cause.message : String(cause)}\n`, + ); + process.exitCode = 1; +} finally { + await manager.closeAll().catch(() => undefined); +} + +function parseArguments(args) { + if (args.length === 1 && args[0] === "--help") return { help: true }; + const values = parseOptionValues(args); + const address = values.get("--address"); + const pidValue = values.get("--pid"); + const pid = parseRemotePid(address, pidValue); + if (address === undefined) assertNoRemoteOptions(values); + const keepaliveInterval = parseKeepalive(values.get("--keepalive-interval")); + return { + help: false, + ...(address === undefined ? {} : { address }), + ...(pid === undefined ? {} : { pid }), + ...(values.has("--token") ? { token: values.get("--token") } : {}), + ...(values.has("--certificate") + ? { certificate: values.get("--certificate") } + : {}), + ...(values.has("--origin") ? { origin: values.get("--origin") } : {}), + ...(keepaliveInterval === undefined ? {} : { keepaliveInterval }), + }; +} + +function parseOptionValues(args) { + const values = new Map(); + const valueFlags = new Set([ + "--address", + "--pid", + "--token", + "--certificate", + "--origin", + "--keepalive-interval", + ]); + for (let index = 0; index < args.length; index += 1) { + const flag = args[index]; + const value = args[index + 1]; + if ( + typeof flag !== "string" || + !valueFlags.has(flag) || + value === undefined || + values.has(flag) + ) + throw new Error( + "Expected no arguments for local-fixture verification, or --address HOST:PORT --pid PID and optional remote auth flags", + ); + values.set(flag, value); + index += 1; + } + return values; +} + +function parseRemotePid(address, pidValue) { + if ((address === undefined) !== (pidValue === undefined)) + throw new Error("Remote verification requires both --address and --pid"); + if (address !== undefined && !isValidFridaRemoteAddress(address)) + throw new Error( + "--address must be a host[:port] without credentials, path, query, or fragment", + ); + const pid = pidValue === undefined ? undefined : Number(pidValue); + if (pid !== undefined && (!Number.isSafeInteger(pid) || pid <= 0)) + throw new Error("--pid must be a positive process identifier"); + return pid; +} + +function assertNoRemoteOptions(values) { + for (const flag of [ + "--token", + "--certificate", + "--origin", + "--keepalive-interval", + ]) + if (values.has(flag)) + throw new Error("Remote connection options require --address and --pid"); +} + +function parseKeepalive(value) { + const interval = value === undefined ? undefined : Number(value); + if ( + interval !== undefined && + (!Number.isSafeInteger(interval) || interval <= 0) + ) + throw new Error("--keepalive-interval must be a positive integer"); + return interval; +} diff --git a/scripts/verify/fridaBindingsCompatibility.ts b/scripts/verify/fridaBindingsCompatibility.ts new file mode 100644 index 000000000..cb156efb6 --- /dev/null +++ b/scripts/verify/fridaBindingsCompatibility.ts @@ -0,0 +1,28 @@ +import type { + Device as VendorDevice, + DeviceManager as VendorDeviceManager, + Script as VendorScript, + Session as VendorSession, +} from "../../node_modules/frida/build/src/frida_binding.js"; +import type { Device, DeviceManager, Script, Session } from "frida"; + +type Assert = Condition; + +type VendorDeviceManagerFitsShim = Assert< + VendorDeviceManager extends DeviceManager ? true : false +>; +type VendorDeviceFitsShim = Assert; +type VendorSessionFitsShim = Assert< + VendorSession extends Session ? true : false +>; +type VendorScriptFitsShim = Assert; + +// These aliases intentionally fail when the pinned Frida API narrows incompatibly. +const compatibility: readonly [ + VendorDeviceManagerFitsShim, + VendorDeviceFitsShim, + VendorSessionFitsShim, + VendorScriptFitsShim, +] = [true, true, true, true]; + +void compatibility; diff --git a/src/application/CapabilityInventory.ts b/src/application/CapabilityInventory.ts index 7c9d3910c..76c5cafbb 100644 --- a/src/application/CapabilityInventory.ts +++ b/src/application/CapabilityInventory.ts @@ -22,6 +22,7 @@ import type { JsonValue } from "../domain/jsonValue.js"; import { isMachOFormat } from "../domain/binaryTargetTypes.js"; import type { ProviderAvailability } from "./AnalysisProvider.js"; import { TOOL_CONTRACTS } from "../contracts/toolContracts.js"; +import { FRIDA_TOOL_CONTRACTS } from "../contracts/fridaToolContracts.js"; import { clientRequirementsFor, NO_CLIENT_FEATURES, @@ -131,6 +132,9 @@ const ACTIVE_TARGET_KINDS: ReadonlySet = new Set([ "native-provider", "artifact-provider", ]); +const FRIDA_OPERATION_NAMES: ReadonlySet = new Set( + FRIDA_TOOL_CONTRACTS.map(({ name }) => name), +); /** Native contracts defined only for an active Mach-O executable. */ const MACHO_TARGET_OPERATIONS: ReadonlySet = new Set([ @@ -316,6 +320,7 @@ const optionalProviderFailureFor = ({ if (kind === "electron-provider") return failures?.electronObservation; if (kind === "runtime-provider") return failures?.javascriptRuntimeObservation; + if (FRIDA_OPERATION_NAMES.has(name)) return failures?.fridaInstrumentation; return undefined; }; @@ -533,6 +538,7 @@ const targetAvailability = ({ targetFormat, targetOpen, }: AvailabilityContext): Availability | null => { + if (FRIDA_OPERATION_NAMES.has(name)) return null; if (!targetOpen && ACTIVE_TARGET_KINDS.has(kind)) return { reason: "target_required", diff --git a/src/application/OptionalObservationProviders.ts b/src/application/OptionalObservationProviders.ts index d66554966..499925a7c 100644 --- a/src/application/OptionalObservationProviders.ts +++ b/src/application/OptionalObservationProviders.ts @@ -22,7 +22,10 @@ export interface OptionalProviderLoadFailure { export type OptionalProviderLoadFailures = Readonly< Partial< - Record + Record< + keyof OptionalObservationProviders | "fridaInstrumentation", + OptionalProviderLoadFailure + > > >; diff --git a/src/application/frida/FridaInstrumentationManager.test.ts b/src/application/frida/FridaInstrumentationManager.test.ts new file mode 100644 index 000000000..8c315c0d5 --- /dev/null +++ b/src/application/frida/FridaInstrumentationManager.test.ts @@ -0,0 +1,988 @@ +import { describe, expect, it } from "vitest"; + +import type { + Device, + DeviceManager, + FridaMessage, + Process, + Script, + Session, + Signal, +} from "frida"; +import { FridaInstrumentationManager } from "../../frida/FridaInstrumentationManager.js"; + +const signal = void>() => { + let connected: Handler | undefined; + const value: Signal & { + emit: (...args: Parameters) => void; + } = { + connect: (handler) => { + connected = handler; + }, + disconnect: (handler) => { + if (connected === handler) connected = undefined; + }, + emit: (...args) => connected?.(...args), + }; + return value; +}; + +class FakeScript implements Script { + readonly message = + signal<(message: FridaMessage, data: Buffer | null) => void>(); + isDestroyed = false; + loadFailure: Error | undefined; + unloadFailureCount = 0; + unloadCalls = 0; + + constructor( + readonly payload: unknown = { event: "loaded" }, + readonly beforeLoad: () => Promise = async () => {}, + readonly description = "script loaded", + readonly stack = "", + ) {} + + async load(): Promise { + await this.beforeLoad(); + if (this.loadFailure !== undefined) throw this.loadFailure; + this.message.emit( + { + type: "send", + payload: this.payload, + description: this.description, + stack: this.stack, + }, + null, + ); + } + + async unload(): Promise { + this.unloadCalls += 1; + if (this.unloadFailureCount > 0) { + this.unloadFailureCount -= 1; + throw new Error("script unload failed"); + } + if (this.isDestroyed) throw new Error("Script is destroyed"); + this.isDestroyed = true; + } +} + +class FakeSession implements Session { + readonly detached = signal<() => void>(); + readonly scripts: FakeScript[] = []; + nextPayload: unknown = { event: "loaded" }; + nextDescription = "script loaded"; + nextStack = ""; + nextLoadFailure: Error | undefined; + nextUnloadFailureCount = 0; + detachFailureCount = 0; + scriptLoadGate: Promise | undefined; + onScriptLoadStarted: (() => void) | undefined; + private detachedState = false; + readonly pid = 512; + + isDetached(): boolean { + return this.detachedState; + } + + async createScript(): Promise