← WebKit Silent-Fix Report — 2026-W23

1cdc540e6ebee5192b5b5d4fc0202b8bfd37eece  Fix baseline write barrier handling in OpDelBy{Id,Val}

severity high class UAF confidence 0.90 JSC Baseline JIT exploitable-grade
Anand Srinivasan Tue Jun 2 10:56:02 2026 -0700 full: 1cdc540e6ebee5192b5b5d4fc0202b8bfd37eece bug report ↗ view on GitHub ↗
Primitive: Missing write barrier in baseline OpDelById/OpDelByVal
Triage note: Baseline JIT del_by_id/del_by_val did not emit a proper write barrier (scratchJSR now reserved for the barrier store); tests run under --verifyHeap with fullGC/edenGC. A missing barrier lets the GC miss a reference, yielding a use-after-free.
Contents

The bug at a glance

The bug is reachable from any JavaScript that runs the baseline JIT form of delete o.x / delete o["x"] and assigns the boolean result back over the base register (o = delete o.x), so no exotic API surface is needed and a web page can drive it. A missing generational write barrier lets the GC’s remembered set omit a live old-to-new reference, producing a use-after-free that is a strong exploitation primitive; the High/8.1 rating is consistent with a memory-corruption bug that still requires a specific bytecode pattern and GC timing to trigger.

The baseline JIT emits its generational write barrier for del_by_id/del_by_val at the very end of the opcode, reading the barrier’s owner cell straight out of the base virtual register. But this opcode also writes its own boolean result back into a virtual register, and for o = delete o.x the destination register is the same one that held o. By the time the barrier runs, the base register has already been overwritten with a boxed boolean, so JSC dutifully fires the barrier on true instead of on the object whose structure the inline cache may have just mutated. The barrier becomes a no-op on a non-cell, the old object is never remembered, and an eden GC can reclaim an object still referenced from old space — a classic missing-barrier UAF. The fix reserves a dedicated scratchJSR, reloads the base from its virtual register after the IC and result store, and barriers that.

Root cause

The vulnerable state lives in JIT::emit_op_del_by_id / emit_op_del_by_val in Source/JavaScriptCore/jit/JITPropertyAccess.cpp. Both opcodes run an inline cache (the DelByIdGenerator/DelByValGenerator appended to m_delByIds/m_delByVals) that can transition the base object’s structure without itself emitting a barrier, then box the boolean delete result into resultJSR and store it via emitPutVirtualRegister(dst, resultJSR). Only afterward did the code call emitWriteBarrier(base, ShouldFilterBase) — passing the virtual register base.

The reaching path is the source pattern o = delete o.x, where the bytecode compiler assigns dst (the boolean result) to the same virtual register that holds the base operand. emitPutVirtualRegister(dst, resultJSR) therefore clobbers the slot that base names with a boxed boolean before the barrier reads it. The old emitWriteBarrier(VirtualRegister owner, ...) reloads the owner from that slot, sees a boolean (a non-cell), and — because the mode is ShouldFilterBase — takes the branchIfNotCell early-out and skips remembering anything.

This is unsafe because the IC may have written a new Structure* pointer (or otherwise created an old-to-new edge) into the base object, and the generational GC relies on the write barrier to add that object to the remembered set. With the barrier misdirected at a boolean, the base object is never remembered; a subsequent edenGC() scans only new-space plus the remembered set, misses the live reference, and frees an object that is still reachable — use-after-free. The test cases (baseline-op-del-by-id-write-barrier.js / -val-) run under --verifyHeap=1 with interleaved fullGC()/edenGC() precisely to make the heap verifier catch the dangling reference.

The fix reserves a scratchJSR (built from scratch1GPR/scratch2GPR via JSValueRegs::withTwoAvailableRegs) in BaselineJITRegisters.h for both DelById and DelByVal, then after setFastPathResumePoint() re-reads the base with emitGetVirtualRegister(base, scratchJSR) — comment: “IC may clobber baseJSR so reload from virtual register” — boxes and stores the result, and finally calls a new emitWriteBarrier(JSValueRegs owner, WriteBarrierMode) overload on scratchJSR. Because the reload happens before emitPutVirtualRegister overwrites the base slot, the barrier now always sees the genuine base object.

Key code

Reload base into scratchJSR before the result store clobbers it, then barrier the reloaded value (emit_op_del_by_id)

    m_delByIds.append(gen);

    setFastPathResumePoint();
    emitGetVirtualRegister(base, scratchJSR); // IC may clobber baseJSR so reload from virtual register
    boxBoolean(resultJSR.payloadGPR(), resultJSR);
    emitPutVirtualRegister(dst, resultJSR);

    // We should emit write-barrier at the end of sequence since write-barrier clobbers registers.
-   emitWriteBarrier(base, ShouldFilterBase);
+   emitWriteBarrier(scratchJSR, ShouldFilterBase);

// New overload:
void JIT::emitWriteBarrier(JSValueRegs ownerJSR, WriteBarrierMode mode)
{
    constexpr GPRReg tempGPR = regT0;
    Jump ownerNotCell;
    if (mode == ShouldFilterBase)
        ownerNotCell = branchIfNotCell(ownerJSR);
    Jump ownerIsRememberedOrInEden = barrierBranch(vm(), ownerJSR.payloadGPR(), tempGPR);
    callOperationNoExceptionCheck(operationWriteBarrierSlowPath, TrustedImmPtr(&vm()), ownerJSR.payloadGPR());
    ownerIsRememberedOrInEden.link(this);
    if (mode == ShouldFilterBase)
        ownerNotCell.link(this);
}

Patch walkthrough

  • Source/JavaScriptCore/jit/BaselineJITRegisters.h — For both the DelById and DelByVal register namespaces, adds a scratchJSR constructed from scratch1GPR and scratch2GPR via JSValueRegs::withTwoAvailableRegs. The noOverlap static_asserts are rewritten to reference scratchJSR in place of the two individual scratch GPRs, guaranteeing the new value register does not collide with baseJSR, propertyJSR, propertyCacheGPR, scratch3GPR, or the handler GPR used by the slow path.
  • Source/JavaScriptCore/jit/JIT.h — Declares a new void emitWriteBarrier(JSValueRegs owner, WriteBarrierMode) overload alongside the existing VirtualRegister-based ones. This lets the caller barrier a live JSValueRegs it controls, rather than re-reading a virtual register that may have been overwritten.
  • Source/JavaScriptCore/jit/JITPropertyAccess.cpp — In emit_op_del_by_id and emit_op_del_by_val, after setFastPathResumePoint() the base is reloaded into scratchJSR (emitGetVirtualRegister(base, scratchJSR)) before boxing the boolean and storing it to dst, and the trailing barrier is changed from emitWriteBarrier(base, ShouldFilterBase) to emitWriteBarrier(scratchJSR, ShouldFilterBase). Also adds the emitWriteBarrier(JSValueRegs, WriteBarrierMode) implementation: it optionally does branchIfNotCell for ShouldFilterBase, then barrierBranch and operationWriteBarrierSlowPath on ownerJSR.payloadGPR().

Background

Generational write barrier — JSC uses a generational GC. When old-generation objects gain a reference to a young object, a write barrier must record the old object into the remembered set so eden (young-only) collections still scan it. A missed barrier means eden GC treats the young object as unreferenced and can free it while it is still live.

ShouldFilterBase / barrierBranch / operationWriteBarrierSlowPathShouldFilterBase tells emitWriteBarrier to skip the barrier when the owner is not a cell (branchIfNotCell). barrierBranch checks whether the cell is already remembered or in eden; only otherwise does it call operationWriteBarrierSlowPath to enqueue it. Feeding a boxed boolean makes branchIfNotCell short-circuit the whole thing.

del_by_id / del_by_val inline cache — The baseline DelByIdGenerator/DelByValGenerator ICs perform the delete and can transition the base object’s structure (e.g. to a structure recording the deleted property). Such structure writes into the base cell are what create the old-to-new edge the barrier is responsible for remembering.

Virtual register aliasing of dst and base — In o = delete o.x, the bytecode’s destination operand (the boolean result) is the same virtual register as the base operand o. emitPutVirtualRegister(dst, ...) therefore overwrites the machine-visible slot that base refers to, which is why re-reading base after the store yields the boolean, not the object.

Vulnerability window

  1. Setup — Script defines opt = new Function('o', 'o = delete o.x;') and warms it so it tiers up to the baseline JIT with the del_by_id IC in place.
  2. Triggeropt(target) runs the baseline sequence: the IC transitions/updates target’s structure, the boolean result is boxed and stored back into the register that held target, and the trailing barrier reads that register.
  3. Missed barrieremitWriteBarrier(base, ShouldFilterBase) reloads the (now boolean) base slot, branchIfNotCell fires, and target is never added to the remembered set despite the structure write.
  4. Reclamationtarget is dropped from the roots the collector can see via new space; the following edenGC() does not scan the un-remembered old object and frees memory still referenced.
  5. Detection/fix — Under --verifyHeap=1 the heap verifier flags the dangling reference. The fix reloads base into scratchJSR before the result store and barriers the reloaded value.

Proof of concept

This is the committed regression test. It warms opt (whose body is o = delete o.x, aliasing dst and base) so the baseline del_by_id path runs, then calls it on target, drops the JS reference, and forces an edenGC(). Under --verifyHeap=1 the missing barrier surfaces as a heap-verification failure / use-after-free. It demonstrates reachability but is not by itself a weaponized primitive.

//@ requireOptions("--verifyHeap=1", "--useConcurrentJIT=0")
function main() {
    globalThis.dummy = { x: 1 };
    globalThis.dummy.y = 2;
    for (let i = 0; i < 10; i++) {
        const target = {x: 1};
        fullGC();
        let opt = new Function('o', 'o = delete o.x;');
        for (let j = 0; j < 10; j++)
            opt({ x: 1 });
        opt(target);
        opt = null;
        edenGC();
    }
}
main();

Exploitation

  1. Trigger the missed barrier — Compile a baseline function of the form o = delete o.x (or o = delete o["x"]), run it on an old-generation object whose structure the delete IC mutates, so the object gains an old-to-new edge that is never remembered.
  2. Provoke premature free — Arrange for the object to appear unreferenced from young space and trigger an eden collection; the collector reclaims the still-live object, producing a dangling cell reference held elsewhere in old space.
  3. Reoccupy and confuse — Spray replacement objects into the freed slot so the stale reference now points at attacker-controlled contents, converting the UAF into a type-confusion / fake-object primitive. This step is standard JSC UAF exploitation and is not demonstrated by the patch’s test.

Detection & hunting

For defenders and SOC / detection engineers:

  • verifyHeap failures
  • Baseline barrier-owner provenance
  • Crash telemetry in eden GC

Audit directions

  • Other baseline opcodes with dst==base aliasing
  • emitWriteBarrier(VirtualRegister) call sites
  • IC structure writes without unconditional barriers

Before / after

Loading diff…