Chrome · V8 › Compiler
CVE-2026-95304
OOB in V8 (Compiler)
High
patch-now
~ plausible
CVSS 8.8
chrome
OOB
🔧 Commit mapped
🧬 Architecture codegen violates zero-extension and ABI invariants
Overview
High
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
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 immediate0, it aims to lower the node to a no-op or a zero-extendingmovl.
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.
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
- Craft the module
Build a WebAssembly module whose bytecode produces a
Word32-typed value that is not guaranteed zero-extended, then feeds it through anInt32Subof0. - Trigger the bad lowering
The x64 instruction selector’s
VisitInt32Subsees the immediate0and emitsEmitIdentity, reusing the operand’s register with stale upper 32 bits intact. - 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.
- 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
| Function | Change | Notes |
|---|---|---|
ifsrc/compiler/backend/x64/instruction-selector-x64.cc |
modified |
Files Changed
src/compiler/backend/x64/instruction-selector-x64.cctest/mjsunit/regress/wasm/regress-560536731.js
Audit Directions
- Representation-vs-fact conflationSearch other
VisitInt32*/Visit*selectors and lowerings that gate zero-extension decisions onoutputs_rep()/type equality rather than aZeroExtendsWord32ToWord64-style guarantee. - `EmitIdentity` on 32-bit valuesReview every
EmitIdentitycall 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 advertisingAudit 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.).
Patch
From 057dcc408de1583cf68c4034bbba89d0f78a88a4 Mon Sep 17 00:00:00 2001
From: Matthias Liedtke <mliedtke@chromium.org>
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 <dmercadier@chromium.org>
Commit-Queue: Matthias Liedtke <mliedtke@chromium.org>
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.
References
On This Page