c1b3dfb8050145b00f0b79a23cd3c230fdf330a6 [JSC] UAF of JIT code: DFG/FTL DirectTailCall to a host function leaves the caller's CodeBlock unrooted during the call
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.
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 becomesnativeFunction && !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 thenativeFunctionshort-circuit that stored the call-site index and emittedjit.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
- 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)). - Trigger — The function is executed enough to tier up to DFG and/or FTL; the optimizing JIT compiles the tail call node.
- Miscompile — Pre-patch, because the callee is native and
isTailis set, the inlined fast path runs CallFrameShuffler::prepareForTailCall() (DFG) / jit.ret() (FTL) over the inlined native-call frame. - 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).
- 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
- Reach — Pure renderer-process JavaScript reaches the DFG/FTL emitCall paths; no special permission needed.
- Trigger — Force tier-up of a function that tail-calls a native built-in so the inlined native tail path is emitted.
- Primitive — Primary: an incorrectly shuffled tail-call frame for a native callee. Secondary: inconsistent callee-save/return-address state after the native function returns.
- 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
!isTailguard 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.