Repository navigation
Description
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:
- Starts the target, waits for its "ready" line, attaches, and loads a script. The script:
- hooks the target's
at_exit(registered withatexit) withsend('hit'); recv('go').wait(); - hooks
ntdll!RtlExitUserProcess(or_exiton macOS) withsend('exiting'); recv('exit').wait(); Thread.sleep(0.3); - attaches a thread observer whose
onAddedandonRemovedcallsend().
- hooks the target's
- Writes a line to the target's stdin, so
mainreturns and runsat_exit. - On
hit: postsgo, then calls an RPC export that reports whether the exit hook has run.- If it has not:
script.unload(), thensession.detach(). - If it has: posts
exitand waits for the process.
- If it has not:
- 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:
- The access violation appeared only with the thread observer attached: 6 in 4800 closes, on 17.23.3 and 17.24.3 alike. With the observer detached first it did not appear (0 in 4800 closes).
- The hang is the 17.24.x exit hang (Windows: Script.unload() during a process exit hangs both the unload and the exit (17.24.x; not in 17.23.3) #1192): it does not appear on 17.23.3.
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 thelist_del(&p->link)that moves an object togc_zero_ref_count_list(the branch wherert->gc_phaseat JSRuntime+0x268 is not REMOVE_CYCLES). - Second thread, at the same moment: write to 0x00000004 at frida-agent.dll+0x22262C, the same
list_delon 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
fix(live): let a released program begin its exit before the close
fix(live): wait at a close only after threads leave their stops
Albasha0x0 commentedon Nov 4, 2026
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.
docs(live): state the 17.25.2 results beside the Frida workarounds