CVE-2026-43658
Overview
Background
- BBQ / OMG JIT callees
- Wasm tiered-compilation callees; BBQCallee owns its OMGOSREntryCallee.
- Weak vs strong reference
- m_osrEntryCallees holds weak refs; keeping the owning BBQCallee alive keeps both alive.
- Callsite repatching
- updateCallsitesToCallUs collects callsites then repatches them, needing the callees alive throughout.
Root Cause Analysis
This fixes a use-after-free in WebAssembly callsite repatching (WasmCalleeGroup). CalleeGroup::updateCallsitesToCallUs collects callsites that call a callee it is about to destroy and then repatches them. As the comment notes, Callees are released under Heap::stopThePeriphery(), which stops JS compiler threads but NOT wasm compiler threads, so a weakly-held BBQCallee and the OMGOSREntryCallee it owns could be freed between the moment their callsites are collected and the moment they are actually repatched — dereferencing them during repatch is a use-after-free.
Before the fix only the OMGOSREntryCallees were pinned (keepAliveOSREntryCallees).
The fix additionally pins the BBQCallee for the duration by appending it to keepAliveBBQCallees (bbqCallee.releaseNonNull()); because BBQCallee owns the OMGOSREntryCallee, keeping the BBQCallee alive keeps both alive. To avoid creating a second owner, the OMGOSREntryCallee is separately kept alive only in the edge case where the BBQCallee has already been freed while its OMGOSREntryCallee sits in the pending-destruction set (guarded by bbqCalleeKeptAlive).
The restored invariant is that any callee whose callsites are being repatched stays alive across the whole operation. INFERENCE: the exact field dereferenced after free is in the repatch/collectCallsites path; the commit establishes the missing keep-alive.
Attack Path
- Instantiate a tiered WebAssembly module JS builds and runs a module so functions are compiled to BBQ and OMG tiers with OSR-entry callees.
- Trigger callsite repatching Tier-up/replacement causes CalleeGroup::updateCallsitesToCallUs to collect and repatch callsites for a callee being destroyed.
- Free a weakly-held callee concurrently Because wasm compiler threads are not stopped by stopThePeriphery(), a weakly-held BBQCallee (and its owned OMGOSREntryCallee) is released between callsite collection and repatch.
- Use-after-free during repatch Repatching dereferences the freed callee, crashing the process (now prevented by pinning the BBQCallee/OMGOSREntryCallee).
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
CalleeGroup::updateCallsitesToCallUsSource/JavaScriptCore/wasm/WasmCalleeGroup.cpp |
modified | Adds keepAliveBBQCallees and pins each collected BBQCallee (releaseNonNull) for the repatch duration; only separately pins the OMGOSREntryCallee in the edge case where the owning BBQCallee is already freed, avoiding a double owner. |
Files Changed
Source/JavaScriptCore/wasm/WasmCalleeGroup.cpp
Audit Directions
- Weakly-held callees used after collectionAudit CalleeGroup for weak refs dereferenced between collection and repatch; ensure keep-alive spans the operation.
- Owner vs owned lifetimeWhere an owned object is kept alive via its owner, verify the owner (not just the owned) is pinned.
Patch
diff --git a/Source/JavaScriptCore/wasm/WasmCalleeGroup.cpp b/Source/JavaScriptCore/wasm/WasmCalleeGroup.cpp
index 3b271fc5daf3..4cf13d7638e8 100644
--- a/Source/JavaScriptCore/wasm/WasmCalleeGroup.cpp
+++ b/Source/JavaScriptCore/wasm/WasmCalleeGroup.cpp
@@ -345,9 +345,18 @@ void CalleeGroup::updateCallsitesToCallUs(const AbstractLocker& locker, CodeLoca
};
// 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;
@@ -373,6 +382,8 @@ void CalleeGroup::updateCallsitesToCallUs(const AbstractLocker& locker, CodeLoca
if (!tuple)
return;
+ 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.
@@ -384,6 +395,8 @@ void CalleeGroup::updateCallsitesToCallUs(const AbstractLocker& locker, CodeLoca
if (bbqCallee) {
collectCallsites(bbqCallee.get());
ASSERT(!bbqCallee->osrEntryCallee() || m_osrEntryCallees.find(callerIndex) != m_osrEntryCallees.end());
+ keepAliveBBQCallees.append(bbqCallee.releaseNonNull());
+ bbqCalleeKeptAlive = true;
}
#endif
#if ENABLE(WEBASSEMBLY_OMGJIT)
@@ -391,7 +404,17 @@ void CalleeGroup::updateCallsitesToCallUs(const AbstractLocker& locker, CodeLoca
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);
}