c9c443b2fe16019559179dd81e49a8d46e48edff [JSC] Reuse retired BBQCallee in epilogue OSR
Triage note: The RC_REMOVE_SEC_ACCEL_3-guarded fix in WasmIPIntSlowPaths makes both Epilogue and Loop OSR reuse a retired BBQCallee via tryGetBBQCalleeForLoopOSRConcurrently instead of returning nullptr, because the comment states that otherwise a new BBQ compile can overwrite the retired BBQCallee and updateCallsitesToCallUs() can no longer patch callers still holding direct calls to the code about to be freed; WasmCalleeGroup adds a RELEASE_ASSERT(!m_bbqCallee) guard. The remaining jsc.cpp/JSDollarVM changes are test instrumentation supporting the fix.
The bug at a glance
In the Wasm tiering machinery, IPInt Epilogue/Loop OSR could return nullptr instead of reusing a retired-but-not-yet-destroyed BBQCallee, which permitted a fresh BBQ compile to overwrite the retired BBQCallee slot. updateCallsitesToCallUs() could then no longer repatch callers that still hold direct (patched) call instructions pointing at the retired callee’s JIT code, and that code is subsequently freed by Heap::finalizeWasmCalleeCleanup() — a use-after-free of executable JIT memory reachable from JavaScript/WebAssembly in the JSC process. UAF of live, callable JIT code is a strong, exploitable-grade primitive; the diff proves the dangling-direct-call-to-freed-code mechanism, so high severity is warranted. Full RCE is the realistic end state but the control-flow-hijack steps beyond the UAF are (inferred).
A tier-up lifetime bug: the retired BBQ callee is only weakly held once retired, yet callers can still carry direct jumps into its machine code, so letting OSR trigger a replacing compile instead of resurrecting the retired callee frees code that is still a live call target.
Root cause
The invariant is that a BBQCallee whose JIT code is still the target of unpatched direct calls must stay alive and patchable until every such caller is redirected. Once a BBQCallee is retired (releaseBBQCallee), the CalleeGroup holds only a weak reference to it (confirmed by the added $vm.wasmCalleeIDs helper, which reports bbqIsRetired when calleeIsReferenced() == TriState::Indeterminate, i.e. the group holds only a weak ref). Direct calls previously patched into other callees still point at the retired callee’s code.
Pre-patch sequence (RC_REMOVE_SEC_ACCEL_3 branch): 1. a function tiers up; a BBQCallee is installed, then later retired while some callers still hold direct calls into its code and have not yet been repatched; 2. IPInt hits Epilogue (or Loop) OSR in jitCompileAndSetHeuristics and, in the old code, OSRFor::Epilogue returns nullptr (and Loop falls through to the replacement path); 3. returning nullptr lets a new BBQ compilation be triggered for that function index; 4. CalleeGroup::startInstallingCallee installs the new BBQCallee into the slot, overwriting the retired one; 5. updateCallsitesToCallUs() can no longer find/patch the callers still pointing at the retired callee because the slot no longer references it; 6. Heap::finalizeWasmCalleeCleanup() later frees the retired callee’s code (wasmCalleesToRelease), after a crossModifyingCodeFence that — per the newly added comment — only orders already-repatched callsites and does NOT keep a live callee holding a not-yet-repatched direct call to the freed code; 7. a caller invokes through its stale direct call into freed executable memory.
The fix makes OSRFor::Epilogue and OSRFor::Loop both return calleeGroup.tryGetBBQCalleeForLoopOSRConcurrently(…), i.e. reuse the retired-but-live BBQCallee rather than returning nullptr, so no replacing BBQ compile is triggered and the retired callee remains the installed, patchable target until its callers are redirected. As defense in depth, CalleeGroup::startInstallingCallee adds, under m_bbqCalleeLock for BBQMode, RELEASE_ASSERT(!slot->m_bbqCallee.get()) so an attempt to install a BBQ callee over a still-present one crashes safely instead of silently overwriting. The jsc.cpp and JSDollarVM.cpp changes (Wasm::Module broadcast across agents, $vm.wasmCalleeIDs returning [bbqID, omgID, bbqIsRetired]) are test instrumentation that let a regression test deterministically observe the retired state and force a re-tier.
Key code
Epilogue/Loop OSR must reuse the retired BBQCallee, not return nullptr
case OSRFor::Epilogue:
case OSRFor::Loop: {
// If there's a retired but not-yet-collected BBQCallee, use it. ... Not using it means a new
// BBQ compilation can be triggered, which can overwrite the retired BBQCallee, leaving
// updateCallsitesToCallUs() unable to patch those callers.
return calleeGroup.tryGetBBQCalleeForLoopOSRConcurrently(instance->vm(), functionIndex);
}
// WasmCalleeGroup::startInstallingCallee, BBQMode:
Locker bbqLocker { slot->m_bbqCalleeLock };
RELEASE_ASSERT(!slot->m_bbqCallee.get());
Fix approach
The patch closes the window by never returning nullptr from Epilogue/Loop OSR when a retired BBQCallee still exists; instead it resurrects that retired callee via tryGetBBQCalleeForLoopOSRConcurrently, so the slot keeps pointing at the callee whose code still has unpatched direct callers, and no overwriting BBQ compile is kicked off. A RELEASE_ASSERT(!m_bbqCallee) guard in startInstallingCallee turns any residual overwrite attempt into a controlled crash rather than a silent dangling-code condition. The Heap comment is clarified to state the crossModifyingCodeFence only covers already-repatched callsites and does not protect a live callee holding a not-yet-repatched direct call. After the fix, the hot IPInt OSR path for Epilogue/Loop collapses to ‘reuse the existing (possibly retired) BBQ callee’ rather than ‘return nullptr and allow a recompile’, eliminating the overwrite-then-free race.
Patch walkthrough
Source/JavaScriptCore/wasm/WasmIPIntSlowPaths.cpp— Merges the OSRFor::Epilogue and OSRFor::Loop cases so both return the retired BBQCallee from tryGetBBQCalleeForLoopOSRConcurrently instead of nullptr, preventing a replacing BBQ compile.Source/JavaScriptCore/wasm/WasmCalleeGroup.cpp— Adds a BBQMode RELEASE_ASSERT(!slot->m_bbqCallee.get()) under m_bbqCalleeLock in startInstallingCallee, so installing a BBQ callee over an existing one aborts safely.Source/JavaScriptCore/heap/Heap.cpp— Clarifying comment in finalizeWasmCalleeCleanup: the crossModifyingCodeFence only orders already-repatched callsites and does not protect a live callee holding a not-yet-repatched direct call to code about to be freed.Source/JavaScriptCore/tools/JSDollarVM.cpp— Adds $vm.wasmCalleeIDs(exportedFn) returning [bbqID, omgID, bbqIsRetired] (retired detected via calleeIsReferenced()==Indeterminate) so a test can observe the retired state and detect re-tier.Source/JavaScriptCore/jsc.cpp— Extends the agent-broadcast test harness to transfer a WebAssembly.Module (RefPtrWasm::Module) between agents, mirroring WebCore structured clone, to drive cross-agent re-tier scenarios in the regression test.
Background
Wasm tiering (IPInt -> BBQ -> OMG) — WebAssembly functions start interpreted (IPInt), tier up to the fast-baseline BBQ JIT, then to the optimizing OMG JIT; OSR (on-stack replacement) swaps a running frame from one tier to another.
BBQCallee and callee retirement — A Callee wraps a tier’s JIT code. When superseded, a BBQCallee is ‘retired’ via releaseBBQCallee and thereafter held only weakly by the CalleeGroup, so it can be destroyed while stale references linger.
Direct calls and updateCallsitesToCallUs() — Wasm callers are patched with direct jumps into a callee’s code; updateCallsitesToCallUs() repatches those callsites when a callee is replaced, but only if the slot still references the callee to be redirected.
CalleeGroup slot / startInstallingCallee — The CalleeGroup holds per-function slots for each tier’s callee under locks (m_bbqCalleeLock); startInstallingCallee writes a newly compiled callee into the slot, which is the overwrite point.
crossModifyingCodeFence / finalizeWasmCalleeCleanup — During GC, finalizeWasmCalleeCleanup frees retired callees’ executable memory; the cross-modifying code fence only synchronizes already-repatched callsites, not a live, un-repatched direct caller.
Vulnerability window
- Setup — A wasm function is exercised enough to compile and then retire a BBQCallee while callers still hold direct calls into its code.
- Trigger — IPInt hits Epilogue/Loop OSR; pre-patch it returns nullptr, allowing a new BBQ compile.
- Overwrite — startInstallingCallee writes the new BBQCallee into the slot, so updateCallsitesToCallUs can no longer reach the retired callee’s stale callers.
- Free — GC’s finalizeWasmCalleeCleanup frees the retired callee’s JIT code despite the un-repatched direct caller.
- Use — A caller executes its stale direct call into freed executable memory (UAF of JIT code).
Proof of concept
No full regression test is included in the diff, but it ships the instrumentation a PoC/test needs: $vm.wasmCalleeIDs() to detect that a BBQ callee is retired (bbqIsRetired) and to observe tier changes by ID equality, plus $vm agent-broadcast of a WebAssembly.Module across workers. A trigger (inferred) drives a function to retire its BBQ callee (tier to OMG while direct callers remain), then forces Epilogue/Loop OSR to provoke a fresh BBQ compile that overwrites the retired callee, then induces GC and calls through the stale caller. The web path is ordinary WebAssembly plus multi-worker execution to force concurrent re-tier (inferred).
Exploitation
- Reach — Pure WebAssembly reachable from any web page/worker; multi-agent (worker) execution helps force the concurrent retire/re-tier window.
- Trigger — Shape execution so a BBQ callee is retired with unpatched direct callers, then force Epilogue/Loop OSR to spawn a replacing BBQ compile that overwrites it.
- Primitive — Use-after-free of freed executable JIT memory via a stale direct call; the freed region is call-target memory, i.e. control-flow-relevant.
- Escalation (inferred) — Reclaiming the freed JIT allocation with attacker-influenced bytes could steer the stale direct call; whether the window can be widened/repeated reliably and whether reclaimed bytes land where the direct call dispatches are the open questions gating RCE beyond a crash.
Detection & hunting
For defenders and detection engineers:
- BBQ install over existing callee —
- Call into freed wasm code —
- Retired-callee re-tier races —
Remaining risk in the patch
- Weak-reference retirement persists — The fix keeps reusing the retired callee rather than changing its weak-hold lifetime, so correctness depends on OSR always choosing the reuse path; any other site that can trigger a BBQ recompile for a function with a live retired callee could reintroduce the overwrite (the RELEASE_ASSERT guards only startInstallingCallee).
- Heap fence unchanged — The crossModifyingCodeFence behavior is only documented, not strengthened; it still does not protect a live caller holding a not-yet-repatched direct call, so the invariant rests entirely on never freeing a still-referenced callee.
- OMG path — The adjacent OMGMode branch in startInstallingCallee (retained) handles a related ‘OMG installed while BBQ retired’ case; whether the symmetric OMG/IPInt retirement paths are fully covered is not provable from this diff (inferred).
Audit directions
- Other OSR return-nullptr paths —
- Callee retirement vs direct callers —
- CalleeGroup slot invariants —