[2] DFG/FTL stack corruption from 9-argument ObjectDefinePropertyFromFields helper
Rated High because the diff repairs JIT-emitted code that pokes the 9th C-argument into a stack slot aliasing the lowest DFG spill slot; the corrupting bits are attacker-chosen EncodedJSValue descriptor encodings and the spilled value is later re-read by the same JIT code, yielding a controllable spill-slot clobber primitive inside WebContent.
ObjectDefinePropertyFromFields was calling an operation with 9 parameters, but ARM64 / x86_64 only support up to 8 / 6 register parameters respectively. The DFG JIT assumes runtime helpers never need stack arguments. The fix uses a scratch buffer for the descriptor fields.
Source/JavaScriptCore/dfg/DFGOperations.h
Source/JavaScriptCore/dfg/DFGSpeculativeJIT.cpp
JSTests/stress/object-define-property-fields-spilled-arg.js
Patch Details
The runtime helper signature changes from 9 individual EncodedJSValue arguments to a single EncodedJSValue* descriptorBuffer, reducing the call to 4 GPR arguments. DFGSpeculativeJIT::compileObjectDefinePropertyFromFields and FTL::LowerDFGToB3::compileObjectDefinePropertyFromFields are reworked to acquire a ScratchBuffer of Node::numberOfDescriptorSlots entries, store each descriptor field into the buffer at its Node::DescriptorSlot index, then pass the buffer pointer as the fourth argument. The runtime helper decodes the slots back inside an ActiveScratchBufferScope (so the GC scans the buffer).
Violation of the JIT slow-path calling-convention invariant that runtime helpers must fit in argument registers, causing a stack-passed argument to alias a JIT spill slot.
Background
In JSC's optimising JITs, runtime helpers (operationXxx) are called as ordinary C functions from JIT-emitted code. The JIT relies on the constant maxFrameExtentForSlowPathCall to reserve outgoing-argument stack space when entering an OSR-able region; on ARM64 and x86_64 it is 0, encoding the invariant that no helper passes arguments on the stack. Spill slots are where the DFG places JSValues that did not fit in physical registers; they live near the current stack pointer.
ARM64 passes the first 8 integer arguments in x0–x7; x86_64 SysV passes the first 6 in rdi/rsi/rdx/rcx/r8/r9. Further arguments are placed on the stack starting at [sp + 0]. A ScratchBuffer is a VM-owned, GC-scanned scratch region used to hand arbitrary data to runtime helpers without consuming arg registers, and ActiveScratchBufferScope marks the buffer as live for conservative scanning during the helper's execution.
Analysis
operationObjectDefinePropertyFromFields was declared with 9 pointer-sized arguments. On x86_64 arguments 7–9 spilled to the stack at [sp + 0], [sp + 8], [sp + 16]. Because maxFrameExtentForSlowPathCall is 0 on these targets, the compiler does not reserve any space below the stack pointer for outgoing C-call stack arguments — the DFG instead uses [sp + 0..] (and the words just below the current SP) as the lowest spill slots for live JIT values. So when the JIT generated the call, the poked stack arguments overwrote one or more spill slots that held in-flight JSValues.
The test demonstrates the corruption: a value spilled before the call is later consumed by ValueAdd, but the consumer sees the corrupted slot and crashes inside operationValueAddNotNumber. The trigger uses the DFG/FTL specialisation that decomposes a constant-shape descriptor literal into six fields (ObjectDefinePropertyFromFields node) and exploits a hot function that holds several JSValues live across the defineProperty call (forcing spills) while calling Object.defineProperty with a descriptor literal whose value/get/set slot carries an attacker-chosen 64-bit bit pattern.
The corrupting bits are attacker-controlled — they are the EncodedJSValue encodings of the descriptor's value, writable, enumerable, configurable, get, and setter slots. If the spilled value happens to be a pointer-typed JSValue (object pointer, boxed integer, double), the corruption would replace it with a forged JSValue. Subsequent JIT code that re-reads the spill slot would dereference the attacker-chosen bit pattern. Where weaponisation is possible, the path mirrors classic JSC type-confusion exploitation: fabricate a fake JSCell pointer, induce the JIT to dereference it as the original type, and lift to arbitrary read/write inside WebContent. Exploitation still requires a separate sandbox escape to reach the host.
This vulnerability weakened type safety inside the WebContent renderer along any DFG/FTL code path that hits Object.defineProperty with a decomposed descriptor literal in hot code. The pattern is recurring in optimising JITs: a runtime helper's C signature grows by one argument over time, and the JIT's calling-convention invariants are not re-checked. The invariant maxFrameExtentForSlowPathCall == 0 is a global property of the build, not a per-call assertion — so adding a 9th-argument helper compiles cleanly, runs cleanly on most inputs, and silently corrupts spill slots only when the surrounding code happens to spill.
Audit directions
- JIT slow-path helpers whose C signatures exceed the architecture's register-argument count silently corrupt spill slots because
maxFrameExtentForSlowPathCallis 0. Audit everyJSC_DECLARE_JIT_OPERATIONandJSC_DECLARE_NOEXCEPT_JIT_OPERATIONdeclaration: grep for declarations with more than 6 parameters on x86_64 or more than 8 on ARM64. Start withSource/JavaScriptCore/dfg/DFGOperations.h,Source/JavaScriptCore/ftl/FTLOperations.h, andSource/JavaScriptCore/jit/JITOperations.h. For any over-budget helper, verify the call site uses a scratch buffer rather than direct pass-by-value of all fields. - Variadic-shape helpers added incrementally. A helper that grows from N to N+k arguments over time can quietly cross the register-argument budget. Investigate the git history of operation signatures over the past two years and re-check current arg counts. Specifically examine
operationDefineDataProperty,operationDefineAccessorProperty,operationPutByVal*WithThis, and any helper handling property-descriptor decomposition. - Architectural enforcement of
maxFrameExtentForSlowPathCall. Investigate whether a build-time assertion (e.g.,static_assert(helperArgCount <= registerArgCount)) could be added to theJSC_DECLARE_JIT_OPERATIONmacro expansion so that adding an over-budget helper fails to compile. ExamineSource/JavaScriptCore/jit/JITOperations.handMaxFrameExtentForSlowPathCall.hfor the right enforcement point. - Hand-written stubs and LLInt/thunk paths. Audit
Source/JavaScriptCore/llint/LLInt stubs, thunk generators inSource/JavaScriptCore/jit/ThunkGenerators.cpp, and Wasm slow-path calls (Source/JavaScriptCore/wasm/WasmSlowPaths.cpp) for any direct C-call with more arguments than the platform register budget.