← All reports

[3] [JSC] Move DataView null vector check in IC outside of register save/restore

HighJSC inline cachesOOB

An IC exit undid a stack adjustment that had never happened.

34669c8

High. A cold, script-triggerable exit path returns to compiled JavaScript with a stack pointer raised past live frame data and registers refilled from the caller's own frame. Escalation hinges on how much control script has over the 64-bit words sitting in the slots the bogus fills read.

Machine-code stubs emitted by a JIT must honour one contract above all others: every exit path returns to the caller with exactly the stack pointer and register contents it was entered with. JSC's inline caches are those stubs — small specialised code fragments that handle a property operation for a particular object shape. When a stub needs more scratch registers than are free at the call site, it borrows registers that are live, spilling them to the stack in a prologue and refilling them in a matching epilogue. The prologue and epilogue are a matched pair: the prologue lowers the stack pointer and stores into the space it just made, the epilogue reloads from that space and raises the pointer back.

The angle: any page can detach a resizable ArrayBuffer behind a hot polymorphic byteLength read and drive JIT-compiled JavaScript to continue executing with a desynchronized stack pointer and registers holding words lifted out of its own live frame.

The commit message describes the shape precisely:

In the DataView byteLength getter IC, there is a null vector check which executes before a preserveReusedRegistersByPushing, but jumps to a point before restoreReusedRegistersByPopping. In other words, causing a misbalanced push and pop of register state.

This PR fixes by making the null vector check jump to after the popping of saved registers.

Source/JavaScriptCore/bytecode/InlineCacheCompiler.cpp

if (isResizableOrGrowableSharedTypedArrayIncludingDataView(accessCase.structure()->classInfoForCells())) {
+ // The null-vector guard above was emitted before the push, so route it
+ // directly to m_failAndIgnore to avoid the post-push restore path.
+ m_failAndIgnore.append(failAndIgnore);
+
auto allocator = makeDefaultScratchAllocator(m_scratchGPR);
GPRReg scratch2GPR = allocator.allocateScratchGPR();
 
ScratchRegisterAllocator::PreservedState preservedState = allocator.preserveReusedRegistersByPushing(jit, ScratchRegisterAllocator::ExtraStackSpace::NoExtraSpace);
 
+ CCallHelpers::JumpList postPushFailAndIgnore;
if (isDataView) {
auto [outOfBounds, doneCases] = jit.loadDataViewByteLength(baseGPR, valueGPR, m_scratchGPR, scratch2GPR, type);
- failAndIgnore.append(outOfBounds);
+ postPushFailAndIgnore.append(outOfBounds);
doneCases.link(&jit);
} else
...
allocator.restoreReusedRegistersByPopping(jit, preservedState);
succeed();
 
- if (allocator.didReuseRegisters() && !failAndIgnore.empty()) {
- failAndIgnore.link(&jit);
+ if (allocator.didReuseRegisters() && !postPushFailAndIgnore.empty()) {
+ postPushFailAndIgnore.link(&jit);
allocator.restoreReusedRegistersByPopping(jit, preservedState);
m_failAndIgnore.append(jit.jump());
} else
- m_failAndIgnore.append(failAndIgnore);
+ m_failAndIgnore.append(postPushFailAndIgnore);
return;
}

JSTests/stress/dataview-bytelength-ic-stub-stack-desync.js

+let ab = new ArrayBuffer(64, { maxByteLength: 1024 });
+let dv = new DataView(ab);
+let decoy = { byteLength: 7 };
+let decoy2 = { byteLength: 7, x: 1 };
+let decoy3 = { byteLength: 7, y: 1 };
+let objs = [];
+for (let i = 0; i < 64; i++) objs.push({marker: 0x1337 + i});
+function hot(o, a, b) {
+ let p0=b[0], p1=b[1], /* ... 32 live values ... */ p31=b[31];
+ let len;
+ try { len = o.byteLength; } catch (e) { len = -1; }
+ return [len, p0.marker, /* ... */ p31.marker];
+}
+noInline(hot);
+for (let i = 0; i < 200000; i++) {
+ hot(decoy, A, objs); hot(decoy2, A, objs); hot(decoy3, A, objs); hot(dv, A, objs);
+}
+ab.transfer();
+let r = hot(dv, A, objs);

The change is confined to InlineCacheCompiler::emitIntrinsicGetter, in the USE(JSVALUE64) branch taken when isResizableOrGrowableSharedTypedArrayIncludingDataView(accessCase.structure()->classInfoForCells()) holds. Previously a single failAndIgnore JumpList collected side-exit branches created at two different points in the stub's stack-depth timeline: a guard emitted earlier in the function, before allocator.preserveReusedRegistersByPushing(...) — the patch's own comment identifies it as the null-vector guard — and the outOfBounds branch produced by jit.loadDataViewByteLength(...), emitted after the push. Both were linked at a common label that ran allocator.restoreReusedRegistersByPopping(jit, preservedState) before jumping to m_failAndIgnore.

The patch splits the two epochs. The pre-push failAndIgnore is appended directly to m_failAndIgnore with no restore, and a new local CCallHelpers::JumpList postPushFailAndIgnore collects the outOfBounds branch, which alone is linked to the pop-then-jump epilogue. The terminal if (allocator.didReuseRegisters() && !failAndIgnore.empty()) / else m_failAndIgnore.append(failAndIgnore) pair is rewritten to operate on postPushFailAndIgnore. A regression test is added that builds a polymorphic byteLength IC under high register pressure and then detaches the buffer with ArrayBuffer.prototype.transfer().

Branch targets from two different stack-depth epochs merged into one jump list, so a side exit taken before a prologue's stack adjustment executes the epilogue that undoes it.

Inline caches. JSC caches property operations as small machine-code stubs specialised for a particular object structure. InlineCacheCompiler::emitIntrinsicGetter emits the stub body for getters implemented as intrinsics, such as DataView.prototype.byteLength.

Polymorphic ICs. When a call site sees several different object shapes, the IC holds several AccessCases chained together, and stub generation happens with more of the caller's registers marked live.

ScratchRegisterAllocator. A stub-local register allocator. allocateScratchGPR() first hands out registers that are neither locked nor live at the call site; if none remain, it hands out a live one and increments m_numberOfReusedRegisters, which makes didReuseRegisters() return true.

preserveReusedRegistersByPushing / restoreReusedRegistersByPopping. The matched prologue and epilogue for borrowed registers. The prologue subtracts a rounded-up byte count from the stack pointer and then stores the reused registers at positive offsets from the lowered stack pointer, returning a PreservedState carrying numberOfBytesPreserved; the epilogue reloads those registers from the same stack-pointer-relative offsets and adds the byte count back. Both are no-ops when didReuseRegisters() is false.

JumpList and link. An assembler-level list of pending forward branches. list.link(&jit) binds every branch in the list to the assembler's current code location, so all members of a list share one destination. Crucially, a JumpList records only the origin of each branch in the instruction stream — it carries no information about the stack depth in effect when the branch was created.

failAndIgnore versus succeed(). A stub exits either through its success continuation or through a failure path; m_failAndIgnore is the compiler-level collection of branches that abandon this stub and fall back to the generic slow path.

Resizable ArrayBuffers and transfer(). new ArrayBuffer(n, { maxByteLength: m }) creates a resizable buffer, and views over it take the isResizableOrGrowableSharedTypedArrayIncludingDataView code path, whose length must be computed at runtime by loadDataViewByteLength. ArrayBuffer.prototype.transfer() detaches the buffer, after which views over it have a null backing vector.

This is a JIT stack and register state desynchronization — an unbalanced push/pop pair producing stack-pointer corruption and clobbering of live caller registers.

  Stub timeline (didReuseRegisters() == true)
  ──────────────────────────────────────────
  entry sp = S
    null-vector guard emitted here ──────────┐   (epoch A: sp == S)
  preserveReusedRegistersByPushing:          │
    subPtr N, sp        -> sp = S - N        │
    store regs at [sp + off]                 │
    loadDataViewByteLength -> outOfBounds ─┐ │   (epoch B: sp == S - N)
  restoreReusedRegistersByPopping (success)│ │
  succeed()                                │ │
                                           ▼ ▼
  shared label:  load regs from [sp + off]      <-- with sp == S, reads
                 addPtr N, sp                       CALLER'S LIVE FRAME
                 jump m_failAndIgnore              and then sp = S + N

The null-vector guard and the outOfBounds branch belong to different epochs in the diagram, yet both landed on the shared label. When the IC is polymorphic and register pressure at the call site is high enough that allocateScratchGPR() must reuse a live register, m_numberOfReusedRegisters becomes non-zero, didReuseRegisters() is true, and both halves of the pair emit real code: a subPtr plus spills, and fills plus a matching addPtr.

If the DataView's backing vector is null because the buffer was detached, the epoch-A guard branches straight to the epilogue label. Execution then runs the restore sequence even though the corresponding subPtr never executed. Because the spill slots are addressed at positive offsets from the lowered stack pointer ([sp + extraBytesAtTopOfStack + offset]), running the fill without the preceding subPtr reads words above the entry stack pointer — memory inside the caller's currently live frame region, not the spill area that was supposed to hold the saved values. The reused live registers are overwritten with unrelated live-frame words, and the stack pointer is then raised by preservedState.numberOfBytesPreserved past live frame data. Control continues via m_failAndIgnore into the IC's slow path and back into the compiled JS function, now running with both a desynchronized stack pointer and registers holding values the register allocator did not put there.

Every element of the trigger is plain JavaScript, and the added regression test is itself a working one. Walking it:

  1. The loop calls hot() with decoy, decoy2, decoy3 and dv, so the o.byteLength site's IC becomes polymorphic across four structures and reaches stub compilation in emitIntrinsicGetter.
  2. The 32 simultaneously live p0..p31 locals are shaped to keep nearly all GPRs occupied so that allocateScratchGPR() for scratch2GPR must take a live register, which would make didReuseRegisters() true and cause the prologue to emit a real stack adjustment. The supplied ScratchRegisterAllocator sources show this mechanism but do not establish that this particular test reaches the reuse case, so that step is an inference from the test's construction.
  3. dv's structure selects the resizable-buffer branch, whose stub contains both the pre-push guard and the post-push outOfBounds branch.
  4. ab.transfer() detaches the buffer, so the DataView's vector is null.
  5. The final hot(dv, A, objs) enters the stub, the pre-push guard fires before any push, and control lands on the epilogue that executes restoreReusedRegistersByPopping.

The immediate observable effect: the reused registers are refilled from words at positive offsets from the entry stack pointer — inside the caller's live frame region rather than a spill area that was never allocated — and the stack pointer is then raised, after which m_failAndIgnore returns control to the compiled function. The test's p0.marker ... p31.marker reads exist to observe the clobbering that follows; the typical outcome is a crash or garbage values.

Escalation would require, in order: (a) that the live-frame words read back by the fill sequence at the reused registers' offsets can be influenced by the attacker through JS values the compiled function itself keeps in those frame slots — the supplied context includes preserveRegistersToStackForCall/restoreRegistersFromStackForCall but not the frame-layout model that would fix which slots those offsets land on, so the degree of control is a projection; (b) that at least one clobbered register is one the compiled function believes holds a live JSValue, which is the shape the test's pN locals establish; and (c) that the injected bit pattern survives to a use site that dereferences it as a cell. If all three held, an attacker could obtain a fake-JSValue injection and from there a type-confusion primitive suitable for building arbitrary read/write. Separately, the raised stack pointer means subsequent calls made by the same function could place callee frames overlapping the caller's live frame slots, which might yield a second, independent corruption channel.

The bug lives entirely within the WebContent process; no sandbox boundary is crossed, and a separate escape would still be required after achieving renderer code execution.

The discovery signature points at targeted pattern auditing of the resizable-ArrayBuffer IC paths rather than generic fuzzing. A side exit that skips or duplicates a stack-adjustment epilogue is found by reading stub emission code for push/pop balance, since the faulty path requires the conjunction of a polymorphic IC, enough register pressure to force didReuseRegisters() true, a resizable-buffer-backed DataView, and a detached buffer. The test is hand-shaped for reliability rather than fuzzer-minimized: the 64-element decoy setup, the exactly-32 live locals, the polymorphism-priming loop order, and the try/catch around the getter all look deliberately constructed. A fuzzer emitting resizable ArrayBuffers plus transfer() inside hot polymorphic property loads could plausibly have produced the initial crash that motivated the audit.

This vulnerability weakens the memory-safety boundary inside the WebContent process by breaking a core JIT invariant. Before the fix, a detached-buffer side exit left the compiled function running on a stack pointer raised past live frame data, with registers that JSC's register allocator believes hold live JSValues instead holding words read out of the caller's own live frame region. An attacker who could arrange chosen 64-bit values in the frame slots the bogus fills read from would substitute an attacker-influenced bit pattern for a live JS variable — the classic route to a fake-object/type-confusion primitive and, from there, arbitrary read/write in the renderer.

JumpList is stack-height-agnostic by construction — a branch records only its origin in the instruction stream, not the stack depth in effect when it was created. That makes "one list, two epochs" an easy mistake to make and an invisible one to review, because the two append sites can be dozens of lines apart with a preserveReusedRegistersByPushing between them. Note also the direction of the damage: because the spill slots are addressed at positive offsets from the lowered stack pointer, skipping the subPtr does not read abandoned memory below the stack — it reads the caller's own live frame, the more attacker-adjacent region of the two. The fix's real content is a naming convention (postPushFailAndIgnore) that encodes the epoch in the variable name; a stronger mitigation would be for ScratchRegisterAllocator::PreservedState to carry a debug-mode stack-height token that restoreReusedRegistersByPopping could assert against at each linked predecessor. Resizable/growable ArrayBuffer support is what forced runtime length computation into these stubs at all — the whole preserve/restore block exists only on that path, so the resizable-buffer feature is what created the epoch split in this function.