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
Sub-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
if
Source/JavaScriptCore/wasm/WasmCalleeGroup.cpp
modified

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.