← WebKit — 2026-W35

c1b3dfb8050145b00f0b79a23cd3c230fdf330a6  [JSC] UAF of JIT code: DFG/FTL DirectTailCall to a host function leaves the caller's CodeBlock unrooted during the call

severity high class LogicError confidence 0.65 signal score 6.8 Source/JavaScriptCore/dfg patch-now AI review PLAUSIBLE
Primitive: incorrect inlined tail-call path for native functions in DFG/FTL emitCall
Triage note: Under the security-accelerated branch the condition becomes `if (nativeFunction && !isTail && ...)` and the `if (isTail) { emitStoreCallSiteIndex; CallFrameShuffler(...).prepareForTailCall(); ... }` fast path (and the FTL direct-tail-call store) is removed, so native-function tail calls no longer take the inlined JIT path. A JIT miscompile of tail calls to native functions is a classic memory-corruption primitive in JavaScriptCore.
Structural signals: msg_strong (+3.0), area:Source/JavaScriptCore/dfg (+2.5), surgical (+1.3)

The bug at a glance

This is a JavaScriptCore JIT correctness bug in the DFG and FTL backends: the inlined fast path that invokes a native (built-in C++) function without going through the call-link machinery was also entered for tail calls, so a native-function tail call ran CallFrameShuffler::prepareForTailCall() (DFG) or an immediate jit.ret() (FTL) over a frame that the inlined native-call sequence had set up, rather than over a frame prepared by the generic DirectTailCall linking path. The fix adds a !isTail guard so native tail calls fall back to the linked DirectTailCall path. A tail-call frame mis-shuffle in JSC is a classic, high-value memory-corruption primitive (attacker-steerable stack/register overwrite) reachable from ordinary renderer-process JavaScript, so high severity is reasonable; however, the diff proves only that the inlined native tail path was disabled, so the precise corruption and any escalation to RCE is (inferred).

The interesting hook is that JSC’s ‘invoke a native function inline, skipping CallLinkInfo’ optimization was being reused for the tail-call variant, so a tail-positioned call to a built-in got frame-shuffled as if it were an ordinary JS tail call even though the inlined native target’s prologue/epilogue and register state do not match that layout.

Root cause

The violated invariant is that the inlined native-call fast path in SpeculativeJIT::emitCall (Source/JavaScriptCore/dfg/DFGSpeculativeJIT64.cpp) is only sound for non-tail calls: tail calls must be set up through CallLinkInfo/DirectTailCall so that the frame shuffler and the linker agree on the caller-frame layout, callee-save restoration and return address. Pre-patch, the guard was if (nativeFunction && !vm().isDebuggerHookInjected()) with no exclusion of tail calls. The vulnerable (RC_REMOVE_SEC_ACCEL_3) sequence is: 1. emitCall resolves the callee executable and, when it is a NativeExecutable, reads nativeFunction = uncheckedDowncast<NativeExecutable>(executable)->function(). 2. Because the guard omits !isTail, the inlined branch is entered even for a tail call. 3. It builds emitCallTarget (emitFunctionPrologue, stores CallFrameSlot::codeBlock = nullptr, … emitFunctionEpilogue), an inline invocation of the native function that bypasses the call-link stubs. 4. For isTail, it runs emitStoreCallSiteIndex(callSite) then CallFrameShuffler(*this, shuffleData).prepareForTailCall() and returns, reusing/overwriting the caller frame to perform a jump-style tail transfer into that inlined native target. 5. The shuffler lays out arguments and tears down the current frame for a generic tail call, but the inlined native path’s prologue/epilogue and saved-register expectations differ, so the resulting frame, callee-saves and/or return slot are left in an inconsistent state once the native function runs and returns.

The FTL lowering in Source/JavaScriptCore/ftl/FTLLowerDFGToB3.cpp has the matching defect: in the direct-tail-call setup (after shuffleData.setupCalleeSaveRegisters(...)), the vulnerable branch did if (nativeFunction && !vm->isDebuggerHookInjected()) { jit.store32(callSiteIndex ...); jit.ret(); return; } — emitting an immediate return for a native-function direct tail call instead of registering a m_directCallLinkInfos.add(...) entry with setCallType(CallLinkInfo::DirectTailCall). That short-circuits the normal DirectTailCall wiring.

Because the triage bugclass is LogicError (a JIT miscompile) and the diff only demonstrates that the inlined native tail path was removed, I keep the proven defect at the level of an incorrect tail-call frame construction for native callees; the step from there to a concrete, controllable stack/register overwrite — the textbook JSC tail-call corruption primitive — is consistent with the code but is (inferred) from this diff alone.

Key code

DFG guard gains !isTail; the inlined isTail/prepareForTailCall fast path is removed for native functions

// vulnerable (RC_REMOVE_SEC_ACCEL_3):
-        if (nativeFunction && !vm().isDebuggerHookInjected()) {
+        if (nativeFunction && !isTail && !vm().isDebuggerHookInjected()) {
             auto emitCallTarget = [&]() { emitFunctionPrologue(); ... };
-            if (isTail) {
-                emitStoreCallSiteIndex(callSite);
-                CallFrameShuffler(*this, shuffleData).prepareForTailCall();
-                ...
-                useChildren(node);
-                return;
-            }

Fix approach

The patch adds !isTail to the inlined-native-call guard in DFGSpeculativeJIT64, so native-function tail calls never enter the inlined fast path, and it deletes the inlined if (isTail) { emitStoreCallSiteIndex; CallFrameShuffler(...).prepareForTailCall(); ...; return; } block; in FTLLowerDFGToB3 it deletes the nativeFunction early jit.ret() shortcut. With those removed, a native-function tail call falls through to the generic path that creates a m_directCallLinkInfos entry and calls setCallType(CallLinkInfo::DirectTailCall), so the frame is built by the linked DirectTailCall machinery whose shuffling matches the native call convention. The semantic change is that native functions lose the inlined tail-call shortcut (a minor throughput cost on tail-positioned built-in calls); after the fix the DFG/FTL hot path for a native tail call collapses to an ordinary linked DirectTailCall rather than an inline prepareForTailCall/ret.

Patch walkthrough

  • Source/JavaScriptCore/dfg/DFGSpeculativeJIT64.cpp — In SpeculativeJIT::emitCall the native-function inlined guard becomes nativeFunction && !isTail && !vm().isDebuggerHookInjected(), and the inlined tail-call block (emitStoreCallSiteIndex + CallFrameShuffler::prepareForTailCall + return) is removed, forcing native tail calls down the linked call path.
  • Source/JavaScriptCore/ftl/FTLLowerDFGToB3.cpp — In the direct-tail-call lowering the nativeFunction short-circuit that stored the call-site index and emitted jit.ret() is removed, so native direct tail calls instead register a DirectTailCall CallLinkInfo like non-native direct tail calls.

Background

DFG / FTL — JavaScriptCore’s optimizing JIT tiers. DFG is the first optimizing tier; FTL lowers the DFG IR to the B3 backend for the highest-tier code. emitCall / the direct-tail-call lowering emit the machine code for JS call nodes.

Tail call (isTail) — A call in tail position that reuses (destroys) the current call frame and jumps to the callee instead of pushing a new frame, so the callee returns directly to the caller’s caller. Correct frame teardown is layout-sensitive.

CallFrameShuffler / prepareForTailCall — A JSC helper that relocates outgoing arguments and rewrites the current frame into the callee’s frame for a tail call, then leaves control ready to jump to the target; it assumes a specific frame/register layout.

NativeExecutable — The Executable subtype wrapping a built-in C++ function pointer (NativeExecutable::function()). The inlined fast path lets the JIT call that C++ function directly, bypassing CallLinkInfo link stubs.

CallLinkInfo / DirectTailCall — The call-linking metadata and call type used for the generic (non-inlined) call path. setCallType(DirectTailCall) selects a direct tail call whose stubs set up the frame consistently with the shuffler.

Vulnerability window

  1. Setup — Attacker JS defines a hot function whose body ends in a tail-positioned call to a native built-in (e.g. return someBuiltin(args)).
  2. Trigger — The function is executed enough to tier up to DFG and/or FTL; the optimizing JIT compiles the tail call node.
  3. Miscompile — Pre-patch, because the callee is native and isTail is set, the inlined fast path runs CallFrameShuffler::prepareForTailCall() (DFG) / jit.ret() (FTL) over the inlined native-call frame.
  4. Corruption — The shuffled/torn-down frame does not match the native call sequence, leaving the caller frame, callee-saves or return address inconsistent (inferred as controllable).
  5. Fallout — On native return control resumes on a corrupted frame, yielding a crash or steered control/data corruption.

Proof of concept

No regression test ships in the diff. Conceptually: define function f(){ return Math.max(1,2); } (or any native built-in) in strict tail position, call f in a loop until it tiers up to DFG/FTL, so the native-function tail call is compiled through the (pre-patch) inlined prepareForTailCall path. A reliable crashing/controlled PoC would require matching the exact callee and argument shuffle; the web path (strict-mode tail-position call to a built-in under JIT) is (inferred).

Exploitation

  1. Reach — Pure renderer-process JavaScript reaches the DFG/FTL emitCall paths; no special permission needed.
  2. Trigger — Force tier-up of a function that tail-calls a native built-in so the inlined native tail path is emitted.
  3. Primitive — Primary: an incorrectly shuffled tail-call frame for a native callee. Secondary: inconsistent callee-save/return-address state after the native function returns.
  4. Open questions — Escalation beyond a crash hinges on whether the argument/register mismatch is attacker-shapeable (choice of built-in and arg count) into a controlled return address or stack overwrite, and whether the window is deterministically reproducible — neither is provable from this diff.

Detection & hunting

For defenders and detection engineers:

  • Crashes inside CallFrameShuffler / tail-call thunks on native callees — Look for faults with corrupted frame pointers or return addresses in DFG/FTL-compiled frames whose callee is a NativeExecutable.
  • JIT dumps showing inlined tail call to a native function — Audit DFG/FTL disassembly (JSC_dumpDFGDisassembly / FTL) for a prepareForTailCall or immediate ret on a path guarded only by nativeFunction.
  • Builds defining RC_REMOVE_SEC_ACCEL_3 — The vulnerable behavior is selected by that macro; flag any build configuration enabling it.

Remaining risk in the patch

  • Proof is limited to disabling the inlined path — The diff shows the inlined native tail path removed but does not include CallFrameShuffler::prepareForTailCall or the native call sequence, so the exact corruption mechanics and attacker controllability are unverified from this diff.
  • Other inlined tail paths — The same inlining pattern could exist for other callee kinds or in the baseline/LLInt tiers; only the native-function DFG/FTL guards are touched here.

Audit directions

  • emitCall inlined fast paths — Review every JSC backend site that inlines a call and check that each has an explicit !isTail guard or a correct tail-call variant, not a shared path.
  • CallFrameShuffler callers — Enumerate all prepareForTailCall() call sites and confirm the frame they operate on was built by a compatible (linked) setup.
  • Native built-in calls across tiers — Check LLInt/baseline and polymorphic/varargs native call paths for analogous tail-call handling.

Before / after

Loading diff…