← All issues

[6] LLInt stack overflow guard bypass in arity fixup

Severity: High | Component: JSC LLInt | 2a07f26

Rated High because the observable effect is a stack write past the soft stack limit reachable from any web page via crafted JavaScript, and the mechanism (computing the overflow bound from cfr instead of sp, missing the entire local frame extent) is confirmed at confidence 0.92 by the diff across three files — the JIT path was already correct, confirming this is a real safety gap.

The stack overflow check in LLInt arity fixup was computed against cfr (the call frame register) instead of sp (the stack pointer). Since arity fixup occurs after the callee's local frame is set up, sp is already well below cfr. The fix changes the base to sp in both the C++ slow path and the assembly fast path.

Source/JavaScriptCore/llint/LLIntSlowPaths.cpp

- int padding = numberOfStackPaddingSlotsWithExtraSlots(newCodeBlock, callFrame->argumentCountIncludingThis());
- Register* newStack = callFrame->registers() - WTF::roundUpToMultipleOf(stackAlignmentRegisters(), padding);
- if (!vm.ensureJSStackCapacityFor(newStack)) [[unlikely]]
+ int slotsToAdd = numberOfStackPaddingSlotsWithExtraSlots(newCodeBlock, callFrame->argumentCountIncludingThis());
+ Register* newStackPointer = callFrame->registers() - WTF::roundUpToMultipleOf(stackAlignmentRegisters(), slotsToAdd) - newCodeBlock->numCalleeLocals() - maxFrameExtentForSlowPathCallInRegisters;
+ if (!vm.ensureJSStackCapacityFor(newStackPointer)) [[unlikely]]
return -1;
- return padding;
+ return slotsToAdd;

Source/JavaScriptCore/llint/LowLevelInterpreter64.asm

- subp cfr, t3, t5
+ subp sp, t3, t5

JSTests/stress/stack-overflow-llint-large-params-and-large-locals.js

+ var args = [];
+ for (var i = 0; i < 20; i++) {
+ args[i] = args.toLocaleString() + "x" + i;
+ }
+ var min = new Function(args, "return Math.min(" + args.join(",") + ");");
+ shouldThrow(() => {
+ min(0);
+ }, `RangeError: Maximum call stack size exceeded.`);

Three files are changed. In LLIntSlowPaths.cpp, arityCheckFor() now subtracts newCodeBlock->numCalleeLocals() and maxFrameExtentForSlowPathCallInRegisters from the computed new stack pointer before passing it to vm.ensureJSStackCapacityFor(). In LowLevelInterpreter64.asm and LowLevelInterpreter32_64.asm, the inline arity check macro changes subp cfr, t3, t5 to subp sp, t3, t5, so the overflow comparison uses the actual stack pointer rather than the frame pointer.

  Before:                              After:
  cfr ──────────── (frame pointer)     cfr ──────────── (frame pointer)
  │                                    │
  │  numCalleeLocals                   │  numCalleeLocals
  │  + maxFrameExtent                  │  + maxFrameExtent
  │                                    │
  sp ──────────── (stack pointer)      sp ──────────── (stack pointer)
  │                                    │
  │  arity padding (slotsToAdd)        │  arity padding (slotsToAdd)
  │                                    │
  └── old check: cfr - slotsToAdd     └── new check: sp - slotsToAdd
       (WRONG: missed frame gap)            (CORRECT: from actual sp)

When a JavaScript function declares N parameters but is called with fewer than N arguments, JSC must pad the call frame with undefined slots so the callee sees a full argument vector. This is called "arity fixup." In the LLInt, this happens in the functionArityCheck macro (assembly) and the arityCheckFor slow path (C++).

In JSC's calling convention, cfr (call frame register) points to the top of the logical call frame header, while sp (stack pointer) points to the actual bottom of the allocated frame — below all callee locals and scratch space. After frame setup, sp = cfr - numCalleeLocals - maxFrameExtent. JSC maintains a per-VM soft stack limit; before growing the stack, the engine checks that the projected new stack pointer does not exceed this limit. If it does, a RangeError (stack overflow) is thrown.

Stack overflow guard computed against the frame pointer instead of the stack pointer, failing to account for already-allocated local frame space during LLInt arity fixup.

By computing the overflow bound from cfr, the check overestimated the remaining stack capacity by exactly the size of the local frame (numCalleeLocals + maxFrameExtentForSlowPathCallInRegisters). When a function had both many parameters (triggering arity fixup because the caller passed fewer arguments) and many local variables (pushing sp far below cfr), the check would pass even though the actual stack pointer after arity fixup would extend past the soft stack limit. The commit message confirms the JIT path was already correct — this was an LLInt-only bug.

🔒

Detailed analysis of how the cfr/sp divergence creates a precise stack overwrite window, and what memory regions fall within the corruption range

Subscribe to read more

🔒

Multiple audit patterns identified for LLInt/JIT safety-check parity, with specific search targets across stack management code

Subscribe to read more