← All issues

[2] DFG/FTL stack corruption from 9-argument ObjectDefinePropertyFromFields helper

Severity: High | Component: JSC DFG/FTL JITs | 59a20dd

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

-JSC_DECLARE_JIT_OPERATION(operationObjectDefinePropertyFromFields, void, (JSGlobalObject*, JSObject*, EncodedJSValue, EncodedJSValue, EncodedJSValue, EncodedJSValue, EncodedJSValue, EncodedJSValue, EncodedJSValue));
+JSC_DECLARE_JIT_OPERATION(operationObjectDefinePropertyFromFields, void, (JSGlobalObject*, JSObject*, EncodedJSValue, EncodedJSValue*));

Source/JavaScriptCore/dfg/DFGSpeculativeJIT.cpp

- JSValueOperand enumerable(this, m_graph.varArgChild(node, 2));
- ...
- JSValueOperand setter(this, m_graph.varArgChild(node, 7));
+ GPRTemporary buffer(this);
+ constexpr size_t scratchSize = sizeof(EncodedJSValue) * Node::numberOfDescriptorSlots;
+ ScratchBuffer* scratchBuffer = vm().scratchBufferForSize(scratchSize);
+ EncodedJSValue* scratchData = static_cast<EncodedJSValue*>(scratchBuffer->dataBuffer());
+ move(TrustedImmPtr(scratchData), bufferGPR);
+ for (unsigned slot = 0; slot < Node::numberOfDescriptorSlots; ++slot) {
+ JSValueOperand operand(this, m_graph.varArgChild(node, slot + 2));
+ storeValue(operand.jsValueRegs(), Address(bufferGPR, sizeof(EncodedJSValue) * slot));
+ operand.use();
+ }
- callOperation(operationObjectDefinePropertyFromFields, ..., enumerableRegs, configurableRegs, valueRegs, writableRegs, getterRegs, setterRegs);
+ callOperation(operationObjectDefinePropertyFromFields, LinkableConstant::globalObject(*this, node), targetGPR, keyRegs, bufferGPR);

JSTests/stress/object-define-property-fields-spilled-arg.js

+// The 9th argument was poked to [sp + 0], but maxFrameExtentForSlowPathCall is 0 on
+// those targets, so [sp + 0] aliased the lowest spill slot and corrupted whatever was
+// spilled there. Here, the spilled value happens to be the function argument that
+// later feeds a ValueAdd; reading the corrupted slot crashed in operationValueAddNotNumber.

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.

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 x0x7; 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.

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 calling-convention assumption broken here is global to the JIT — explore what a stack-poked argument can do when the surrounding code spills, and what the attacker controls in the overwriting bits.

Subscribe to read more

🔒

Four reusable audit patterns identified, with concrete starting files and a class-of-helpers worth re-counting against the per-architecture register budget.

Subscribe to read more