1cdc540e6ebee5192b5b5d4fc0202b8bfd37eece Fix baseline write barrier handling in OpDelBy{Id,Val}
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 theDelByIdandDelByValregister namespaces, adds ascratchJSRconstructed fromscratch1GPRandscratch2GPRviaJSValueRegs::withTwoAvailableRegs. ThenoOverlapstatic_asserts are rewritten to referencescratchJSRin place of the two individual scratch GPRs, guaranteeing the new value register does not collide withbaseJSR,propertyJSR,propertyCacheGPR,scratch3GPR, or the handler GPR used by the slow path.Source/JavaScriptCore/jit/JIT.h— Declares a newvoid emitWriteBarrier(JSValueRegs owner, WriteBarrierMode)overload alongside the existingVirtualRegister-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— Inemit_op_del_by_idandemit_op_del_by_val, aftersetFastPathResumePoint()the base is reloaded intoscratchJSR(emitGetVirtualRegister(base, scratchJSR)) before boxing the boolean and storing it todst, and the trailing barrier is changed fromemitWriteBarrier(base, ShouldFilterBase)toemitWriteBarrier(scratchJSR, ShouldFilterBase). Also adds theemitWriteBarrier(JSValueRegs, WriteBarrierMode)implementation: it optionally doesbranchIfNotCellforShouldFilterBase, thenbarrierBranchandoperationWriteBarrierSlowPathonownerJSR.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 / operationWriteBarrierSlowPath — ShouldFilterBase 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
- 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. - Trigger —
opt(target)runs the baseline sequence: the IC transitions/updatestarget’s structure, the boolean result is boxed and stored back into the register that heldtarget, and the trailing barrier reads that register. - Missed barrier —
emitWriteBarrier(base, ShouldFilterBase)reloads the (now boolean) base slot,branchIfNotCellfires, andtargetis never added to the remembered set despite the structure write. - Reclamation —
targetis dropped from the roots the collector can see via new space; the followingedenGC()does not scan the un-remembered old object and frees memory still referenced. - Detection/fix — Under
--verifyHeap=1the heap verifier flags the dangling reference. The fix reloads base intoscratchJSRbefore 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
- Trigger the missed barrier — Compile a baseline function of the form
o = delete o.x(oro = 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. - 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.
- 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 —