rea-reverse

Frida Hook a Native Function on Android: Spawn vs Attach

Written for REA 6.7.0Published by rea-reverseUpdated 4 min read
On this page

To hook a native function with Frida, find its address with Process.getModuleByName(...).getExportByName(...), wrap it with Interceptor.attach, and report with send(). Use spawn (frida -f) when you need calls from the very start, because the script is loaded while the process is still paused; attach (frida -p) only sees calls after the hook lands. The function must be in the dynamic symbol table, which on Android means building with -rdynamic.

We ran everything on a C program we wrote, inside an Android 14 emulator we created, with frida-server 17.23.2 and frida-tools 14.11.0. Only instrument processes you own or are authorised to test: a Frida script runs with the target’s full authority.

Hook Your Own Process with FridaSilent walkthrough with English captions.

The target and the hook

tally.c scores a word and appends the result to a log, five times a second. score_word is the function 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;
}

trace.js reads the 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() });

this is per-call state shared between onEnter and onLeave, which is how the word reaches the exit handler.

Emulator and frida-server

frida-server needs root, so we used a google_apis image (the Play Store image does not allow 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'

Keep the server version equal to your client’s frida version. Then cross-compile with the NDK (27.1.12297006 here):

$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

The -rdynamic trap. Without it, an Android executable keeps score_word out of its dynamic symbol table, and the script fails with Error: /data/local/tmp/tally-nordyn: unable to find export 'score_word'. Check with llvm-nm -D before blaming Frida. For a binary you cannot rebuild, find the function offset in a disassembler and hook module.base.add(offset) instead.

Attach to a running process

$ 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
...

Six messages in one second: ready plus five calls at 200 ms each. The values are right (f+r+i+d+a = 6+18+9+4+1 = 38). -q -t 1 runs quietly and exits after one second.

frida-trace does the same without writing any JavaScript, and can trace several functions at once:

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

It generates a handler file per function under __handlers__/; edit it to print arguments the way trace.js does.

Spawn: hook before main

Attach only shows what happens after the hook lands, so a function that runs once at startup never appears. Spawn starts the program suspended, loads the script, then resumes it: frida -D emulator-5554 -f /data/local/tmp/tally -l trace.js (arguments after the program go to its argv). We recorded our spawn runs as JSON with REA (next section): a spawn with argv ["12"] and a 2000 ms window held 11 messages, the ready message plus all 10 calls from the first loop iteration on, with frida first.

When the window ends, the process keeps running after detach: after a 1000 ms spawn window, adb shell pidof tally still printed the PID.

Spawn takes a path, not a relative name. frida -f ./tally printed Failed to spawn: unable to find application with identifier './tally', because Frida treated it as an app identifier. Pass the absolute path.

Recording a run as JSON with REA

REA, a third-party open-source tool, wraps Frida as rea instrument-with-frida plus MCP tools for agents. You still write the script; REA attaches or spawns, collects every send() for a fixed window, detaches and returns one JSON record that includes the script’s SHA-256. We used [email protected] from npm, which pins frida 17.23.2 as an optional dependency (check that node_modules/frida/build/frida_binding.node exists after install).

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

Exit 0 after 2.6 s. Trimmed:

{
  "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 equals shasum -a 256 trace.js, so a saved record proves which script produced which observation. provider.version came back null, and the target’s stdout is not captured: only send() messages are.

Attach works the same way (instrument-with-frida attach 4992 --device-id emulator-5554 ... --duration-ms 1000 gave five messages in 1.49 s, the same 0x558a1eca70 address as plain frida). Things to know:

  • A failing script does not fail the command. The missing-export error above arrived as a "type": "error" message and the command exited 0; check messages before trusting an empty result.
  • list-frida-devices prints deviceId while list-frida-processes and MCP results use device_id.
  • --duration-ms is capped at 60000, capture is bounded at 16 MiB (messages_truncated turns true), and --argument is rejected in attach mode.
  • Over MCP there are session tools for an agent that wants to look, then decide: start_frida_session (spawn, paused, 99 ms), load_frida_script (7 ms), resume_frida_session, frida_session_status, unload_frida_script, close_frida_session. frida_session_status returns the whole message history each time, so deduplicate when polling; close_frida_session reports target_state: "unknown", though pidof showed the process still running.

For exploring, frida-trace is quicker. For a run you want to compare later or hand to an agent, the JSON record is the useful part.

Why it timed out on macOS

We tried the native macOS build first (cc -O1 -g -o tally tally.c, an ad-hoc linker-signed arm64 binary) on macOS 27.0.1 with SIP on and DevToolsSecurity -status reporting developer mode enabled. As a normal user, plain Frida 17.23.2 failed: frida -p <pid> -l trace.js printed Failed to attach: timeout was reached after 51 s. Spawning through REA took 1 min 40 s to fail with Unable to resume spawned target: Timeout was reached, and left the target stopped (ps state T) and a frida-helper from ~/.cache/frida/ running:

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

The cause is the host: a non-root Frida could not get the task port even for a binary we had just compiled. We did not try root or re-signing with the get-task-allow entitlement, so we cannot say which fixes it. On a recent Mac, get plain frida -p working before anything else.

We did not test Linux or Windows hosts, physical devices, iOS, --remote-address connections, or Android apps as opposed to command-line executables. For the API itself, see Frida’s JavaScript API reference; to find a function worth hooking first, see native binary analysis with Ghidra.