9a16de46f2 [JSC] BBQCallee should be kept alive between callsite collection and repatch
Triage note: Callees can be freed between collecting and repatching callsites on non-JS threads; explicit keep-alive vectors fix a use-after-free.
Contents
The bug at a glance
A use-after-free in the WebAssembly JIT tiering machinery: a BBQCallee (and, in an edge case, its OMGOSREntryCallee) can be freed by a wasm compiler thread between the moment CalleeGroup::updateCallsitesToCallUs collects callsites and the moment it repatches them, so the repatch operates on freed code objects. Medium reflects that it requires OSR-entry tiering plus adversarial thread timing and that the impact is corruption of JIT callsite patching rather than a demonstrated controlled primitive.
updateCallsitesToCallUs collected callsites from weakly-held BBQ/OMG callees and then repatched them, but callees are freed under Heap::stopThePeriphery(), which only halts JS compiler threads - not wasm ones. A wasm thread could free the callee in that gap, so the subsequent repatch touched freed memory.
Root cause
CalleeGroup::updateCallsitesToCallUs collects the callsites that call a given callee and later rewrites (repatches) them to point at a new callee. The callees involved (BBQCallee and OMGOSREntryCallee) are held weakly in the group’s maps. The original comment explains the hazard: callees are released under Heap::stopThePeriphery(), but that only stops JS compiler threads, not wasm compilation threads, so an OMGOSREntryCallee could die between callsite collection and the actual repatch, leaving repatch working on freed code.
The original mitigation only kept the OMGOSREntryCallee alive (keepAliveOSREntryCallees). But OMGOSREntryCallee is owned by BBQCallee, and a BBQCallee that is being repatched can itself be freed in the same window while it is only weakly referenced. The fix adds Vector<Ref<BBQCallee>, 4> keepAliveBBQCallees and, whenever a bbqCallee is found, promotes it to a strong Ref via bbqCallee.releaseNonNull() and records it, setting bbqCalleeKeptAlive = true. Because BBQCallee owns its OMGOSREntryCallee, keeping the BBQCallee alive transitively keeps the OSR-entry callee alive for the whole collect-then-repatch duration.
The subtlety handled next is the edge case where a BBQCallee has already been freed but its OMGOSREntryCallee is only on the pending-destruction set and not yet freed, so m_osrEntryCallees still holds a live weak ref to it. In that case there is no BBQCallee to keep alive, so the code still appends the OMGOSREntryCallee to keepAliveOSREntryCallees - but only when !bbqCalleeKeptAlive, to avoid creating a second strong owner for an OMGOSREntryCallee that is already owned by a kept-alive BBQCallee. Both keep-alive vectors are locals in updateCallsitesToCallUs, so the strong refs are released once repatching completes.
Key code
Keep BBQCallee alive; only separately retain OMGOSREntryCallee in the orphan edge case
if (auto iter = m_osrEntryCallees.find(callerIndex); iter != m_osrEntryCallees.end()) {
if (RefPtr callee = iter->value.get()) {
collectCallsites(callee.get());
// If we track the OMGOSREntryCallee as a callsite there are 2 possibilities -
// 1. The BBQCallee is already being tracked - in this case we don't have to
// track the OMGOSREntryCallee since the BBQCallee owns it and keeping the
// BBQCallee alive is good enough to keep the OMGOSREntryCallee alive. Also,
// OMGOSREntryCallee is only supposed to be owned by BBQCallee
// 2. The BBQCallee is not tracked - This happens if the BBQCallee is already
// released but the OMGOSREntryCallee is still alive. In this case there is
// no other strong reference to OMGOSREntryCallee so we have to keep it
// alive here.
if (!bbqCalleeKeptAlive)
keepAliveOSREntryCallees.append(callee.releaseNonNull());
} else
m_osrEntryCallees.remove(iter);
}
Patch walkthrough
Source/JavaScriptCore/wasm/WasmCalleeGroup.cpp— Adds Vector<Ref<BBQCallee>, 4> keepAliveBBQCallees alongside the existing keepAliveOSREntryCallees, and rewrites the comment to explain that BBQCallee ownership of OMGOSREntryCallee makes keeping the BBQCallee alive sufficient. Introduces a per-callsite bool bbqCalleeKeptAlive: when a bbqCallee is collected it is appended via releaseNonNull() and the flag set. In the OMG branch, the OMGOSREntryCallee is only appended to keepAliveOSREntryCallees when !bbqCalleeKeptAlive, covering the edge case where the BBQCallee is already gone but the OSR-entry callee is still on the pending-destruction set (weakly reachable), while avoiding a duplicate strong owner otherwise.
Background
Wasm tiering: BBQ and OMG — WebKit compiles WebAssembly in tiers. BBQ is the baseline optimizing tier and OMG is the higher optimizing tier. A BBQCallee represents baseline-compiled code; an OMGOSREntryCallee is the OSR-entry variant used to jump into OMG-optimized code mid-execution. OMGOSREntryCallee is owned by its BBQCallee.
CalleeGroup::updateCallsitesToCallUs — When newly compiled code becomes available, the CalleeGroup repatches existing callsites to call the new callee. It first collects the callsites referencing the target callee(s), then rewrites them. Both steps must see the callee objects alive, which is the invariant this bug broke.
Heap::stopThePeriphery() and wasm threads — Callees are released during Heap::stopThePeriphery(), a GC safepoint that halts JS compiler threads. Crucially it does not halt wasm compilation threads, so a wasm thread can still be freeing callees while the main thread is between collecting and repatching callsites - the exact race exploited here.
Weak references and pending-destruction set — m_osrEntryCallees stores weak references to OMGOSREntryCallees. An object queued for destruction may still be weakly reachable until actually freed; the edge case handles a BBQCallee already freed while its OSR-entry callee lingers on the pending-destruction set, so the OSR-entry callee must be retained directly.
Vulnerability window
- Trigger — OSR-entry tiering makes CalleeGroup::updateCallsitesToCallUs collect callsites that call a weakly-held BBQCallee and/or OMGOSREntryCallee.
- Race — A wasm compilation thread (not stopped by Heap::stopThePeriphery) frees the BBQCallee - and transitively its OMGOSREntryCallee - between callsite collection and repatch.
- UAF (pre-patch) — Repatch rewrites callsites against the freed callee code objects; the original fix only retained OMGOSREntryCallee, leaving the BBQCallee itself vulnerable.
- Fix — keepAliveBBQCallees takes a strong Ref to each collected BBQCallee, transitively pinning its OMGOSREntryCallee; keepAliveOSREntryCallees now only retains an orphaned OSR-entry callee when no BBQCallee was kept alive.
- Release — Both keep-alive vectors are function locals, so the strong refs are dropped once repatching finishes, restoring normal reclamation.
Triggering
No test accompanies the change (a thread-timing race in wasm tiering). Conceptual trigger: run a WebAssembly module hot enough to tier through BBQ into OMG with OSR entry, so updateCallsitesToCallUs must repatch OSR-entry callsites while wasm compiler threads concurrently release the corresponding BBQCallee/OMGOSREntryCallee, hitting the collect-then-repatch window.
Exploitation
- Setup — Drive a wasm module through BBQ->OMG OSR-entry tiering to force callsite repatching in CalleeGroup.
- Race — Rely on wasm compiler threads freeing the weakly-held BBQCallee/OMGOSREntryCallee during the unprotected window while stopThePeriphery leaves wasm threads running.
- Use — Repatch touches freed executable-callee objects. Impact is corruption/crash in JIT callsite patching; no controlled primitive is demonstrated, so treat as crash-class requiring precise thread timing.
Detection & hunting
For defenders and SOC / detection engineers:
- ASan UAF in updateCallsitesToCallUs —
- Crashes in wasm tiering under concurrent compilation —
Audit directions
- Weakly-held callees used across async steps —
- Ownership assumptions (BBQ owns OMGOSREntry) —
- stopThePeriphery vs wasm thread lifetime —