rea-reverse

用 REA 调用 Frida:给自己编译的原生程序做动态插桩

基于 REA 6.7.0发布者 rea-reverse更新于 约 8 分钟读完
本页目录

先说结论: REA(morluto 的开源项目)从 6.5.0 起支持 Frida(本文在 6.7.0 上实测),CLI 有 list-frida-devices、list-frida-processes、instrument-with-frida 三个命令,MCP 一侧有 9 个工具。它不是 Frida 的替代品,脚本还是得你自己写。REA 做的事是把一次插桩“框”起来:attach 或 spawn,加载一个脚本,在固定的毫秒数内把所有 send() 消息收成 JSON,然后 detach,最后包装成一条带脚本 SHA-256 的 Evidence 记录交还给你。我们在 Android 模拟器上拿自己写的 C 程序测,一切正常;回到 Mac 本机,REA 跑不通,原生 Frida 也一样跑不通。两边的过程下面都写了。

这篇适合想观察自己的原生代码运行时行为的开发者,不管你是在终端里直接用,还是让 AI agent 通过 MCP 来调用。我们会编译一个小 C 程序,hook 其中一个函数,分别用 REA 和 frida-tools 跑同一个 hook,看两者哪里一样、哪里不一样。

只对你自己的进程,或者拿到书面授权的测试目标做插桩。Frida 脚本拥有目标进程的全部权限,可以读写它的内存。开始前请先读 REA 的 DISCLAIMER.md。本文所有操作的对象,都是我们自己写的程序,跑在专门为这次测试新建的模拟器上。

测试环境

  • REA:npm 上的 [email protected],可选依赖 frida 锁定在 17.23.2
  • 对照组:frida-server 17.23.2(android-arm64),frida-tools 14.11.0,Python 3.14.7
  • macOS 27.0.1(Apple 芯片),Node.js 22.22.3,npm 10.9.8,Apple clang 21.0.0
  • Android 模拟器 37.1.11.0,镜像为 Android 14(API 34)google_apis arm64-v8a
  • 交叉编译用 Android NDK 27.1.12297006(clang 18.0.2)

安装,先看能发现哪些设备

我们没有全局安装,而是装进一个空目录,方便检查它带进来的 Frida 绑定:

mkdir rea-frida && cd rea-frida
npm init -y
npm i [email protected]
npx rea --version      # 6.7.0

发布包的 package.json 里写着 "optionalDependencies": {"frida": "17.23.2"},并且允许它的安装脚本运行。在这台 Mac 上安装没有报错,装完能看到 node_modules/frida/build/frida_binding.node。如果这个文件不在,Frida 相关命令就没有东西可调。

$ 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

默认输出是 REA 的紧凑格式 toon,要接管道处理时加 --format json(或者全局参数 --json)。这条命令用了 1.3 秒。几秒后我们再跑一次,结果多出第四台设备:一台已启动的 iOS 模拟器(iPhone 17,类型 remote)。设备列表就是 Frida 当时枚举到的东西,连续两次结果不同很正常,脚本里别假设它固定不变。

目标程序:我们自己写的 tally

tally.c 每秒 5 次:打开日志文件,给一个单词算分,把结果追加进去。我们要观察的是 score_word。

#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;
}

Frida 脚本 trace.js:找到 score_word,进入时读出字符串参数,返回时把返回值报上来。

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() });

想让 REA 记下来的东西,都得走 send()。REA 收集的就是这些消息,目标进程自己的 stdout 它不管,所以那行 printf 在 REA 的输出里看不到。

第一次尝试:直接在 Mac 本机上跑

一般人第一步都会先试本机,我们也一样。用 cc -O1 -g -o tally tally.c 编出 macOS 版本,得到的是 arm64、链接器 ad-hoc 签名的二进制。

第一个报错来自相对路径:

$ 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'"}

原生 Frida 也是这样,frida -f ./tally 输出 Failed to spawn: unable to find application with identifier './tally':Frida 把这个字符串当成了应用标识符,而不是文件路径。换成绝对路径后,这一关过了:

$ 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", ...}

这次等了 1 分 40 秒才失败,而且留下了残局:ps 显示我们的 tally 还在,状态是 T(已停止),~/.cache/frida/ 下的一个 frida-helper 进程也没退出。REA 如实报告了这件事,返回 cleanup_incomplete,退出码非 0,但收尾得你自己来:

ps -o pid,stat,etime,command -p <pid>   # STAT "T" = stopped, never resumed
kill <pid>
pgrep -fl frida-helper                  # kill leftovers too

attach 到一个已经在跑的 tally,同样是 "reason":"Timeout was reached"。为了分清是不是 REA 的锅,我们在同一个 shell 里用原生 Frida 17.23.2 试了一遍:frida -p <pid> -l trace.js 51 秒后输出 Failed to attach: timeout was reached,它的 frida-helper 也同样留了下来。所以问题出在宿主机,不在 REA。在这个 macOS 版本上,SIP 开着,DevToolsSecurity -status 显示开发者模式已启用,非 root 的 Frida 依然拿不到我们刚编译出来的二进制的 task port。我们没有在这台机器上继续尝试 root 运行或重新签名,所以说不准哪个办法能解决。结论是:在新版 macOS 上做本机插桩,光 npm i 不够,先确认原生 frida 能跑通;Frida 做不到的事,REA 也做不到。

换到自己掌控的 Android 模拟器

模拟器上我们有 root,frida-server 正好需要。我们用 google_apis 镜像新建了一个 AVD(没用 Play 商店镜像,因为要 adb root),无窗口启动,再推上版本匹配的 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'

server 版本要和 REA 装的 frida 包对上,这里都是 17.23.2。REA 不会替你装 frida-server。

同一份 tally.c 用 NDK 交叉编译。这里 -rdynamic 不能省:不加的话,Android 可执行文件不会把 score_word 放进动态符号表。我们专门编了一个不带这个参数的版本试了下,结果是 Error: /data/local/tmp/tally-nordyn: unable to find export 'score_word'。这个错误是以一条 "type": "error" 的消息出现的(带调用栈和行号),而命令本身照样退出 0。脚本出错不等于命令出错,结果是空的时候,先去 messages 里找有没有 type: error。

$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

这时模拟器以 USB 设备的身份出现了:

$ npx rea list-frida-devices | grep usb
  emulator-5554,Android Emulator 5554,usb

spawn:从第一条指令开始 hook

spawn 模式下,Frida 先以暂停状态启动程序,REA 加载脚本,然后才恢复运行,所以 main 执行前 hook 就已经挂好了。--argument 的值会跟在程序路径后面(REA 构造的 argv 是 [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

退出码 0,总耗时 2.6 秒。删减后的输出:

{
  "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
}

完整文件里一共 11 条消息:1 条 ready,加上 2000 毫秒内的 10 次调用,和 200 毫秒一轮的循环对得上(每条消息还带一个 script_id,上面删掉了)。数值也是对的:f+r+i+d+a = 6+18+9+4+1 = 38。source_sha256 和 shasum -a 256 trace.js 的结果完全一致,留存 Evidence 时这一点最有用:事后能证明哪份脚本产生了哪次观测。provider.version 是 null,虽然 Frida 版本其实是已知的;limitations 也直说了没有记录目标的系统和架构。脚本自己在 payload 里带上 Process.arch,正好补上这个空缺。

观测窗口结束后 REA 会 detach,但不会杀掉目标。我们用 --argument 600 加 1000 毫秒窗口跑了一次,命令返回后 adb shell pidof tally 仍然能打出 PID。

attach:挂到已经在跑的进程上

先找 PID。list-frida-processes 默认是 --device-id local,要显式指定模拟器:

$ npx rea list-frida-processes --device-id emulator-5554 | grep -E 'tally|processes\['
processes[145]{pid,name,identifier}:
  4992,tally,null

然后 attach 一秒钟,用 --filter-output 只保留关心的字段:

$ 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

5 条消息,总耗时 1.49 秒。attach 只能看到 hook 挂上之后发生的事,启动时只跑一次的函数永远抓不到,这类场景用 spawn。attach 模式下传 --argument 会被直接拒绝,错误信息写得很清楚。

和 frida-tools 对照

同一个脚本、同一个 PID,换原生 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
...

地址一样,数值一样,只是多了一条,6 条对 5 条。-t 1 和 --duration-ms 1000 开始计时的时间点不同,调用间隔 200 毫秒,差一条在预期之内。frida-ps -D emulator-5554 列出的也是 145 个进程,tally 的 PID 同样是 4992。

真正的差别在 frida-trace。一行代码不写,它就给两个函数生成了 handler,并打出时间线:

$ 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 没有自动生成 handler,也不支持按通配符批量追踪,脚本得你自己带。换来的是每次运行一份结构化 JSON,而不是一屏滚动的终端输出。我们的分工是:摸索阶段用 frida-trace,要留档、要事后对比、要交给 agent 处理的,用 REA。

MCP 会话工具

一次性命令适合“盯 N 毫秒”这种需求。如果 agent 要边看边决定下一步,REA 的 MCP 服务(rea mcp)提供了一组会话工具,CLI 里没有对应命令。我们写了个很短的 Node.js 客户端,通过 stdio 发 JSON-RPC 来驱动它。tools/list 一共返回 207 个工具,其中 9 个和 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

对模拟器实际跑的调用顺序,以及每次调用的耗时:

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"

这次跑下来有两个细节。第一,frida_session_status 返回的是完整的消息历史,不只是新增的部分,第二次查询里又带上了那条 ready。如果你循环轮询,要自己去重。第二,close_frida_session 返回的确实是 target_state: "unknown",detach 之后 REA 对进程状态不做任何断言。在模拟器上用 pidof tally 看,进程还在跑。

我们发现的不一致

  • CLI 里字段大小写不统一。 list-frida-devices 输出的是 deviceId,而 list-frida-processes 和所有 MCP 结果用的都是 device_id。MCP 那边的处理函数做了字段改名,CLI 这条命令则把内部对象原样输出。同时解析这两条命令的脚本,两种写法都要兼容。
  • npm 包里没有文档。 npm 上的 README 完全没提 Frida,tarball 里也没有 docs/ 目录。设计说明在 GitHub 上的 docs/frida-instrumentation.md,但那跟的是 main 分支,内容可能比 6.7.0 更新。我们以 --help 和发布包里的 dist/ 为准。
  • 脚本出错不影响退出码。 脚本抛了异常,命令照样退出 0,错误只出现在 messages 里。

常见问题和排错

Unable to find application with identifier './tally': spawn 模式要传绝对路径。

本机 macOS 目标报 Timeout was reached: 先确认原生 frida -p <pid> 能不能用。它也超时的话,就是宿主机的进程权限问题,不是 REA 的问题。然后检查有没有停住的目标进程和残留的 frida-helper,有就 kill 掉。

消息里出现 type: error,内容是 unable to find export 'score_word',退出码却是 0: 函数不在动态符号表里。用 llvm-nm -D 确认,再加 -rdynamic 重新编译。

Provide exactly one of --script or --script-file: 两个参数同时传了,或者一个都没传。

一条消息都没有: 脚本里必须调用 send(),观测窗口也要够长。--duration-ms 最大 60000。每个会话的消息捕获上限是 16 MiB,超出时 messages_truncated 会变成 true。

局限和下一步

本文只演示了我们自己编译的程序,跑在我们自己新建的模拟器上。没有测试 Linux 和 Windows 宿主机、真机、iOS、通过 --remote-address 连远程 frida-server,也没有测 Android App(这里只测了命令行可执行文件)。macOS 本机插桩我们也始终没跑通。上面这些场景,请在你自己的环境里重新验证后再用。