← WebKit Silent-Fix Report — 2026-W21

59a20ddcb9  [JSC] Use scratch buffer for ObjectDefinePropertyFromFields

severity high class OOB confidence 0.65 JSC DFG/FTL exploitable-grade
Yusuke Suzuki Tue May 19 04:10:33 2026 -0700 full: 59a20ddcb9ca0eecd9bd88bce32ffc8900c4fbcb bug report ↗ view on GitHub ↗
Primitive: ObjectDefinePropertyFromFields overran ABI arg-register budget
Triage note: Register/ABI OOB in JIT.
Contents

The bug at a glance

A JIT-generated slow-path call passed nine 64-bit arguments to a C operation while both ARM64 (8 argument registers) and x86_64 (6) can only place a subset in registers, and the DFG/FTL back ends assume the platform never spills call arguments to the stack (maxFrameExtentForSlowPathCall is 0 on those targets). The ninth argument was therefore poked to [sp + 0], aliasing the lowest spill slot and silently corrupting a live value that had been spilled there. Because the corrupted slot can hold an arbitrary JSValue that later feeds arithmetic or object operations, this is a controlled in-process memory-corruption primitive reachable from ordinary script, not merely a robustness bug. It rates high: JS-reachable JIT frame-layout corruption is the classic starting point for a renderer RCE chain.

The interesting part is that nothing in the C++ source looks wrong — the bug lives entirely in the mismatch between an operation’s declared C signature and the JIT’s calling-convention assumptions. operationObjectDefinePropertyFromFields honestly wanted nine arguments, but the DFG’s slow-path caller has no code to spill excess arguments to the real stack frame, so the surplus argument scribbles over an adjacent spill slot. The fix does not change the semantics; it collapses six of the arguments into a single pointer to a VM scratch buffer, bringing the register pressure back within budget.

Root cause

operationObjectDefinePropertyFromFields is the runtime operation the DFG and FTL emit for the fast object-literal / Object.defineProperty path when the descriptor fields are known statically (enumerable, configurable, value, writable, get, set). Before the patch the operation’s C signature took nine 64-bit parameters: JSGlobalObject*, JSObject* target, EncodedJSValue key, then six EncodedJSValue descriptor slots. On ARM64 the AArch64 procedure call standard passes the first eight integer/pointer arguments in x0–x7 and everything beyond that on the stack; on x86_64 SysV only six go in registers (rdi, rsi, rdx, rcx, r8, r9). Nine arguments therefore overflow the register file on both targets.

The DFG’s callOperation machinery is built on the invariant that operation calls never need outgoing stack arguments: maxFrameExtentForSlowPathCall is 0 on these platforms, so the JIT reserves no outgoing-argument area below the stack pointer. When the argument marshaller nonetheless needs to place a ninth value, it writes it to [sp + 0]. But [sp + 0] is not a dedicated outgoing slot — it aliases the lowest spill slot the register allocator uses to preserve live JSValues across the call. The poke of the ninth argument therefore overwrites whatever the allocator had spilled there.

The SpeculativeJIT::compileObjectDefinePropertyFromFields code materialised all eight children (target, key, and the six descriptor operands) into JSValueRegs and then invoked callOperation with all of them. FTLLowerDFGToB3::compileObjectDefinePropertyFromFields did the equivalent with lowJSValue and vmCall. In both cases B3/DFG generated the over-budget call and the surplus argument’s store clobbered a spill slot holding a live value.

The accompanying regression test makes the corruption observable: the spilled value that gets overwritten happens to be a function argument (a double, -5.3049894784e-314) that is later consumed by a ValueAdd. Reading the corrupted slot after the call makes operationValueAddNotNumber operate on a bit pattern that is no longer the original JSValue, producing a crash — or, with attacker-chosen inputs, a type confusion where a controlled 64-bit value is reinterpreted as a JSValue of the wrong kind.

The fix restructures the calling convention rather than the semantics. A VM scratch buffer sized sizeof(EncodedJSValue) * Node::numberOfDescriptorSlots is obtained via vm().scratchBufferForSize; the six descriptor operands are stored into it one slot at a time (storeValue in the DFG, m_out.store64 in FTL), and only a pointer to the buffer is passed. The operation signature drops to four arguments (globalObject, target, key, EncodedJSValue* descriptorBuffer), comfortably within the six-register x86_64 budget. Inside the operation an ActiveScratchBufferScope registers the buffer with the GC (the descriptor JSValues live in a raw buffer the conservative scanner must know about) before the six slots are decoded via Node::EnumerableSlot, ConfigurableSlot, ValueSlot, WritableSlot, GetSlot, SetSlot indices.

Key code

DFGSpeculativeJIT.cpp: descriptor fields now marshalled through a VM scratch buffer, cutting the operation to four GPR args

    GPRReg targetGPR = target.gpr();
    JSValueRegs keyRegs = key.jsValueRegs();
    GPRReg bufferGPR = buffer.gpr();

    speculateObject(m_graph.varArgChild(node, 0), targetGPR);

    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();
    }

    target.use();
    key.use();

    flushRegisters();
    callOperation(operationObjectDefinePropertyFromFields, LinkableConstant::globalObject(*this, node), targetGPR, keyRegs, bufferGPR);
    noResult(node, UseChildrenCalledExplicitly);

Patch walkthrough

  • Source/JavaScriptCore/dfg/DFGOperations.h — Rewrites the JSC_DECLARE_JIT_OPERATION prototype for operationObjectDefinePropertyFromFields from nine arguments (six trailing EncodedJSValue descriptor fields) down to four, replacing the six fields with a single EncodedJSValue* descriptorBuffer. This is the ABI-level change that brings the call within the register budget.
  • Source/JavaScriptCore/dfg/DFGOperations.cpp — Rewrites the operation body to decode the six descriptor slots out of descriptorBuffer instead of from separate register arguments. Wraps the decode in an ActiveScratchBufferScope(ScratchBuffer::fromData(descriptorBuffer), Node::numberOfDescriptorSlots) so the GC treats the buffer contents as roots while they are being read into JSValue locals. Adds #include “DFGNode.h” for the Node::*Slot enum constants.
  • Source/JavaScriptCore/dfg/DFGSpeculativeJIT.cpp — In compileObjectDefinePropertyFromFields, stops allocating six JSValueOperands for the descriptor children. Allocates a GPRTemporary buffer, obtains a scratch buffer via vm().scratchBufferForSize(scratchSize), stores each descriptor child into consecutive buffer slots with storeValue and marks each operand.use(), then calls the operation with just targetGPR, keyRegs and bufferGPR. Switches noResult to UseChildrenCalledExplicitly because the children are now consumed manually.
  • Source/JavaScriptCore/ftl/FTLLowerDFGToB3.cpp — Mirrors the DFG change in the FTL/B3 lowering: instead of lowering six descriptor children to LValues and passing them to vmCall, it obtains a scratch buffer, stores each lowered value with m_out.store64 into buffer + slot, and passes m_out.constIntPtr(buffer) as the single descriptor pointer argument.
  • JSTests/stress/object-define-property-fields-spilled-arg.js — New regression test that drives the DFG/FTL to compile the operation with a live spilled argument, so the pre-patch [sp + 0] poke corrupts a spill slot that later feeds a ValueAdd, reproducing the crash in operationValueAddNotNumber.

Background

ABI argument registers and register budget — The AArch64 procedure call standard passes the first eight integer/pointer arguments in registers x0–x7; the x86_64 System V ABI passes only the first six in rdi, rsi, rdx, rcx, r8, r9. Arguments beyond those counts are placed on the stack by the caller. A C function signature with nine 64-bit parameters therefore requires one stack-passed argument on ARM64 and three on x86_64. JIT compilers that hand-roll their calling convention must reproduce this spill behaviour exactly, or the callee reads garbage for the overflow arguments.

maxFrameExtentForSlowPathCall and the no-stack-args invariant — JSC’s DFG and FTL back ends assume operation (slow-path) calls never require outgoing stack arguments on ARM64/x86_64, encoded by maxFrameExtentForSlowPathCall being 0 on those targets. Consequently no outgoing-argument scratch area is reserved below the stack pointer at a call site. When the marshaller is nonetheless asked to pass a stack argument it writes to [sp + 0], which on these targets aliases the register allocator’s lowest spill slot rather than a safe outgoing slot — turning an over-budget call into silent corruption of a live spilled value.

VM scratch buffers and ActiveScratchBufferScope — A ScratchBuffer is a VM-owned raw memory region (vm().scratchBufferForSize) the JIT uses to shuttle values that do not fit in registers or that must survive an operation call. Because the buffer holds raw EncodedJSValues that are otherwise invisible to the garbage collector, code that populates a buffer wraps the live window in an ActiveScratchBufferScope, which records the buffer and its active length so the conservative GC scans those slots as roots. This is the idiomatic JSC pattern for passing a variable or large number of JSValues into an operation with a single pointer argument.

*Descriptor slot indices (Node::Slot) — ObjectDefinePropertyFromFields is a varargs DFG node whose children are laid out as [0] target, [1] key, then six descriptor fields. The patch names those field positions with the Node enum constants EnumerableSlot, ConfigurableSlot, ValueSlot, WritableSlot, GetSlot, SetSlot and a count Node::numberOfDescriptorSlots, so both the JIT store loop and the operation decode use the same symbolic indices into the scratch buffer instead of separate register operands.

Spill slots and the register allocator — When the DFG/FTL register allocator runs out of physical registers it spills live values to slots in the current stack frame and reloads them after the pressure point (such as a call that clobbers caller-saved registers). These spill slots are addressed relative to the frame/stack pointer. If a call-argument marshaller writes below the frame into what it believes is an outgoing-argument area but which actually overlaps a spill slot, the reload after the call returns an attacker-influenced value instead of the original, which is the corruption exploited here.

Vulnerability window

  1. Introduction — ObjectDefinePropertyFromFields was added as a fast path for object-literal / defineProperty descriptors, with an operation taking the six descriptor fields as direct EncodedJSValue arguments — nine total including globalObject, target and key.
  2. Latent ABI violation — On ARM64 the ninth argument exceeded the 8-register budget (three excess on x86_64), and the DFG’s zero-sized outgoing-argument assumption meant the surplus was poked to [sp + 0], aliasing a spill slot. The bug was silent whenever no live value happened to occupy that slot.
  3. Manifestation — Fuzzing / testing produced a case where a live function argument (a double later feeding a ValueAdd) was spilled to the aliased slot; the poke corrupted it and the subsequent reload crashed in operationValueAddNotNumber, exposing the frame-layout bug.
  4. Diagnosis — Root cause identified as the operation exceeding the register-argument budget combined with the JIT’s no-stack-args assumption, rather than any logic error in the operation body.
  5. Fix — Yusuke Suzuki reworked both the DFG and FTL lowerings to marshal the six descriptor fields through a VM scratch buffer and pass a single pointer, dropping the operation to four arguments; a regression test was added (bug 315074, commit 313474@main).

Proof of concept

VERBATIM added test JSTests/stress/object-define-property-fields-spilled-arg.js. It constructs an Object.defineProperty call whose descriptor forces the ObjectDefinePropertyFromFields node, while arranging for a live function argument (the double -5.3049894784e-314) to be spilled and then reused in a ValueAdd (a2 + input) after the operation call. On the unpatched build the ninth argument’s store to [sp + 0] overwrites that spilled double, so the ValueAdd reads a corrupted JSValue and crashes in operationValueAddNotNumber; on a patched build it runs cleanly. testLoopCount is supplied by the JSC test harness to force DFG/FTL tiering.

// Regression test for a DFG/FTL frame layout bug. operationObjectDefinePropertyFromFields
// used to take 9 GPR args, exceeding ARM64's 8-arg-register budget (and x86_64's 6).
// 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 fix passes the six descriptor slots through a scratch buffer instead of as
// direct C-call arguments, keeping the operation at four GPR args.

function opt(input) {
    Object.defineProperty((function (t, x) { t.y = x; }), 'reject', { get: (({ valueOf: (/(?<!x)y/.test(input)), c: -5.3049894784e-314, this: 1_000_000 }).prototype &&= "ab") });
    a2 = ["ab"];
    try {
        let combined = a2 + input;
        class Inner {
            method() { return combined; }
            delete = ((unused) => this.species)();
        }
    } catch (x) { }
}
for (let i = 0; i < testLoopCount; i++) {
    try {
        opt(-5.3049894784e-314);
    } catch (e) { }
}

Exploitation

  1. Trigger / JIT tiering — Get the ObjectDefinePropertyFromFields node compiled by DFG or FTL (hot loop over a defineProperty with statically-shaped descriptor fields), and arrange register pressure so a value the attacker controls is spilled to the frame slot that [sp + 0] aliases.
  2. Controlled corruption — Because the surplus operation argument is itself one of the descriptor EncodedJSValues, the value written over the spill slot is attacker-chosen. Shaping which live value occupies the aliased slot (and what descriptor field lands there) turns the poke into an overwrite of a chosen live JSValue with a chosen 64-bit pattern.
  3. Type confusion primitive — The reloaded spill slot is subsequently consumed as a typed JSValue (the test’s ValueAdd path; other schedules could reload it as a cell pointer). Overwriting a boxed double with a crafted pointer, or vice versa, yields a wrong-type read — the standard seed for addrof/fakeobj primitives in JSC exploitation.
  4. Reliability caveat — Whether the corruption is exploitable rather than crash-only depends on the register allocator’s spill decisions for a given schedule, which are sensitive to surrounding code. The public test is crash-only (it lands in operationValueAddNotNumber); weaponisation requires controlling both the spilled victim and the overflow value, which is feasible but schedule-dependent.

Detection & hunting

For defenders and SOC / detection engineers:

  • Renderer crashes in operationValueAddNotNumber or other operations with corrupt JSValue operands shortly after Object.defineProperty-heavy JIT code — Look at crash telemetry for JavaScriptCore renderer/GPU-process faults whose backtrace passes through DFG/FTL slow-path operations reached from defineProperty; a corrupted (non-canonical) JSValue reaching an arithmetic operation is the fingerprint of this class.
  • Script patterns that force ObjectDefinePropertyFromFields under tiering pressure — Heuristically flag pages running tight loops that repeatedly call Object.defineProperty with full accessor+data descriptor fields on the same shape while doing arithmetic on function arguments — the shape used to reach the vulnerable node many times.
  • Version fingerprinting — Detect Safari/WebKit builds predating commit 313474@main; the operation signature change is not observable from script, so exposure must be inferred from the WebKit build number rather than a runtime probe.

Audit directions

  • Enumerate all JIT operations by argument count — Grep JSC_DECLARE_JIT_OPERATION / JSC_DEFINE_JIT_OPERATION for operations with more than six EncodedJSValue/pointer parameters and audit each for the same [sp + 0] aliasing risk, since the no-stack-args invariant makes any over-budget operation a candidate.
  • Verify maxFrameExtentForSlowPathCall assumptions per target — Confirm every callOperation / vmCall path on ARM64, x86_64 and 32-bit targets never needs outgoing stack arguments; where an operation legitimately needs many values, ensure the scratch-buffer pattern (with ActiveScratchBufferScope) is used rather than direct arguments.
  • Scratch-buffer GC correctness — Audit the new descriptor-buffer code and analogous sites to ensure the buffer is registered with the GC for the full window in which live JSValues reside in it (ActiveScratchBufferScope length matches numberOfDescriptorSlots), preventing collection of values only reachable through the raw buffer.
  • varargs node lowerings — Review other varargs DFG nodes (varArgChild-based lowerings) that fan children directly into operation calls; the same pattern of one operand per child can silently exceed the register budget as node arity grows.

Before / after

Loading diff…