8000 Script.unload() while a thread observer is attached can crash the target in QuickJS (two threads in __JS_FreeValueRT) · Issue #1194 · frida/frida-gum · GitHub
[go: up one dir, main page]

Skip to content

Script.unload() while a thread observer is attached can crash the target in QuickJS (two threads in __JS_FreeValueRT) #1194

Open

Description

@Albasha0x0

When a script that has a Process.attachThreadObserver() with JS callbacks is unloaded while the target process is exiting, the target sometimes crashes with an access violation inside frida-agent. Two threads are inside the same QuickJS runtime at once. One of them is a Frida thread running an Interceptor listener (the thread observer's callback). This happens on 17.24.3 and on 17.23.3, so it is older than the 17.24 exit hang in #1192.

Repro

Build the target (source below). Windows: cl /Od target.c. macOS: cc -O0 -o target target.c.

Then run python -I close_at_exit.py <target> 150 observer. In each run, the script:

  1. Starts the target, waits for its "ready" line, attaches, and loads a script. The script:
    • hooks the target's at_exit (registered with atexit) with send('hit'); recv('go').wait();
    • hooks ntdll!RtlExitUserProcess (or _exit on macOS) with send('exiting'); recv('exit').wait(); Thread.sleep(0.3);
    • attaches a thread observer whose onAdded and onRemoved call send().
  2. Writes a line to the target's stdin, so main returns and runs at_exit.
  3. On hit: posts go, then calls an RPC export that reports whether the exit hook has run.
    • If it has not: script.unload(), then session.detach().
    • If it has: posts exit and waits for the process.
  4. Checks the target's exit code (5 is expected).

Mode plain leaves out the thread observer. Mode detach has the observer, but the RPC export calls observer.detach() before the host unloads.

Observed

GitHub-hosted windows-11-arm (ARM64, Windows 11), Python 3.13 ARM64. Each cell is 600 closes, 4 loops of 150 run in parallel. AV = exit code 0xC0000005; hung = the target had not exited 10 s after the close.

Frida Target observer detach
17.24.3 x86 (emulated) 13 hung 7 hung
17.24.3 x64 (emulated) 2 AV, 5 hung 2 hung
17.24.3 arm64 29 hung 11 hung
17.23.3 x86 (emulated) 0 0
17.23.3 x64 (emulated) 2 AV 0
17.23.3 arm64 0 0

An earlier run (x64 and arm64 targets, 300 closes per cell) agrees:

  • observer: 8000 1 AV on 17.24.3 and 1 AV on 17.23.3.
  • detach: 0 AV.
  • plain (no observer): 0 AV in 1200 closes; hangs only on 17.24.3 (7 of 600).

Across both runs:

macos-latest (ARM64), Python 3.13 ARM64, target signed ad hoc with get-task-allow, 600 closes per mode and version: 0 failed on 17.24.3 or 17.23.3 (3600 closes). On macOS the exit hook had already run when the host asked in about 92% of closes, so few closes took the unload path (about 280 in 3600). In a larger program with the same close sequence on macos-latest (17.24.3), the target ended with exit status 139 (SIGSEGV) during such a close.

The crash: the exit code is 0xC0000005, and on macOS the exit status is 139 (SIGSEGV), seen in a larger program with the same close sequence. A vectored exception handler in the target recorded each fault. Below is an x86 target on 17.24.3, with RVAs in the 17.24.3 x86 frida-agent.dll:

  • First thread: write to 0x00000004 at frida-agent.dll+0x21CA4C. That is QuickJS's __JS_FreeValueRT, in the list_del(&p->link) that moves an object to gc_zero_ref_count_list (the branch where rt->gc_phase at JSRuntime+0x268 is not REMOVE_CYCLES).
  • Second thread, at the same moment: write to 0x00000004 at frida-agent.dll+0x22262C, the same list_del on the decref path.
  • Both nodes had already been unlinked, with next and prev zeroed.
  • The faulting thread starts at frida-agent.dll+0x12542CC (the GLib thread entry). It is inside Interceptor's listener dispatch, the loop that calls each listener's on_enter (frida-agent.dll+0x6768B), so a JS listener runs on a Frida thread while the runtime is being torn down.

Expected

The unload finishes, or fails because the process terminated, and the target exits with its own code. Observer callbacks do not run JS once the unload has started.

Workaround

Detach the thread observer before unloading the script (mode detach).

target.c

#include <stdio.h>
#include <stdlib.h>
#ifdef _WIN32
#define EXPORT __declspec(dllexport) __declspec(noinline)
#else
#define EXPORT __attribute__((visibility("default"), noinline))
#endif
EXPORT void at_exit(void) { printf("at_exit\n"); fflush(stdout); }
int main(void)
{
    char line[16];
    atexit(at_exit);
    printf("ready\n");
    fflush(stdout);
    if (!fgets(line, sizeof line, stdin)) return 9;
    return 5;
}

close_at_exit.py

# A thread held in a hook on an atexit handler is released while the host
# closes the session: the host asks the script whether the exit has begun,
# then unloads and detaches, or (exit begun) answers the exit hook and waits.
# Counts runs whose target does not exit with its own code (5).
# Mode: plain (no observer), observer (a thread observer that sends each
# thread start and end), detach (the observer, detached when the close begins).
# Usage: python -I close_at_exit.py <target> <runs> <mode>
import os, subprocess, sys, threading
import frida

AGENT = """
let exiting = false;
const atExit = Process.getModuleByName(TARGET).getExportByName('at_exit');
Interceptor.attach(atExit, { onEnter() { send('hit'); recv('go', () => {}).wait(); } });
function onExit() {
  exiting = true;
  send('exiting');
  recv('exit', () => {}).wait();
  Thread.sleep(0.3);
}
if (Process.platform === 'windows')
  Interceptor.attach(Process.getModuleByName('ntdll.dll').getExportByName('RtlExitUserProcess'), onExit);
else
  Interceptor.attach(Module.getGlobalExportByName('_exit'), onExit);
let observer = null;
if (MODE !== 'plain')
  observer = Process.attachThreadObserver({ onAdded(t) { send({ added: t.id }); }, onRemoved(t) { send({ removed: t.id }); } });
rpc.exports = {
  closeBegin() {
    if (MODE === 'detach' && observer !== null) {
      observer.detach();
      observer = null;
    }
    return exiting;
  },
};
"""


def once(target):
    program = subprocess.Popen([target], stdin=subprocess.PIPE, stdout=subprocess.PIPE)
    # Attach once the program runs main, past its loader.
    program.stdout.readline()
    session = frida.attach(program.pid)
    script = session.create_script("const TARGET = '%s', MODE = '%s';" % (os.path.basename(target), MODE) + AGENT)
    hit = threading.Event()

    def on_message(m, d):
        p = m.get("payload")
        if p == "hit":
            hit.set()
        elif p == "exiting":
            script.post({"type": "exit"})
    script.on("message", on_message)
    script.load()
    program.stdin.write(b"go\n")
    program.stdin.flush()
    if not hit.wait(10):
        program.kill()
        raise RuntimeError("no hit")
    script.post({"type": "go"})
    path = ["close_begin"]
    done = threading.Event()

    def close():
        try:
            if script.exports_sync.close_begin():
                path[0] = "exiting"
                script.post({"type": "exit"})
                program.wait(10)
            else:
                path[0] = "unload"
                script.unload()
                path[0] = "unload, detach"
                session.detach()
        except (frida.InvalidOperationError, frida.core.RPCException, subprocess.TimeoutExpired):
            path[0] += " (terminated)"
        done.set()
    threading.Thread(target=close, daemon=True).start()
    hung = not done.wait(15)
    try:
        code = program.wait(10)
    except subprocess.TimeoutExpired:
        program.kill()
        return "exit hung", path[0]
    if hung:
        return "close hung", path[0]
    if os.name == "nt":
        code &= 0xFFFFFFFF
    return ("ok" if code == 5 else "exit code %s" % (hex(code) if os.name == "nt" else code)), path[0]


if __name__ == "__main__":
    target, runs, MODE = os.path.abspath(sys.argv[1]), int(sys.argv[2]), sys.argv[3]
    bad, paths = 0, {}
    for i in range(runs):
        r, path = once(target)
        paths[path] = paths.get(path, 0) + 1
        if r != "ok":
            bad += 1
            print(f"run {i}: {r} ({path})", flush=True)
    print(f"frida {frida.__version__} {os.path.basename(target)} {MODE}: {runs} runs, {bad} failed; paths {paths}", flush=True)

Activity

  1. Albasha0x0 commented on Nov 4, 2026

    @Albasha0x0
    Author

    Retested on 17.25.2 (Windows 11 x64, Python 3.12, x64 target) with close_at_exit.py from this report: 0 failed closes in 300 for each of observer, detach and plain. The same files on 17.24.3 on the same host failed 12 of 300, all as the #1192 exit hang and none as an access violation. The access violation is rare (6 in 4800 closes in the report), so 900 clean closes do not show it is fixed. We keep detaching the observer before the unload.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions

      0