← All reports

[JSC] BBQCallee should be kept alive between callsite collection and repatch

HighJSC WebAssembly runtime —UAF

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

Severity: High | Component: JSC WebAssembly runtime — Wasm::CalleeGroup | 9a16de4 | Bugzilla 307669

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

// This is necessary since Callees are released under `Heap::stopThePeriphery()`, but that only stops JS compiler
- // threads and not wasm ones. So the OMGOSREntryCallee could die between the time we collect the callsites and when
- // we actually repatch its callsites.
+ // threads and not wasm ones. So a weakly held BBQCallee and its OMGOSREntryCallee could die between the time we
+ // collect the callsites and when we actually repatch its callsites. Since BBQCallee owns OMGOSREntryCallee,
+ // keeping BBQCallee alive is enough to ensure that both are alive for the required duration.
+ //
+ // There is however an edge case here - it can happen that a BBQCallee has been freed but its OMGOSREntryCallee
+ // has been added to the pending-destruction set and not yet free'd. This means that m_osrEntryCallees will still
+ // hold a weak ref to it. In this scenario, BBQCallee won't be kept alive since it does not exist so we manually
+ // have to keep the OMGOSREntryCallee alive separately. This should only be done in this scenario else we will
+ // end up with multiple owners for OMGOSREntryCallee.
+
// FIXME: These inline capacities were picked semi-randomly. We should figure out if there's a better number.
+ Vector<Ref<BBQCallee>, 4> keepAliveBBQCallees;
Vector<Ref<OMGOSREntryCallee>, 4> keepAliveOSREntryCallees;
Vector<Callsite, 16> callsites;
+ bool bbqCalleeKeptAlive = false;
+ UNUSED_VARIABLE(bbqCalleeKeptAlive);
#if ENABLE(WEBASSEMBLY_BBQJIT)
// This callee could be weak but we still need to update it since it could call our BBQ callee
// that we're going to want to destroy.
@@
if (bbqCallee) {
collectCallsites(bbqCallee.get());
ASSERT(!bbqCallee->osrEntryCallee() || m_osrEntryCallees.find(callerIndex) != m_osrEntryCallees.end());
+ keepAliveBBQCallees.append(bbqCallee.releaseNonNull());
+ bbqCalleeKeptAlive = true;
}
#endif
if (auto iter = m_osrEntryCallees.find(callerIndex); iter != m_osrEntryCallees.end()) {
if (RefPtr callee = iter->value.get()) {
collectCallsites(callee.get());
- keepAliveOSREntryCallees.append(callee.releaseNonNull());
+ // 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);
}

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

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.

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.

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.