rea-reverse

在 Android 模拟器上用 Frida hook 原生函数:spawn 与 attach

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

用 Frida hook 原生函数,就是三步:用 Process.getModuleByName(...).getExportByName(...) 拿到函数地址,用 Interceptor.attach 包住它,再用 send() 把结果发回来。需要从程序一开始就抓调用时用 spawn(frida -f),脚本在进程暂停时就已加载;attach(frida -p)只能看到 hook 生效之后的调用。函数必须在动态符号表里,在 Android 上意味着编译时要加 -rdynamic。

下面所有操作都针对我们自己写的 C 程序,跑在专门新建的 Android 14 模拟器上,frida-server 17.23.2、frida-tools 14.11.0。只对自己的进程或拿到授权的目标做插桩:Frida 脚本拥有目标进程的全部权限。

目标程序和 hook 脚本

tally.c 每秒五次给一个单词打分,把结果追加到日志里。要观察的是 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;
}

trace.js 在进入时读字符串参数,返回时上报返回值:

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

this 是 onEnter 和 onLeave 共享的单次调用状态,单词就是这样传到返回处理里的。

模拟器和 frida-server

frida-server 需要 root,所以用 google_apis 镜像(Play Store 镜像不允许 adb root):

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'

frida-server 的版本要和客户端 frida 一致。然后用 NDK(这里是 27.1.12297006)交叉编译:

$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

-rdynamic 这个坑。 不加的话,Android 可执行文件不会把 score_word 放进动态符号表,脚本报 Error: /data/local/tmp/tally-nordyn: unable to find export 'score_word'。先用 llvm-nm -D 查一下再怀疑 Frida。如果是没法重编的二进制,就在反汇编器里找到函数偏移,改 hook module.base.add(offset)。

attach 到正在运行的进程

$ frida-ps -D emulator-5554 | grep tally
 4992  tally
$ 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
...

一秒内六条消息:ready 加上每 200 毫秒一次的五次调用。数值也对(f+r+i+d+a = 6+18+9+4+1 = 38)。-q -t 1 表示安静模式、一秒后退出。

frida-trace 不用写 JavaScript 就能做到同样的事,还能同时追多个函数:

$ 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()

它会在 __handlers__/ 下为每个函数生成一个处理文件,照 trace.js 的写法改一下就能打印参数。

spawn:在 main 之前装好 hook

attach 只能看到 hook 生效之后发生的事,启动时只跑一次的函数永远抓不到。spawn 会先以暂停状态启动程序、加载脚本,再恢复运行:frida -D emulator-5554 -f /data/local/tmp/tally -l trace.js(程序路径后面的参数会传进它的 argv)。我们的 spawn 运行是用 REA 记录成 JSON 的(见下一节):argv ["12"]、2000 毫秒窗口,共 11 条消息,即 ready 加上从第一轮循环开始的全部 10 次调用,第一条就是 frida。

窗口结束、detach 之后进程会继续运行:1000 毫秒的 spawn 窗口结束后,adb shell pidof tally 仍能打印出 PID。

spawn 要给路径,不能给相对名字。frida -f ./tally 报 Failed to spawn: unable to find application with identifier './tally',Frida 把它当成了应用标识符。改用绝对路径即可。

用 REA 把一次运行记录成 JSON

第三方开源工具 REA 把 Frida 包装成 rea instrument-with-frida 命令,另外提供给 agent 用的 MCP 工具。脚本仍然要自己写;REA 负责 attach 或 spawn、在固定窗口内收集所有 send()、detach,然后返回一条带脚本 SHA-256 的 JSON 记录。我们用的是 npm 上的 [email protected],它把 frida 17.23.2 锁为可选依赖(装完确认 node_modules/frida/build/frida_binding.node 存在)。

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

2.6 秒后退出码 0。删减后:

{
  "evidence": {
    "predicate_type": "rea.frida.instrumentation-observation",
    "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_path": "trace.js",
      "source_sha256": "425a2db5c84d1bd9a7460f5e1297aa7cbc719d64092e007a2b5aacb35595c838",
      "cleanup_error": null
    },
    "limitations": [
      "Artifact identity is unavailable for this observation.",
      "The target operating system and architecture were not reported by this observation."
    ]
  }
}

source_sha256 与 shasum -a 256 trace.js 一致,保存下来的记录能证明是哪个脚本产生了哪次观察。provider.version 返回 null;目标的 stdout 不会被收集,只有 send() 消息。

attach 用法相同(instrument-with-frida attach 4992 --device-id emulator-5554 ... --duration-ms 1000 在 1.49 秒内拿到五条消息,地址和原生 frida 一样是 0x558a1eca70)。需要知道的几点:

  • 脚本出错不会让命令失败。上面那个找不到导出的错误是以 "type": "error" 消息返回的,命令退出码仍是 0;结果为空时先查 messages。
  • list-frida-devices 输出 deviceId,list-frida-processes 和 MCP 结果用的是 device_id。
  • --duration-ms 上限 60000,消息采集上限 16 MiB(超了 messages_truncated 变 true),attach 模式不接受 --argument。
  • MCP 侧有会话工具,适合 agent 先看再决定:start_frida_session(spawn 后暂停,99 毫秒)、load_frida_script(7 毫秒)、resume_frida_session、frida_session_status、unload_frida_script、close_frida_session。frida_session_status 每次都返回完整消息历史,轮询时要自己去重;close_frida_session 报告 target_state: "unknown",但 pidof 显示进程仍在运行。

探索阶段用 frida-trace 更快;要留档对比或交给 agent 的运行,JSON 记录才是有用的部分。

为什么在 macOS 上超时

我们先试了 macOS 本机构建(cc -O1 -g -o tally tally.c,ad-hoc 链接器签名的 arm64 二进制),系统是 macOS 27.0.1,SIP 开启,DevToolsSecurity -status 显示开发者模式已启用。以普通用户运行原生 Frida 17.23.2 失败:frida -p <pid> -l trace.js 在 51 秒后报 Failed to attach: timeout was reached。通过 REA spawn 用了 1 分 40 秒才失败,报 Unable to resume spawned target: Timeout was reached,还留下了停住的目标进程(ps 状态 T)和 ~/.cache/frida/ 里的 frida-helper:

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

原因在主机:非 root 的 Frida 连我们刚编译的二进制的 task port 都拿不到。我们没有试 root 运行或加 get-task-allow entitlement 重签,所以说不准哪个能解决。在新版 macOS 上,先把原生 frida -p 跑通再说别的。

我们没测 Linux、Windows 主机、真机、iOS、--remote-address 远程连接,也没测 Android 应用(只测了命令行可执行文件)。API 细节见 Frida 的 JavaScript API 文档;想先找到值得 hook 的函数,参见用 Ghidra 分析原生二进制。