Overview

High
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds write in V8
DescriptionOut of bounds write in V8
ComponentV8
Sub-componentCompiler
Bug ClassOOB
Action tierpatch-now — corruption primitive, web-reachable in default config (plausible) — upheld by independent review by AI
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.
Independent review by AIPlausible
Reviewer notes: Root cause (missing zero-extension in x64 ISel) confirmed, but the regress test only shows a wrong i64 value; the dirty-index-to-OOB-write path is inferred, not shown. x64-only limitation omitted. Test forces tiering via testing flag.
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker560536731
Fix commit057dcc408de1 (v8/v8) +64/-3
CWECWE-787 (Out-of-bounds write)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
CreditedOpenAI Codex Security (amyb)
Reported2026-09-12 (per vendor advisory)
Fix landed2026-09-16 (public git)
Disclosed2026-09-22
Patch gap6 days — fix public in source before the advisory

Background

Zero-extension
On x64, writing to a 32-bit register (movl) automatically clears the upper 32 bits of the 64-bit register; V8 tracks which values are “zero-extended” so they can be safely reused as 64-bit values (e.g. array indices).
`EmitIdentity`
Instruction-selector helper that reuses the first input’s virtual register directly as the node’s output, emitting no machine instruction; it copies the register contents as-is, including any stale high bits.
`ZeroExtendsWord32ToWord64`
Predicate that returns true only when a value is genuinely guaranteed to have its upper 32 bits cleared, not merely typed as Word32().
`VisitInt32Sub`
x64 instruction-selection routine for Int32Sub; when subtracting immediate 0, it aims to lower the node to a no-op or a zero-extending movl.

Root Cause Analysis

Verify before building a PoC

This analysis is consistent with the fixing commit but not fully provable from it: part of the vulnerable path lives in code the fix commit does not touch. Confirm the trigger and the sink against the source before relying on it for exploit development.
What the commit does not establish: Root cause (missing zero-extension in x64 ISel) confirmed, but the regress test only shows a wrong i64 value; the dirty-index-to-OOB-write path is inferred, not shown. x64-only limitation omitted. Test forces tiering via testing flag.
In VisitInt32Sub, the special case for subtracting the immediate 0 decided whether it could emit a no-op (EmitIdentity) versus a real movl by checking this->Get(left).outputs_rep()[0] == RegisterRepresentation::Word32(). That check only tests the representation of the operand, which is not the same as a guarantee that the value’s upper 32 bits are already zero. Because Int32Sub itself advertises its result as zero-extended, downstream consumers assume the full 64-bit register is clean. When the left operand was a Word32-typed value that was not actually zero-extended, EmitIdentity reused its register verbatim, leaving garbage in the high 32 bits while the node still claimed to be zero-extending. Replacing the check with ZeroExtendsWord32ToWord64(left) ensures EmitIdentity is used only when the value is truly zero-extended, otherwise falling back to an explicit movl that clears the high bits. The stale high bits could then be trusted by later code (such as an index computation), producing a 64-bit address with attacker-influenced upper bits and an out-of-bounds write.
Key insight
Representation (Word32()) is not a proof of zero-extension; conflating the two let a value with dirty high bits inherit Int32Sub’s zero-extension guarantee, so the fix gates the no-op lowering on the stronger ZeroExtendsWord32ToWord64 fact.

Attack Path

  1. Craft the module Build a WebAssembly module whose bytecode produces a Word32-typed value that is not guaranteed zero-extended, then feeds it through an Int32Sub of 0.
  2. Trigger the bad lowering The x64 instruction selector’s VisitInt32Sub sees the immediate 0 and emits EmitIdentity, reusing the operand’s register with stale upper 32 bits intact.
  3. Propagate the dirty value Consumers trust the falsely-advertised zero-extension and use the full 64-bit register (e.g. as a memory index) without masking the high bits.
  4. Force the OOB access The garbage high bits shift the effective address far outside the intended bounds, yielding an out-of-bounds write.

Impact Assessment

This is an out-of-bounds write in V8’s x64 compiler backend reachable from a crafted WebAssembly module. Controlling the stale high bits gives an attacker influence over the write address, providing a strong primitive for memory corruption and likely remote code execution within the renderer sandbox.

Changed Functions

FunctionChangeNotes
if
src/compiler/backend/x64/instruction-selector-x64.cc
modified

Files Changed

  • src/compiler/backend/x64/instruction-selector-x64.cc
  • test/mjsunit/regress/wasm/regress-560536731.js

Audit Directions

  • Representation-vs-fact conflation
    Search other VisitInt32*/Visit* selectors and lowerings that gate zero-extension decisions on outputs_rep()/type equality rather than a ZeroExtendsWord32ToWord64-style guarantee.
  • `EmitIdentity` on 32-bit values
    Review every EmitIdentity call reached from a Word32 operation to confirm the reused register is proven zero-extended before it is trusted as a 64-bit value.
  • Zero-extension advertising
    Audit operations that self-advertise as zero-extending (like Int32Sub) to ensure all their lowering paths actually clear the upper 32 bits, especially fast/no-op paths for identity-like inputs (subtract 0, add 0, or 0, etc.).
From 057dcc408de1583cf68c4034bbba89d0f78a88a4 Mon Sep 17 00:00:00 2001
From: Matthias Liedtke <[email protected]>
Date: Wed, 16 Sep 2026 11:07:59 +0200
Subject: [PATCH] [compiler] ISel: Fix VisitInt32Sub for 32-bit lhs

Only skip zero-extension of lhs if it is guaranteed to be zero-extended
already.

Fixed: 560536731
Bug: 562243479
Change-Id: I76bdfa74e60dce43817da96832e5cc2e021c06f9
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/8411046
Reviewed-by: Darius Mercadier <[email protected]>
Commit-Queue: Matthias Liedtke <[email protected]>
Cr-Commit-Position: refs/heads/main@{#109855}
---
diff --git a/src/compiler/backend/x64/instruction-selector-x64.cc b/src/compiler/backend/x64/instruction-selector-x64.cc
index 3adb6a4..fb12535 100644
--- a/src/compiler/backend/x64/instruction-selector-x64.cc
+++ b/src/compiler/backend/x64/instruction-selector-x64.cc
@@ -2264,10 +2264,10 @@
   if (g.CanBeImmediate(right)) {
     int32_t imm = g.GetImmediateIntegerValue(right);
     if (imm == 0) {
-      if (this->Get(left).outputs_rep()[0] ==
-          RegisterRepresentation::Word32()) {
+      if (ZeroExtendsWord32ToWord64(left)) {
         // {EmitIdentity} reuses the virtual register of the first input
-        // for the output. This is exactly what we want here.
+        // for the output. This is only safe if it is known to zero-extend
+        // (as the int32 subtraction advertises itself as zero-extending.)
         EmitIdentity(node);
       } else {
         // Emit "movl" for subtraction of 0.
diff --git a/test/mjsunit/regress/wasm/regress-560536731.js b/test/mjsunit/regress/wasm/regress-560536731.js
new file mode 100644
index 0000000..57c3d56
--- /dev/null
+++ b/test/mjsunit/regress/wasm/regress-560536731.js
@@ -0,0 +1,61 @@
+// 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: --wasm-tier-mask-for-testing=1
+
+d8.file.execute('test/mjsunit/wasm/wasm-module-builder.js');
+
+let helperBuilder = new WasmModuleBuilder();
+let sig64_h =
+    helperBuilder.addType(makeSig([kWasmI64], [kWasmI64, kWasmI64, kWasmI64]));
+let sig32_h =
+    helperBuilder.addType(makeSig([kWasmI32], [kWasmI32, kWasmI32, kWasmI32]));
+
+helperBuilder.addFunction('prime', sig64_h)
+    .addBody([kExprLocalGet, 0, kExprLocalGet, 0, kExprLocalGet, 0])
+    .exportFunc();
+
+helperBuilder.addFunction('get', sig32_h)
+    .addBody([kExprLocalGet, 0, kExprLocalGet, 0, kExprLocalGet, 0])
+    .exportFunc();
+
+let helper = helperBuilder.instantiate({});
+
+let builder = new WasmModuleBuilder();
+let sig64 = builder.addType(makeSig([kWasmI64], [kWasmI64, kWasmI64, kWasmI64]));
+let sig32 = builder.addType(makeSig([kWasmI32], [kWasmI32, kWasmI32, kWasmI32]));
+let primeImport = builder.addImport('m', 'prime', sig64);
+let getImport = builder.addImport('m', 'get', sig32);
+
+builder.addFunction('test', makeSig([kWasmI64, kWasmI32, kWasmI32], [kWasmI64]))
+    .addLocals(kWasmI32, 1)
+    .addBody([
+      kExprLocalGet, 0,
+      kExprCallFunction, primeImport,
+      kExprDrop, kExprDrop, kExprDrop,
+      kExprLocalGet, 2,
+      kExprIf, kWasmVoid,
+        kExprLoop, kWasmVoid,
+          kExprLocalGet, 1,
+          kExprCallFunction, getImport,
+          kExprLocalGet, 3,
+          kExprI32Sub,
+          kExprI64UConvertI32,
+          kExprLocalGet, 2,
+          kExprI32Eqz,
+          kExprIf, kWasmVoid,
+            kExprLocalGet, 1,
+            kExprLocalSet, 3,
+            kExprBr, 1,
+          kExprEnd,
+          kExprReturn,
+        kExprEnd,
+      kExprEnd,
+      kExprI64Const, 42,
+    ])
+    .exportFunc();
+
+let instance = builder.instantiate({m: helper.exports});
+
+assertEquals(0x4d2n, instance.exports.test(0x133700000000n, 1234, 1));
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/test/mjsunit/regress/wasm/regress-560536731.js b/test/mjsunit/regress/wasm/regress-560536731.js
new file mode 100644
index 0000000..57c3d56
--- /dev/null
+++ b/test/mjsunit/regress/wasm/regress-560536731.js
@@ -0,0 +1,61 @@
+// 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: --wasm-tier-mask-for-testing=1
+
+d8.file.execute('test/mjsunit/wasm/wasm-module-builder.js');
+
+let helperBuilder = new WasmModuleBuilder();
+let sig64_h =
+    helperBuilder.addType(makeSig([kWasmI64], [kWasmI64, kWasmI64, kWasmI64]));
+let sig32_h =
+    helperBuilder.addType(makeSig([kWasmI32], [kWasmI32, kWasmI32, kWasmI32]));
+
+helperBuilder.addFunction('prime', sig64_h)
+    .addBody([kExprLocalGet, 0, kExprLocalGet, 0, kExprLocalGet, 0])
+    .exportFunc();
+
+helperBuilder.addFunction('get', sig32_h)
+    .addBody([kExprLocalGet, 0, kExprLocalGet, 0, kExprLocalGet, 0])
+    .exportFunc();
+
+let helper = helperBuilder.instantiate({});
+
+let builder = new WasmModuleBuilder();
+let sig64 = builder.addType(makeSig([kWasmI64], [kWasmI64, kWasmI64, kWasmI64]));
+let sig32 = builder.addType(makeSig([kWasmI32], [kWasmI32, kWasmI32, kWasmI32]));
+let primeImport = builder.addImport('m', 'prime', sig64);
+let getImport = builder.addImport('m', 'get', sig32);
+
+builder.addFunction('test', makeSig([kWasmI64, kWasmI32, kWasmI32], [kWasmI64]))
+    .addLocals(kWasmI32, 1)
+    .addBody([
+      kExprLocalGet, 0,
+      kExprCallFunction, primeImport,
+      kExprDrop, kExprDrop, kExprDrop,
+      kExprLocalGet, 2,
+      kExprIf, kWasmVoid,
+        kExprLoop, kWasmVoid,
+          kExprLocalGet, 1,
+          kExprCallFunction, getImport,
+          kExprLocalGet, 3,
+          kExprI32Sub,
+          kExprI64UConvertI32,
+          kExprLocalGet, 2,
+          kExprI32Eqz,
+          kExprIf, kWasmVoid,
+            kExprLocalGet, 1,
+            kExprLocalSet, 3,
+            kExprBr, 1,
+          kExprEnd,
+          kExprReturn,
+        kExprEnd,
+      kExprEnd,
+      kExprI64Const, 42,
+    ])
+    .exportFunc();
+
+let instance = builder.instantiate({m: helper.exports});
+
+assertEquals(0x4d2n, instance.exports.test(0x133700000000n, 1234, 1));
Loading diff…

Original Bug Report

The reporter's bug is still restricted on the tracker. Chrome de-restricts security bugs ~30–90 days after the fix ships; a later run will backfill it here.