Frida Instrumentation with REA: Hook Your Own Native Program
On this page
Short answer: REA 6.7.0, the open-source project by morluto, ships Frida support as three CLI commands (list-frida-devices, list-frida-processes, instrument-with-frida) and nine MCP tools. It doesn’t replace Frida. You still write the JavaScript yourself. What REA adds is a bounded run: it attaches or spawns, loads one script, collects every send() message as JSON for a fixed number of milliseconds, detaches, and returns the result as an Evidence record that includes the script’s SHA-256. In our tests that worked well against our own C program on an Android emulator. On the Mac itself it did not work at all, and neither did plain Frida. Both stories are below.
This guide is for developers who want to watch their own native code run, either from a terminal or through an AI agent over MCP. You’ll compile a small C program, hook one of its functions, run the same hook with REA and with frida-tools, and see where the two differ.
Only instrument processes you own or have written permission to test. A Frida script runs with the full authority of the target process and can read and change its memory. Read REA’s DISCLAIMER.md before you start. Everything here was run against a program we wrote, on an emulator we created for this test.
What we tested with
- REA
[email protected]from npm, which pins the optional dependencyfridato17.23.2 - frida-server 17.23.2 (android-arm64) and frida-tools 14.11.0 with Python 3.14.7, for the cross-check
- macOS 27.0.1 on Apple silicon, Node.js 22.22.3, npm 10.9.8, Apple clang 21.0.0
- Android emulator 37.1.11.0 with an Android 14 (API 34)
google_apisarm64-v8a image - Android NDK 27.1.12297006 (clang 18.0.2) to cross-compile the target
Install and look for devices
We installed REA into an empty directory instead of globally, so the Frida binding it pulls in was easy to inspect:
mkdir rea-frida && cd rea-frida
npm init -y
npm i [email protected]
npx rea --version # 6.7.0
package.json in the release lists "optionalDependencies": {"frida": "17.23.2"} and allows its install script. On this Mac the install finished without errors, and node_modules/frida/build/frida_binding.node was there afterwards. If that file is missing, the Frida commands have nothing to talk to.
$ npx rea list-frida-devices
devices[3]{deviceId,name,type}:
local,Local System,local
socket,Local Socket,remote
barebone,GDB Remote Stub,remote
cleanup_error: null
The default output is REA’s compact “toon” format. Add --format json (or the global --json) when you want to pipe it somewhere. The command took 1.3 s. When we ran it a second time a few seconds later, it listed a fourth device, a booted iOS Simulator (iPhone 17, type remote). The list reflects what Frida enumerates at that moment, so don’t assume two consecutive runs return the same thing.
The target: a program we wrote
tally.c opens a log file, scores a word and appends the result, five times a second. score_word is the function we want to watch.
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
/* Sum of letter positions. Not static, so the symbol stays in the binary. */
__attribute__((noinline)) int score_word(const char *word) {
int total = 0;
for (const char *p = word; *p; p++)
if (*p >= 'a' && *p <= 'z') total += *p - 'a' + 1;
return total;
}
int main(int argc, char **argv) {
const char *words[] = {"frida", "rea", "hook", "trace"};
int rounds = argc > 1 ? atoi(argv[1]) : 600;
printf("tally pid=%d rounds=%d\n", getpid(), rounds);
fflush(stdout);
for (int i = 0; i < rounds; i++) {
const char *w = words[i % 4];
int fd = open("tally.log", O_WRONLY | O_CREAT | O_APPEND, 0644);
dprintf(fd, "%s=%d\n", w, score_word(w));
close(fd);
usleep(200000);
}
return 0;
}
The Frida script, trace.js, finds score_word, reads its string argument on entry and reports the return value on exit:
const score = Process.getModuleByName('tally').getExportByName('score_word');
Interceptor.attach(score, {
onEnter(args) {
this.word = args[0].readUtf8String();
},
onLeave(retval) {
send({ fn: 'score_word', word: this.word, ret: retval.toInt32() });
}
});
send({ ready: true, pid: Process.id, arch: Process.arch, score_word: score.toString() });
Everything the script wants REA to record has to go through send(). REA collects those messages. It doesn’t capture the target’s stdout, so the printf line never shows up in REA’s output.
First attempt: the local Mac
We started where most people would, with the native macOS build (cc -O1 -g -o tally tally.c, which produces an ad-hoc, linker-signed arm64 binary).
The first error came from a relative path:
$ npx rea instrument-with-frida spawn ./tally --argument 8 --script-file trace.js --duration-ms 3000 --format json
{"code":"capability_unavailable", ... "reason":"Unable to find application with identifier './tally'"}
Plain Frida does the same thing: frida -f ./tally printed Failed to spawn: unable to find application with identifier './tally'. Frida treated the string as an app identifier, not a file path. An absolute path got past that check:
$ npx rea instrument-with-frida spawn "$PWD/tally" --argument 8 --script-file trace.js --duration-ms 3000 --format json
{"code":"cleanup_incomplete","category":"execution_failure", ...
"reason":"Timeout was reached",
"cleanup_reason":"Unable to resume spawned target: Timeout was reached", ...}
That call took 1 minute 40 seconds to fail, and it left something behind. ps showed our tally process still alive in state T (stopped), and a frida-helper process from ~/.cache/frida/ was still running. REA reported this honestly, with cleanup_incomplete and a non-zero exit code, but you have to clean up yourself:
ps -o pid,stat,etime,command -p <pid> # STAT "T" = stopped, never resumed
kill <pid>
pgrep -fl frida-helper # kill leftovers too
Attaching to a tally that was already running failed the same way with "reason":"Timeout was reached". To check whether REA was at fault, we tried plain Frida 17.23.2 from the same shell. frida -p <pid> -l trace.js printed Failed to attach: timeout was reached after 51 s, and its frida-helper stayed behind as well. So the cause is the host, not REA. On this macOS build, with SIP enabled and DevToolsSecurity -status reporting developer mode enabled, a non-root Frida couldn’t get hold of the task port of a binary we had just compiled ourselves. We didn’t try running as root or re-signing the binary on this machine, so we can’t say which of those would fix it. Our takeaway: on a recent macOS, expect local instrumentation to need setup beyond npm i, and check plain frida first. REA can’t do anything Frida itself can’t.
Moving to an Android emulator we control
An emulator gave us a target where we hold root. That’s exactly what frida-server needs. We created a fresh AVD from the google_apis image (we picked it over the Play Store image because we needed adb root), booted it without a window and pushed the matching server:
avdmanager create avd -n rea_frida_api34 \
-k "system-images;android-34;google_apis;arm64-v8a" -d pixel_6
emulator -avd rea_frida_api34 -no-window -no-audio -no-snapshot &
adb wait-for-device && adb root
curl -LO https://github.com/frida/frida/releases/download/17.23.2/frida-server-17.23.2-android-arm64.xz
xz -d frida-server-17.23.2-android-arm64.xz
adb push frida-server-17.23.2-android-arm64 /data/local/tmp/frida-server
adb shell chmod 755 /data/local/tmp/frida-server
adb shell '/data/local/tmp/frida-server -D'
The server version should match the frida package REA installed (17.23.2 here). REA doesn’t install frida-server for you.
The same tally.c was cross-compiled with the NDK. -rdynamic matters here. Without it, an Android executable doesn’t put score_word in its dynamic symbol table. We tried a build without the flag and got Error: /data/local/tmp/tally-nordyn: unable to find export 'score_word'. That error arrived as a message with "type": "error" (plus a stack and line number), and the command still exited 0. A failing script isn’t a failing command, so check messages for type: error before you trust an empty result.
$NDK/toolchains/llvm/prebuilt/darwin-x86_64/bin/aarch64-linux-android34-clang \
-O1 -g -rdynamic -o tally-android tally.c
llvm-nm -D tally-android | grep score # 0000000000001a70 T score_word
adb push tally-android /data/local/tmp/tally
adb shell chmod 755 /data/local/tmp/tally
Now the emulator showed up as a USB device:
$ npx rea list-frida-devices | grep usb
emulator-5554,Android Emulator 5554,usb
Spawn: hook from the first instruction
In spawn mode, Frida starts the program paused, REA loads the script, and only then is the program resumed. So the hook is already in place before main runs. --argument values are passed after the program path (REA builds argv as [program, ...arguments]):
npx rea instrument-with-frida spawn /data/local/tmp/tally \
--device-id emulator-5554 --argument 12 \
--script-file trace.js --duration-ms 2000 --format json > spawn1.json
It exited with status 0 after 2.6 s of wall time. Trimmed:
{
"evidence": {
"predicate_type": "rea.frida.instrumentation-observation",
"operation": "instrument_with_frida",
"provider": { "id": "frida", "name": "Frida", "version": null },
"parameters": { "device_id": "emulator-5554", "mode": "spawn",
"program": "/data/local/tmp/tally", "argv": ["12"] },
"normalized_result": {
"state": "running",
"pid": 4950,
"messages": [
{ "type": "send", "payload": { "ready": true, "pid": 4950, "arch": "arm64",
"score_word": "0x5ede11da70" } },
{ "type": "send", "payload": { "fn": "score_word", "word": "frida", "ret": 38 } },
{ "type": "send", "payload": { "fn": "score_word", "word": "rea", "ret": 24 } },
{ "type": "send", "payload": { "fn": "score_word", "word": "hook", "ret": 49 } },
{ "type": "send", "payload": { "fn": "score_word", "word": "trace", "ret": 47 } }
],
"messages_truncated": false,
"source_kind": "file",
"source_path": "trace.js",
"source_sha256": "425a2db5c84d1bd9a7460f5e1297aa7cbc719d64092e007a2b5aacb35595c838",
"cleanup_error": null
},
"confidence": "observed",
"limitations": [
"Artifact identity is unavailable for this observation.",
"The target operating system and architecture were not reported by this observation."
]
},
"cleanup_error": null
}
The full file held 11 messages: the ready message plus 10 calls in 2000 ms, which matches the 200 ms loop. Each message also carries a script_id, removed above. The values are correct (f+r+i+d+a = 6+18+9+4+1 = 38). source_sha256 is identical to shasum -a 256 trace.js, which is the useful part when you keep Evidence: later you can prove which script produced which observation. provider.version came back null even though the Frida version is known, and the limitations say outright that the target OS and architecture weren’t recorded. Your own Process.arch in the payload fills that gap.
When the window ends, REA detaches and doesn’t kill the target. We spawned with --argument 600 and a 1000 ms window, and adb shell pidof tally still printed the PID after the command returned.
Attach: a process that’s already running
First find the PID. list-frida-processes defaults to --device-id local, so name the emulator:
$ npx rea list-frida-processes --device-id emulator-5554 | grep -E 'tally|processes\['
processes[145]{pid,name,identifier}:
4992,tally,null
Then attach for one second. --filter-output keeps the output down to the fields you care about:
$ npx rea instrument-with-frida attach 4992 --device-id emulator-5554 \
--script-file trace.js --duration-ms 1000 \
--filter-output evidence.normalized_result.messages,evidence.normalized_result.state
evidence:
normalized_result:
messages[5]:
- script_id: 0ddadc25-480b-417b-8ce6-ea2b768fd3e8
type: send
payload:
ready: true
pid: 4992
arch: arm64
score_word: 0x558a1eca70
- ...
payload:
fn: score_word
word: frida
ret: 38
state: running
Five messages in 1.49 s of wall time. In attach mode you see only what happens after the hook lands, so a function that runs once at startup will never show up. Use spawn for those. --argument is rejected in attach mode with an explicit error.
Cross-check with frida-tools
We pointed the same script and PID at plain Frida:
$ frida -D emulator-5554 -p 4992 -l trace.js -q -t 1
message: {'type': 'send', 'payload': {'ready': True, 'pid': 4992, 'arch': 'arm64', 'score_word': '0x558a1eca70'}} data: None
message: {'type': 'send', 'payload': {'fn': 'score_word', 'word': 'frida', 'ret': 38}} data: None
...
Same address, same values, six messages instead of five. -t 1 and --duration-ms 1000 don’t start their clocks at the same moment, so a one-message difference at 200 ms intervals is expected. frida-ps -D emulator-5554 listed the same 145 processes, with tally at PID 4992.
frida-trace is where plain Frida does something REA doesn’t. Without writing any code, it generated handlers for both functions and printed a timeline:
$ frida-trace -D emulator-5554 -p 4992 -i 'score_word' -i 'open'
Started tracing 2 functions. Web UI available at http://localhost:61272/
/* TID 0x1380 */
186 ms open(path="tally.log", oflag=0x441, ...)
186 ms score_word()
391 ms open(path="tally.log", oflag=0x441, ...)
391 ms score_word()
REA 6.7.0 has no handler generation and no glob-based tracing. You bring the script. In exchange you get one structured JSON document per run instead of a scrolling terminal. To explore, start with frida-trace. To record something you’ll compare later or hand to an agent, use REA.
The MCP session tools
The one-shot command is fine for “watch for N ms”. For an agent that wants to look, then decide, REA’s MCP server (rea mcp) has session tools with no CLI equivalent. We drove it from a short Node.js client that speaks JSON-RPC over stdio. tools/list returned 207 tools, nine of them Frida:
list_frida_devices, list_frida_processes, start_frida_session, load_frida_script,
resume_frida_session, unload_frida_script, frida_session_status,
close_frida_session, instrument_with_frida
The sequence we ran against the emulator, with the time each call took:
start_frida_session {mode: "spawn", device_id: "emulator-5554",
program: "/data/local/tmp/tally", argv: ["600"]} 99 ms state: "paused", pid 5132
load_frida_script {session_id, source_kind: "file", path: ".../trace.js"} 7 ms 1 message (ready)
frida_session_status {session_id} 2 ms state: "paused"
resume_frida_session {session_id} 2 ms state: "running"
(wait 1.5 s)
frida_session_status {session_id} 1 ms ready + 8 score_word calls
unload_frida_script {session_id, script_id} 3 ms unloaded: true
close_frida_session {session_id} 2 ms target_state: "unknown"
Two details from that run. frida_session_status returns the whole message history, not just what’s new: the second status call included the ready message again. If you poll it in a loop, deduplicate on your side. And close_frida_session really does report target_state: "unknown". REA makes no claim about the process after detaching. On the emulator, pidof tally showed it was still running.
Discrepancies we noticed
- Key casing in the CLI.
list-frida-devicesprintsdeviceId, whilelist-frida-processesand every MCP result usedevice_id. The MCP handler renames the field, and the CLI command passes the internal object through as is. Scripts that parse both commands need to handle both spellings. - Docs not in the package. The npm README doesn’t mention Frida, and the tarball has no
docs/folder. The design notes are in docs/frida-instrumentation.md on GitHub, which tracksmainand may be ahead of 6.7.0. We used--helpand the release’sdist/as the reference. - Script errors don’t fail the command. A script that throws still exits 0. The error is only in
messages.
Troubleshooting
Unable to find application with identifier './tally'. Pass an absolute path to spawn mode.
Timeout was reached on a local macOS target. Check that plain frida -p <pid> works first. If it times out too, the problem is the host’s process permissions, not REA. Then look for a stopped target and a stray frida-helper and kill them.
A type: error message saying unable to find export 'score_word', with exit code 0. The function isn’t in the dynamic symbol table. Check with llvm-nm -D and rebuild with -rdynamic.
Provide exactly one of --script or --script-file. You passed both, or neither.
No messages at all. The script must call send(), and the window must be long enough. --duration-ms is capped at 60000. Message capture per session is bounded at 16 MiB, and messages_truncated turns true when that limit is hit.
Limits and where to go next
We showed only programs we compiled ourselves, running on an emulator we created. We didn’t test Linux or Windows hosts, physical devices, iOS, remote frida-server connections over --remote-address, or Android apps (as opposed to command-line executables). We also never got local macOS instrumentation working. Re-test with your own setup before relying on any of those.
- Find the function to hook before you hook it: Free native binary analysis with REA and Ghidra
- Static analysis of your own APK before you instrument it: Android APK analysis with REA
- Your app crashed under instrumentation: adb logcat and bugreport crash debugging
- Keep and compare the Evidence records these runs produce: REA Evidence, snapshots and compare
- Register REA’s MCP server with your agent: Getting started with REA and MCP
- Frida’s own reference: JavaScript API