Medium CVSS 7.5 webkit UAF 🔧 Commit mapped

Overview

Medium
Severity
7.5
CVSS
No
Exploited ITW
Fixed
Fix Status
DescriptionProcessing maliciously crafted web content may lead to an unexpected Safari crash
ComponentJSC Wasm
Bug ClassUAF
Tracker307669
Fix commit9a16de46f205 (WebKit/WebKit) +26/-3
CWECWE-119, CWE-120 (Buffer bounds error, Buffer overflow)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N
CISA KEVNot listed
CreditedDo Young Park
Disclosed2026-05-11

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.

Key insight
When repatching a callee’s callsites, keep the owning object (BBQCallee) alive for the whole operation; pinning only the weakly-held OMGOSREntryCallee missed the case where the owner is freed.

Attack Path

  1. Instantiate a tiered WebAssembly module JS builds and runs a module so functions are compiled to BBQ and OMG tiers with OSR-entry callees.
  2. Trigger callsite repatching Tier-up/replacement causes CalleeGroup::updateCallsitesToCallUs to collect and repatch callsites for a callee being destroyed.
  3. 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.
  4. Use-after-free during repatch Repatching dereferences the freed callee, crashing the process (now prevented by pinning the BBQCallee/OMGOSREntryCallee).

Impact Assessment

A use-after-free during wasm callsite repatching (callees freed by wasm threads mid-operation); medium crash, a UAF that could be shaped with tier-up timing.

Changed Functions

FunctionChangeNotes
CalleeGroup::updateCallsitesToCallUs
Source/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 collection
    Audit CalleeGroup for weak refs dereferenced between collection and repatch; ensure keep-alive spans the operation.
  • Owner vs owned lifetime
    Where an owned object is kept alive via its owner, verify the owner (not just the owned) is pinned.
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);
         }
Loading diff…

Original Bug Report

The reporter's bug is still restricted on the tracker.