[JSC] BBQCallee should be kept alive between callsite collection and repatch
CVE: CVE-2026-43658 · Safari 26.5 · Released May 13, 2026 Impact: Processing maliciously crafted web content may lead to an unexpected Safari crash Apple's description: The issue was addressed with improved memory handling. Credit: Do Young Park
High. The window this patch closes was already documented in the file — someone had diagnosed it and pinned the wrong end of the ownership chain. Winning the race gets you a store through a code location inside a freed JIT allocation; the observed impact is a crash, and turning that into more needs allocator grooming the diff doesn't speak to.
WebAssembly in WebKit runs through three execution tiers, and every promotion between them leaves behind a bookkeeping problem: compiled callers still hold direct call instructions pointing at the entrypoint of the tier they were compiled against. Fixing those up means walking every caller, recording the machine-code addresses of those call instructions, and then going back and writing new targets into them. That two-phase shape — collect addresses, then store through them — is Wasm::CalleeGroup::updateCallsitesToCallUs, and its correctness rests on one invariant: every object whose generated code will be written to in phase two must still be alive when phase two runs.
The angle: A page that keeps wasm compilation busy while the collector releases callees can get the engine to write a pointer into a just-freed JIT code allocation.
Source/JavaScriptCore/wasm/WasmCalleeGroup.cpp
Patch Details
Three edits to a single function, all in Source/JavaScriptCore/wasm/WasmCalleeGroup.cpp.
The first is a new accumulator alongside the existing one: Vector<Ref<BBQCallee>, 4> keepAliveBBQCallees, declared next to keepAliveOSREntryCallees and Vector<Callsite, 16> callsites. Nothing in the pre-fix function held a strong reference to a BBQ callee past the statement that observed it.
The second is inside the per-caller body. After collectCallsites(bbqCallee.get()) records that callee's call-instruction addresses into callsites, the patch moves the local handle into the new vector with keepAliveBBQCallees.append(bbqCallee.releaseNonNull()) and sets a local bool bbqCalleeKeptAlive = true. The flag is declared and immediately handed to UNUSED_VARIABLE above the #if ENABLE(WEBASSEMBLY_BBQJIT) block, so a build with BBQ disabled — where nothing ever assigns it — compiles without a warning.
The third makes the pre-existing keep-alive conditional. The OSR-entry branch still resolves the weak handle out of m_osrEntryCallees and still collects its callsites, but the keepAliveOSREntryCallees.append(...) is now guarded by if (!bbqCalleeKeptAlive). The two added comment blocks state the reasoning directly: BBQCallee owns OMGOSREntryCallee, so retaining the owner already covers the owned object, and taking a second Ref would hand a supposedly singly-owned object a second independent owner. The unconditional path is kept only for the case where the BBQ callee is already gone while its OSR-entry callee is still resolvable.
The delta in retention coverage:
Before (per caller): After (per caller):
bbqCallee (RefPtr, local) bbqCallee ──► keepAliveBBQCallees
└─ collectCallsites └─ collectCallsites
└─ scope ends → ref dropped └─ held until function returns
osrCallee ──► keepAlive vector osrCallee ──► keepAlive vector
ONLY if no bbqCallee
Background
Wasm execution tiers. WebKit runs WebAssembly in three tiers: IPInt, an in-place interpreter that executes the wasm bytecode directly; BBQ, a fast baseline JIT; and OMG, the optimizing JIT. Each compiled tier is represented at runtime by a Callee object — BBQCallee, OMGCallee, OMGOSREntryCallee — and that object owns the generated machine code for its function.
OSR entry. On-stack replacement lets execution jump out of a loop that is already running in a lower tier and into freshly compiled higher-tier code, mid-iteration, without waiting for the function to return. OMGOSREntryCallee is the OMG-compiled body produced specifically for one such entry point. It is owned by the BBQCallee it was created from — one owner, by design, which is why the second half of this patch exists at all.
CalleeGroup. For each module and memory mode, CalleeGroup is the container that holds every callee for that module's functions. Two of its members matter here: m_optimizedCallees, a per-function tuple holding the BBQ and OMG callees behind m_bbqCalleeLock, and m_osrEntryCallees, a map from caller index to OSR-entry callee.
Callsite repatching. When a function is recompiled at a higher tier, every already-compiled caller still contains a direct call instruction whose immediate target is the old entrypoint. updateCallsitesToCallUs fixes that: it walks callers, collects the code locations of those call instructions into Vector<Callsite, 16> callsites, and then rewrites each one to point at the new entrypoint. Collection and rewriting are separate passes over the function body.
Weak vs. strong handles. m_bbqCallee and the values in m_osrEntryCallees are held weakly. Calling .get() on a weak handle returns a RefPtr — null if the object is already destroyed, otherwise a strong reference that keeps the object alive for exactly as long as that RefPtr local remains in scope. Ref and RefPtr are WebKit's reference-counted smart pointers; releaseNonNull() moves a known-non-null RefPtr into a Ref without touching the refcount. The important consequence is a scoping one: an upgraded weak handle protects the object until the end of the block that declared it, not until the last use of anything derived from it.
Heap::stopThePeriphery(). A GC phase that quiesces auxiliary threads so the collector can release objects safely. Per the comment that has sat in this function since before the patch, it stops JS compiler threads — but not wasm compilation threads.
UNUSED_VARIABLE. A WebKit macro that suppresses unused-variable diagnostics for a variable that is only read under some #if configurations.
Analysis
The bug is a use-after-free created by upgrading a weak handle for only the first phase of a two-phase collect-then-mutate operation.
wasm compilation / GC release updateCallsitesToCallUs
───────────────────────────── ──────────────────────────────────
phase 1: bbqCallee = m_bbqCallee.get()
collectCallsites(bbqCallee)
── lambda scope ends ──
RefPtr destructs, refcount--
last strong ref dropped
~BBQCallee() → JIT code freed
(owned OMGOSREntryCallee too)
phase 2: repatch each collected Callsite
store → freed code memory
Both columns run concurrently, and the middle column is the window. In phase one the function resolves each caller's BBQ callee out of the optimized-callee tuple — tuple->m_bbqCallee.get() under tuple->m_bbqCalleeLock — which upgrades the weak handle into a temporary RefPtr. collectCallsites(bbqCallee.get()) then walks that callee's generated code and records the addresses of the call instructions that need rewriting. And then the lambda returns. The RefPtr was a local; it destructs, the refcount drops, and from that instant nothing holds the callee. What survives into phase two is a Vector<Callsite> full of raw code locations — addresses, not owning references — pointing into memory whose only owner just let go.
That the free can actually happen concurrently was already known in this file. The comment above the keep-alive vectors, present before this patch, spells it out: callees are released under Heap::stopThePeriphery(), and that primitive stops JS compiler threads but not wasm ones. A wasm compilation thread, or the GC's release path itself, is free to drop the last reference to a BBQCallee in exactly the interval this function assumes is quiet. When the destructor runs it takes the generated machine code with it — and, because of the ownership edge, its OMGOSREntryCallee as well. Phase two then executes its repatch loop over the addresses collected earlier and performs a store through each one. The store lands in freed, potentially recycled, executable memory.
What makes this one worth reading twice is that the fix for this exact hazard was already sitting three lines below the bug. keepAliveOSREntryCallees exists precisely because someone previously walked this same reasoning and concluded that an object collected in phase one must be pinned until phase two finishes. They pinned the OMGOSREntryCallee. They did not pin the BBQCallee — which is the object that owns the OMGOSREntryCallee. The prior fix protected a child while leaving open a path for its parent to be destroyed underneath it.
The patch closes the window by moving the upgraded handle out of the lambda's scope and into keepAliveBBQCallees, a vector whose lifetime spans both phases. releaseNonNull() transfers the existing strong reference rather than taking a new one, so the object the repatch loop will write into is guaranteed live from the moment its callsites were observed until the function returns.
The !bbqCalleeKeptAlive guard on the older vector is not a second bug fix; it is what keeps the first fix from breaking the ownership model. Once the BBQ callee is retained, its owned OSR-entry callee is transitively protected, and appending an independent Ref to it would give a documented sole-ownership object two concurrent owners. So the manual retain is now scoped to the one case that genuinely needs it: the BBQ callee is already destroyed, but its OSR-entry callee is only pending destruction, so the weak handle in m_osrEntryCallees still resolves. There, no other strong reference exists and the explicit retain is the only thing holding the object up.
A weak handle upgraded only for phase one of a two-phase collect-then-repatch let a concurrent wasm thread free the JIT code that phase two was about to store into.
Insight
This is an incomplete-prior-fix pattern legible in the diff itself: the earlier keepAliveOSREntryCallees vector and its comment show the same window was diagnosed before and patched one level too low in the ownership chain — the owned object was pinned, the owner was left weak. When a fix pins one link of an ownership chain across a critical window, every other link in that chain earns the same audit, and the keep-alive lists that result should be made mutually exclusive along each ownership edge so that protecting an owner doesn't silently duplicate ownership of what it owns.
Audit directions
-
Weak handle upgraded for phase one, dereferenced in phase two. The invariant: anything a later phase will dereference must be retained from the moment it is observed until its last use, not until the end of the block that observed it. Narrow — audit the other weak-handle upgrades in
Source/JavaScriptCore/wasm/;tryGetReplacementConcurrentlyandtryGetBBQCalleeForLoopOSRConcurrentlyinWasmCalleeGroup.cppboth dotuple->m_bbqCallee.get()underm_bbqCalleeLock, andm_osrEntryCallees/m_jsToWasmCalleesare similar registries — for each, check whether the returnedRefPtroutlives every use of code pointers or entrypoints derived from it. Wider — the shape recurs with any deferred-use handle: an iterator or index cached from a weakly-keyedHashMap, a rawCodePtrorMacroAssemblerCodeRefcopied out of an object, aWeakPtrupgraded inside a lambda whose results escape into an outerVector; grep for localVector<...>accumulators populated inside a per-item lambda and consumed after the loop. Widest — the upgrade-then-drop-across-a-phase-boundary class holds anywhere weak references exist: Rust'sWeak::upgradewhoseArcis dropped before the collected data is used, Chromium'sbase::WeakPtrin post-task pipelines, Java'sWeakReference.get()results cached past the guarding scope. Match tells: narrow — a.get()-derivedRefPtrwhose scope ends before a second loop touches data it produced; wider — an accumulator vector whose elements are pointers or offsets rather than owning refs; widest — between the upgrade and the last use, is there any point where the strong count could reach zero on another thread? -
Quiescence primitives with incomplete thread coverage. A stop-the-world or safepoint mechanism treated as if it froze all mutators when a class of threads is exempt; the invariant is that code relying on a pause must enumerate exactly which threads that pause covers. Narrow — the comment in
updateCallsitesToCallUsstates thatHeap::stopThePeriphery()stops JS compiler threads but not wasm ones; trace the other callers and destruction paths running understopThePeriphery()and check which touch wasm-owned state (Wasm::Worklist,WasmMachineThreads.h, BBQ/OMG plan completion callbacks). Wider — the same class covers any "we hold the lock so nothing can change" assumption where a second lock or a lock-free path exists: look for functions takingm_lockinWasmCalleeGroupwhile other members are guarded by the per-tuplem_bbqCalleeLockorm_jsToWasmCalleesLock, and forAbstractLocker¶meters that document one lock while the body touches state owned by another. Widest — the partial-safepoint class lives in every managed runtime: V8's isolate-versus-shared-heap safepoints, HotSpot's thread-in-native states that don't participate in safepoints, Go's non-preemptible assembly regions. Match tells: narrow — a comment or lock name naming one thread class while the body manipulates objects mutated by another; wider — a function carrying oneAbstractLockerwhose body reads two differently-guarded members; widest — for any pause primitive, which thread categories are explicitly exempted, and can any of them free memory? -
Keep-alive lists that duplicate ownership along an ownership edge. Retaining both an owner and the object it exclusively owns gives the owned object two independent strong holders; temporary retention must be taken at exactly one point on each ownership chain. Narrow — grep
Source/JavaScriptCore/wasm/andSource/JavaScriptCore/jit/for localVector<Ref<...>>keep-alive or protector accumulators and check, for each pair of appended types, whether one owns the other;keepAliveBBQCallees/keepAliveOSREntryCalleeshere is the model and the!bbqCalleeKeptAliveguard is the shape of a correct resolution. Wider — the class shows up with any ad-hoc protection idiom:Ref protectedThis { *this }taken in both a caller and a callee that already protects its parent,RefPtrmembers added to defer destruction, scope guards releasing a resource the parent also releases; the tell in code-search results is two protector locals in one function whose types are related by aRef<T> m_childmember. Widest — the sole-ownership-contract-violated-by-a-temporary-co-owner class appears wherever a codebase documents unique ownership but the language permits shared handles: astd::shared_ptrhanded out from a documented unique owner, a RustArcclone escaping a module that treats itself as sole owner, an Objective-C strong local retained on an object whose lifetime is documented as owner-controlled. Match tells: narrow — twoappend(x.releaseNonNull())calls in one function on types linked by an ownership edge; wider — any protector local whose type is reachable as a member of another protector local's type; widest — does the documented sole owner still get to decide when this object dies, given this temporary reference?