← WebKit Silent-Fix Report — 2026-W21

c415e39f48  [JSC] Introduce private tmp mechanism to DFG ByteCodeParser

severity high class TypeConfusion confidence 0.70 JSC DFG exploitable-grade
Yusuke Suzuki Wed May 20 11:36:32 2026 -0700 full: c415e39f485baa2a4bb6ac94458b8516d2213947 bug report ↗ view on GitHub ↗
Primitive: aliased cross-block tmps between nested inlined sorts corrupt values
Triage note: Test shows neighbour arrays getting corrupted from tmp aliasing across nested inlined sorts — a JIT register/tmp allocation soundness bug.
Contents

The bug at a glance

This is a JIT soundness bug in the DFG’s Array.prototype.sort inlining: nested inlined sorts allocated cross-block temporaries that could alias each other, letting the inner sort’s tmp slots overwrite the outer sort’s live values (or vice versa) and corrupt JSArray cells and neighbouring heap objects. Memory corruption originating in the optimizing compiler and driven purely by ordinary JavaScript is the highest-value class for exploitation because it gives an attacker deterministic, attacker-shaped control over object state. The added regression test demonstrates real data corruption (neighbour arrays losing their length, the outer result being mangled), confirming the bug is reachable and not merely theoretical.

The attack surface is Array.prototype.sort with a user comparator, which the DFG intrinsically inlines (ArraySortIntrinsic / handleArraySort). Any web page can call arr.sort(cmp) where cmp itself calls someOtherArray.sort(innerCmp), and after enough warm-up the DFG will inline the outer sort and, inside the inlined comparator, inline the inner sort too. That nesting is the whole trigger — no special API, just tiered-up JavaScript reaching the compiler’s private-tmp allocation path.

Root cause

handleArraySort implements sort inlining by materializing a small set of cross-block ’tmp’ operands (loop index tmpI, tmpJ, tmpPivot, tmpArray, tmpCallee, tmpComparator, tmpCmpResult, tmpScratch, and tmpLength) that carry state between the basic blocks of the emitted sort loop. Tmps are a DFG register class separate from locals; each inline frame addresses them relative to its inline-call-frame tmpOffset, and the parser’s global high-water mark m_numTmps (grown by ensureTmps) bounds them. The pre-patch code hand-computed the base of its tmps as unsigned tmpBase = m_inlineStackTop->m_codeBlock->numTmps() + maxNumCheckpointTmps; and then ensureTmps’d to currentTmpOffset + tmpBase + 9. The intent was to place the nine sort tmps above the CodeBlock’s own tmps and above the checkpoint tmps that an inlined comparator’s call could claim.

The defect is that this scheme only accounted for CodeBlock-derived tmps and checkpoint tmps; it had no notion of tmps that the DFG itself synthesizes for its own programming. When the inlined comparator (outerCmp) itself contains an Array.prototype.sort that also satisfies ArraySortIntrinsic, handleArraySort runs a SECOND time for the inner sort while still inside the same inline frame chain. Both invocations computed their tmpBase from the same m_codeBlock->numTmps() + maxNumCheckpointTmps expression, so the inner sort’s nine private tmps landed at the SAME relative slot range as the outer sort’s. The two sorts then aliased tmpArray/tmpLength/etc.: the inner sort’s writes to its tmpArray/tmpScratch clobbered the outer sort’s cached butterfly and length, and because these tmps feed the actual element-swapping and length-stability checks, the corruption reached JSArray cell contents and adjacent heap objects. The commit message states this directly: the comparator’s sort ‘private range would otherwise overlap ours and corrupt the JSArray cell.’

The fix introduces a real private-tmp allocator so DFG-synthesized tmps are tracked per inline frame. A new field unsigned m_numPrivateTmps { 0 }; is added to InlineStackEntry. A new helper tmpOffsetForInlineeOf(InlineStackEntry* caller) computes an inlinee’s tmp offset as callerOffset + caller->m_codeBlock->numTmps() + caller->m_numPrivateTmps — critically now adding the caller’s already-allocated private tmps into the offset, so an inlinee never reuses slots the caller carved out for itself. allocatePrivateTmps(slotCount) computes a base relative to the current frame as top->m_codeBlock->numTmps() + top->m_numPrivateTmps, calls ensureTmps to grow the global bound, and then bumps top->m_numPrivateTmps += slotCount so the next allocation (including a nested handleArraySort) starts above it. handleArraySort now calls auto sortTmps = allocatePrivateTmps(numArraySortTmps); and derives each Operand via sortTmps.operandAt(i). Because m_numPrivateTmps is per-frame monotonic state, the inner nested sort’s allocation is forced to a disjoint range, eliminating the aliasing.

Two call sites are also retrofitted to respect private tmps: inlineCall’s ensureTmps now uses tmpOffsetForInlineeOf(m_inlineStackTop) + codeBlock->numTmps() instead of the old expression that omitted private tmps, and InlineStackEntry’s constructor sets the new frame’s inlineCallFrame tmpOffset via tmpOffsetForInlineeOf(m_caller). Both changes ensure that once a frame has allocated private tmps, any subsequently inlined callee is offset above them, so the private tmps of an outer scope and the entire tmp space of an inner inlined callee are guaranteed non-overlapping.

Key code

handleArraySort now allocates disjoint private tmps instead of a hand-computed base (verbatim from diff)

    // Cross-block tmps. tmpLength caches the original length so Commit can detect
    // mutation. The slots must not alias the inlined comparator's tmps -- if the
    // comparator itself contains a sort that satisfies ArraySortIntrinsic, its
    // private range would otherwise overlap ours and corrupt the JSArray cell.
    constexpr unsigned numArraySortTmps = 9;
    auto sortTmps = allocatePrivateTmps(numArraySortTmps);
    Operand tmpI = sortTmps.operandAt(0);
    Operand tmpJ = sortTmps.operandAt(1);
    Operand tmpPivot = sortTmps.operandAt(2);
    Operand tmpArray = sortTmps.operandAt(3);
    Operand tmpCallee = sortTmps.operandAt(4);
    Operand tmpComparator = sortTmps.operandAt(5);
    Operand tmpCmpResult = sortTmps.operandAt(6);
    Operand tmpScratch = sortTmps.operandAt(7);
    Operand tmpLength = sortTmps.operandAt(8);

Patch walkthrough

  • Source/JavaScriptCore/dfg/DFGByteCodeParser.cpp — Adds the PrivateTmpRange struct (base/count with operandAt), the static tmpOffsetForInlineeOf helper, and the allocatePrivateTmps allocator. Adds m_numPrivateTmps to InlineStackEntry. Rewrites handleArraySort to obtain its nine cross-block sort tmps through allocatePrivateTmps(numArraySortTmps) instead of a hand-computed tmpBase, so a nested inlined sort receives a disjoint slot range. Updates inlineCall’s ensureTmps and the InlineStackEntry constructor’s tmpOffset computation to include the caller’s private tmps, so inlined callees are offset above them.
  • JSTests/stress/array-sort-inline-nested-in-comparator.js — Regression test: an outer sort whose comparator (outerCmp) forces a checkpoint and then calls inner.sort(innerCmp), run past testLoopCount so both sorts inline. It surrounds the inner array with 4096 neighbour arrays on each side and asserts none had its length corrupted, plus that the outer result still has length 5 — directly detecting tmp aliasing that would smash neighbouring cells.

Background

DFG tmps vs. locals — The DFG bytecode parser addresses values by Operand, which can name an argument, a local, or a ’tmp’. Tmps are an auxiliary slot class used for values that must live across basic blocks but are not source-level locals — for instance, checkpoint state for calls that can OSR mid-operation, and the cross-block state of an inlined sort loop. They are remapped through each inline frame’s tmpOffset and bounded by the parser’s global m_numTmps.

handleArraySort / ArraySortIntrinsic — When Array.prototype.sort is called with a comparator, the DFG can inline the sort as a hand-written graph of basic blocks (an intrinsic) rather than a generic call. It emits an in-place sort loop whose index, pivot, cached array butterfly, cached length and comparator-call scratch are carried in cross-block tmps. Because the comparator is user JavaScript, it too may be inlined into that same loop.

InlineStackEntry and tmpOffset — Each level of inlining pushes an InlineStackEntry describing the inlined CodeBlock, its argument positions, and its InlineCallFrame. tmpOffset is the base at which that frame’s tmps live within the shared tmp space; it was historically computed as caller tmpOffset + caller CodeBlock numTmps, which correctly stacks CodeBlock tmps but had no term for DFG-synthesized private tmps.

Checkpoint tmps / maxNumCheckpointTmps — Operations like a call inside sort can OSR-exit at a checkpoint, and the interpreter needs the partially-computed state preserved. Those live in checkpoint tmps, sized up to maxNumCheckpointTmps. The old handleArraySort tried to sit above this bound, but reserving space above checkpoint tmps does not prevent a second handleArraySort in a nested frame from computing the identical base.

ensureTmps and the high-water mark — ensureTmps(n) grows every basic block’s tmp array to at least n and updates the parser’s m_numTmps, which the operand bounds check (operand.value() < m_numTmps) relies on. Growing the bound is necessary but not sufficient for safety: two independent allocations can both fit under the bound yet still overlap each other if they compute the same relative base, which is exactly the aliasing this patch removes with per-frame m_numPrivateTmps.

Butterfly and length caching in the sort loop — The sort tmps include tmpArray (the array’s butterfly/storage) and tmpLength (the pre-sort length cached so the Commit step can detect concurrent mutation). If a nested sort aliases these slots, the outer loop reads a butterfly pointer and length that belong to the inner array, so element swaps write through the wrong storage — corrupting whichever cell now sits at the aliased slot.

Vulnerability window

  1. Warm-up — JavaScript repeatedly calls test(arr)=arr.sort(outerCmp) where outerCmp calls inner.sort(innerCmp); tier-up promotes test into the DFG.
  2. Outer inline — The DFG inlines the outer Array.prototype.sort via handleArraySort, which allocates its nine cross-block tmps at tmpBase = codeBlock numTmps + maxNumCheckpointTmps.
  3. Comparator inline — outerCmp is inlined into the sort loop; because it contains inner.sort(innerCmp) that also satisfies ArraySortIntrinsic, handleArraySort runs again for the inner sort inside the same frame chain.
  4. Aliasing — The inner handleArraySort recomputes the same tmpBase and lays its nine private tmps over the outer sort’s slots; the two sorts now share tmpArray/tmpLength/tmpScratch etc.
  5. Corruption — As the loops interleave, writes to the inner sort’s tmpArray/tmpScratch overwrite the outer sort’s cached butterfly and length; element swaps and length-stability logic operate on the wrong storage, smashing the JSArray cell and neighbouring heap objects.
  6. Observable — Post-run, neighbour arrays report a corrupted length and the outer result is mangled — the exact assertions the regression test makes. Post-patch, allocatePrivateTmps forces the inner sort into a disjoint range and no aliasing occurs.

Proof of concept

Verbatim added regression test. The outer comparator forces a checkpoint (a instanceof Object) and then runs a nested inner.sort, so after warm-up both sorts are inlined and, pre-patch, share tmp slots. The 8192 surrounding neighbour arrays are length-3 sentinels; tmp aliasing corrupts the butterfly/length of nearby cells, which the length checks detect. In a browser the same shape (a sort whose comparator sorts another array) reaches the bug without the JSC test shell intrinsics.

//@ requireOptions("--useConcurrentJIT=0")
//
// Nested sort: the inlined comparator itself calls Array.prototype.sort, so the
// inner handleArraySort's private cross-block tmps must not alias the outer's.

function makeIntArr(n) {
    let a = [];
    for (let i = 0; i < n; i++)
        a.push((n - i) | 0);
    return a;
}

let neighbours = [];
for (let i = 0; i < 4096; i++)
    neighbours.push(makeIntArr(3));
let inner = makeIntArr(3);
for (let i = 0; i < 4096; i++)
    neighbours.push(makeIntArr(3));

function innerCmp(a, b) { return 0; }

function outerCmp(a, b) {
    (a instanceof Object); // forces a checkpoint in outerCmp.
    inner.sort(innerCmp);
    return false;
}

function test(arr) { return arr.sort(outerCmp); }
noInline(test);

for (let i = 0; i < testLoopCount; i++)
    test(makeIntArr(5));

for (let i = 0; i < neighbours.length; i++) {
    if (neighbours[i].length !== 3)
        throw new Error("neighbour " + i + " corrupted: length = " + neighbours[i].length);
}
gc();

let result = test(makeIntArr(5));
if (result.length !== 5)
    throw new Error("outer result corrupted: " + JSON.stringify(result));

Exploitation

  1. Trigger the miscompile — Run a nested sort (outer sort whose comparator sorts an inner array) enough times to force DFG inlining of both sorts, reproducing the tmp aliasing.
  2. Shape the corruption — Because the aliased tmps include the cached butterfly pointer and length, an attacker arranges heap layout so a controlled object sits where the sort writes, turning the aliasing into an attacker-influenced length/element overwrite of an adjacent JSArray or object.
  3. Length/OOB primitive — Corrupting an array’s length field yields an out-of-bounds read/write over the butterfly, the classic springboard in JSC exploitation for building addrof/fakeobj and then arbitrary read/write.
  4. Reliability caveat — The public artifact only proves deterministic data corruption of neighbouring arrays and the sort result; converting that to a stable R/W requires heap-grooming work not shown in the patch, so as delivered it is a strong memory-corruption primitive rather than a demonstrated full chain.

Detection & hunting

For defenders and SOC / detection engineers:

  • Sort-in-comparator crash signature — Crashes in DFG-compiled code whose stack shows Array.prototype.sort with a comparator that itself calls sort, or asserts about tmp operand bounds (operand.value() vs m_numTmps) in DFGByteCodeParser, indicate this class. On debug builds a CPS/tmp validation failure during handleArraySort is the direct tell.
  • Corrupted array length telemetry — In crash triage, JSArray cells whose length disagrees with butterfly capacity, or unrelated arrays losing length after sort-heavy workloads, match the neighbour-corruption behaviour the regression test encodes.
  • Version gating — Fixed at commits.webkit.org/313590@main. Builds predating it inline nested sorts with aliasing private tmps; treat heavy nested-sort JavaScript as a corruption vector on those versions.

Audit directions

  • Hand-computed tmp bases — grep DFGByteCodeParser.cpp for numTmps() + and Operand::tmp( at fixed offsets; any intrinsic that carves cross-block tmps from a manually computed base rather than through allocatePrivateTmps risks the same aliasing when it can recurse under inlining.
  • Re-entrant intrinsics under inlining — Audit handle* intrinsics (handleArraySort and peers) that can be invoked again for a nested inlined call within the same frame chain; the architectural class is ‘intrinsic that allocates frame-relative scratch without tracking prior allocations in that frame.’
  • tmpOffset / m_numTmps computations — Search for tmpOffset and ensureTmps call sites to confirm every inlinee offset now flows through tmpOffsetForInlineeOf (i.e. includes m_numPrivateTmps); a stray site still using the old caller numTmps expression would reintroduce overlap.
  • Checkpoint tmp sizing assumptions — grep for maxNumCheckpointTmps to find code that assumes reserving space above checkpoint tmps is sufficient isolation; that assumption is the exact reasoning error this patch corrects.

Before / after

Loading diff…