Low watch CVSS 8.8 chrome Type Confusion 🔧 Commit mapped

Overview

Low
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactType confusion in V8
DescriptionType confusion in V8
ComponentV8
Sub-componentWASM
Bug ClassType Confusion
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.
Tracker534579660
Fix commit3464747444ff (v8/v8) +134/-16
CWECWE-843 (Type confusion)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-07-14 (per vendor advisory)
Fix landed2026-07-20 (public git)
Disclosed2026-09-22
Patch gap64 days — fix public in source before the advisory

Background

Liftoff
V8’s baseline WebAssembly compiler; its generated code can be swapped at runtime for instrumented debug code via On-Stack Replacement.
OSR (On-Stack Replacement)
mid-execution transition from one compiled version of a function to another; here from live Liftoff code into debug code, requiring the old machine PC to be mapped to the new code’s PC.
Source-position table
a per-code table mapping machine code_offsets to WebAssembly bytecode byte_offsets (ScriptOffset); FindNewPC walks it to translate PCs across the two code versions.
Safepoint
metadata attached to a specific PC describing which registers/stack slots hold tagged (GC-managed) pointers versus raw values; the GC relies on it being correct for the actual PC.

Root Cause Analysis

FindNewPC maps an old suspend/call PC to the corresponding PC in the freshly generated debug code by matching the WebAssembly byte_offset in the new source-position table. For the kAfterWasmCall case it selected the last matching code_offset in the first contiguous run of entries sharing that byte_offset. On non-x64 architectures, however, intermediate table entries such as stack checks and out-of-line (OOL) trap entries can split a single byte offset’s matching entries into multiple non-contiguous runs. Because the walk stopped at the first gap, it returned the earlier inline breakpoint PC (A_inline) rather than the correct OOL trap PC (A_ool) that actually corresponds to the old suspend point. The returned PC therefore pointed at code with a different safepoint than the machine’s real state, so subsequent safepoint/register-index consistency checks failed during GC or breakpoint installation.

The fix determines whether the old suspend point was the last match in the old table and, if so, scans the entire new table for the absolute last match (the OOL trap), otherwise keeps the first-run inline match.

Key insight
The mapping assumed all entries sharing a byte_offset were contiguous, but architecture-dependent intermediate entries (stack checks, OOL traps) break that assumption — so “last match in the first run” is not the same as “last match in the table,” and only the latter identifies the true OOL trap PC.

Attack Path

  1. Reach debug OSR Trigger installation of WebAssembly debug code (e.g. setting a breakpoint) so that a running Liftoff frame is On-Stack-Replaced into debug code on a non-x64 target.
  2. Land on a split position Arrange execution to be suspended at a call site (kAfterWasmCall) whose byte_offset run is split by an intermediate stack-check or OOL trap entry.
  3. Mismap the PC FindNewPC returns the inline breakpoint PC A_inline instead of the OOL trap PC A_ool, so the resumed frame is associated with the wrong safepoint.
  4. Force a GC scan Induce garbage collection (or the breakpoint-setting consistency check) so the collector reads the mismatched safepoint and scans slots according to the wrong tagged/untagged layout.
  5. Type confusion The GC treats a raw value as a tagged pointer (or vice versa), yielding a corrupted object reference exploitable as a type confusion primitive.

Impact Assessment

An incorrect PC mapping desynchronizes the safepoint from the actual register/stack state, causing the garbage collector to scan the wrong slots for tagged pointers. This produces a type confusion between tagged and untagged values in V8, corrupting memory in a way that is a strong foundation for further exploitation. It requires the debugger/OSR path on non-x64 architectures, but once reached leads to attacker-influenceable memory corruption during GC.

Changed Functions

FunctionChangeNotes
while
src/wasm/wasm-debug.cc
modified
if
src/wasm/wasm-debug.cc
modified
if
test/mjsunit/wasm/regress-534579660.js
modified

Files Changed

  • test/mjsunit/wasm/regress-534579660.js
  • src/wasm/wasm-debug.cc

Audit Directions

  • Contiguity assumptions in position tables
    Review any code that walks the source-position table assuming entries for one byte_offset are contiguous; intermediate stack-check/OOL entries can split runs, especially on non-x64 targets.
  • PC-to-safepoint mapping across code versions
    Audit OSR and deopt paths that remap PCs (FindNewPC and callers) to confirm the mapped PC’s safepoint matches the true machine state before GC or consistency checks run.
  • Architecture-divergent codegen
    Look for logic validated only on x64 where non-x64 emit extra entries (traps, stack checks), since layout differences change which table entry is “last” or “correct.”
From 3464747444ff8f85e429c82e5502d4592ca826ce Mon Sep 17 00:00:00 2001
From: Clemens Backes <[email protected]>
Date: Fri, 17 Jul 2026 16:31:24 +0200
Subject: [PATCH] [wasm][debug] Fix OSR safepoint lookup for non-contiguous positions

During WebAssembly Liftoff debug On-Stack Replacement (OSR) on non-x64
architectures, intermediate table entries (like stack checks or OOL trap
entries) can split a single byte offset's contiguous source-position
table run.

Previously, FindNewPC walked only the contiguous run, returning the
lower inline breakpoint PC (A_inline) instead of the correct
out-of-line (OOL) trap PC (A_ool). This caused safepoint/register-
indexes mismatch checks to fail during GC or when setting breakpoints.

We now scan the entire source-position table and find the entry with
the maximum code offset matching the byte offset when the return
location is kAfterWasmCall, ensuring the correct OOL target PC is
returned.

TAG=agy
[email protected]

Bug: 534579660
Change-Id: I8ef613267dacb701ec635b8eda8add9e924a7f45
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/8092868
Reviewed-by: Matthias Liedtke <[email protected]>
Commit-Queue: Clemens Backes <[email protected]>
Cr-Commit-Position: refs/heads/main@{#108754}
---
diff --git a/src/wasm/wasm-debug.cc b/src/wasm/wasm-debug.cc
index fb35d6a..8a89798 100644
--- a/src/wasm/wasm-debug.cc
+++ b/src/wasm/wasm-debug.cc
@@ -53,42 +53,67 @@
   WasmCode* old_code = frame->wasm_code();
   int pc_offset = static_cast<int>(frame->pc() - old_code->instruction_start());
   base::Vector<const uint8_t> old_pos_table = old_code->source_positions();
-  SourcePositionTableIterator old_it(old_pos_table);
   int call_offset = -1;
-  while (!old_it.done() && old_it.code_offset() < pc_offset) {
+  for (SourcePositionTableIterator old_it(old_pos_table);
+       !old_it.done() && old_it.code_offset() < pc_offset; old_it.Advance()) {
     call_offset = old_it.code_offset();
-    old_it.Advance();
   }
   DCHECK_LE(0, call_offset);
   int call_instruction_size = pc_offset - call_offset;
 
-  // If {return_location == kAfterBreakpoint} we search for the first code
-  // offset which is marked as instruction (i.e. not the breakpoint).
-  // If {return_location == kAfterWasmCall} we return the last code offset
-  // associated with the byte offset.
+  // If {return_location == kAfterBreakpoint}, we want to skip the breakpoint
+  // preamble/handler and resume at the first instruction representing the Wasm
+  // statement. Thus, we find the first code offset marked as a statement.
+  // If {return_location == kAfterWasmCall}, the frame is waiting for a call
+  // to return. The call instruction may be inline or inside Out-Of-Line (OOL)
+  // code (which is appended at the end of the function). In both cases, the
+  // call is the last instruction emitted for this bytecode offset. Thus, we
+  // want the last matching code offset.
   SourcePositionTableIterator it(new_pos_table);
   while (true) {
     CHECK(!it.done());
     if (it.source_position().ScriptOffset() == byte_offset) break;
     it.Advance();
   }
+  int code_offset;
   if (return_location == kAfterBreakpoint) {
     while (!it.is_statement()) {
       it.Advance();
       CHECK(!it.done());
     }
     DCHECK_EQ(byte_offset, it.source_position().ScriptOffset());
-    return wasm_code->instruction_start() + it.code_offset() +
-           call_instruction_size;
-  }
-
-  DCHECK_EQ(kAfterWasmCall, return_location);
-  int code_offset;
-  while (true) {
     code_offset = it.code_offset();
-    it.Advance();
-    if (it.done() || it.source_position().ScriptOffset() != byte_offset) break;
+  } else {
+    DCHECK_EQ(kAfterWasmCall, return_location);
+    // Find the last matching entry in the old code. If the old suspend point
+    // was the last match, we want the last match in the new code (e.g. for OOL
+    // trap). Otherwise we want the inline call (which is the last matching
+    // entry in the first contiguous run).
+    int last_old_match = -1;
+    for (SourcePositionTableIterator old_group_it(old_pos_table);
+         !old_group_it.done(); old_group_it.Advance()) {
+      if (old_group_it.source_position().ScriptOffset() == byte_offset) {
+        last_old_match = old_group_it.code_offset();
+      }
+    }
+    bool was_last_entry = (call_offset == last_old_match);
+
+    // Find the last match in the first contiguous run.
+    code_offset = it.code_offset();
+    for (; !it.done() && it.source_position().ScriptOffset() == byte_offset;
+         it.Advance()) {
+      code_offset = it.code_offset();
+    }
+    if (was_last_entry) {
+      // Find the absolute last match in the rest of the table (e.g., OOL trap).
+      for (; !it.done(); it.Advance()) {
+        if (it.source_position().ScriptOffset() == byte_offset) {
+          code_offset = it.code_offset();
+        }
+      }
+    }
   }
+  CHECK_NE(code_offset, -1);
   return wasm_code->instruction_start() + code_offset + call_instruction_size;
 }
 
diff --git a/test/mjsunit/wasm/regress-534579660.js b/test/mjsunit/wasm/regress-534579660.js
new file mode 100644
index 0000000..bd04b3e
--- /dev/null
+++ b/test/mjsunit/wasm/regress-534579660.js
@@ -0,0 +1,93 @@
+// 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: --enable-inspector --allow-natives-syntax --expose-gc --liftoff
+// Flags: --no-wasm-tier-up
+
+d8.file.execute('test/mjsunit/wasm/wasm-module-builder.js');
+
+let next_id = 2;
+let breakpoint_set = false;
+let instance;
+let arr;
+
+function receive(message) {
+  let msg = JSON.parse(message);
+  if (msg.error) {
+    failWithMessage('Inspector error: ' + JSON.stringify(msg.error));
+  }
+  if (msg.id === 1) {
+    // Step 2: Debugger enabled, now set pause on exceptions
+    send(JSON.stringify({
+      id: 10,
+      method: 'Debugger.setPauseOnExceptions',
+      params: {state: 'all'}
+    }));
+  } else if (msg.id === 10) {
+    // Step 3: Pause on exceptions is set, now instantiate and run Wasm
+    instantiateAndRun();
+  } else if (msg.method === 'Debugger.paused') {
+    if (!breakpoint_set) {
+      breakpoint_set = true;
+      // The top frame is $trigger itself when paused on exception/trap
+      let triggerFrame = msg.params.callFrames[0];
+      let scriptId = triggerFrame.location.scriptId;
+      let columnNumber = triggerFrame.location.columnNumber;
+
+      // Set breakpoint inside trigger at the exact trapping columnNumber!
+      send(JSON.stringify({
+        id: 2,
+        method: 'Debugger.setBreakpoint',
+        params: {
+          location:
+              {scriptId: scriptId, lineNumber: 0, columnNumber: columnNumber}
+        }
+      }));
+    } else {
+      send(JSON.stringify({id: next_id++, method: 'Debugger.resume'}));
+    }
+  } else if (msg.id === 2) {
+    // Once the breakpoint is set, trigger GC while paused to verify the
+    // safepoint layout
+    gc();
+    send(JSON.stringify({id: 3, method: 'Debugger.resume'}));
+  }
+}
+
+function instantiateAndRun() {
+  let builder = new WasmModuleBuilder();
+  let array_type = builder.addArray(kWasmI32);
+
+  // create_array() returns (ref $array_type)
+  builder.addFunction('create_array', makeSig([], [wasmRefType(array_type)]))
+      .addBody([kExprI32Const, 5, kGCPrefix, kExprArrayNewDefault, array_type])
+      .exportFunc();
+
+  // trigger(arr_0, arr_1, ..., arr_29)
+  let num_params = 30;
+  let params = Array(num_params).fill(wasmRefType(array_type));
+  builder.addFunction('trigger', makeSig(params, [kWasmI32]))
+      .addLocals(kWasmI32, 1)
+      .addBody([
+        ...Array(num_params).fill(0).map((_, i) => [kExprLocalGet, i]).flat(),
+        ...wasmI32Const(0x7fffffff),  // OOB index
+        kGCPrefix, kExprArrayGet, array_type, kExprLocalSet, num_params,
+        ...Array(num_params - 1).fill(kExprDrop), kExprLocalGet, num_params
+      ])
+      .exportFunc();
+
+  let module_bytes = builder.toArray();
+  globalThis.body_offset = builder.functions[1].body_offset;
+
+  instance = builder.instantiate({});
+  arr = instance.exports.create_array();
+
+  assertThrows(
+      () => instance.exports.trigger(...Array(30).fill(arr)),
+      WebAssembly.RuntimeError);
+  assertTrue(breakpoint_set);
+}
+
+// Start sequence
+send(JSON.stringify({id: 1, method: 'Debugger.enable'}));
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/test/mjsunit/wasm/regress-534579660.js b/test/mjsunit/wasm/regress-534579660.js
new file mode 100644
index 0000000..bd04b3e
--- /dev/null
+++ b/test/mjsunit/wasm/regress-534579660.js
@@ -0,0 +1,93 @@
+// 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: --enable-inspector --allow-natives-syntax --expose-gc --liftoff
+// Flags: --no-wasm-tier-up
+
+d8.file.execute('test/mjsunit/wasm/wasm-module-builder.js');
+
+let next_id = 2;
+let breakpoint_set = false;
+let instance;
+let arr;
+
+function receive(message) {
+  let msg = JSON.parse(message);
+  if (msg.error) {
+    failWithMessage('Inspector error: ' + JSON.stringify(msg.error));
+  }
+  if (msg.id === 1) {
+    // Step 2: Debugger enabled, now set pause on exceptions
+    send(JSON.stringify({
+      id: 10,
+      method: 'Debugger.setPauseOnExceptions',
+      params: {state: 'all'}
+    }));
+  } else if (msg.id === 10) {
+    // Step 3: Pause on exceptions is set, now instantiate and run Wasm
+    instantiateAndRun();
+  } else if (msg.method === 'Debugger.paused') {
+    if (!breakpoint_set) {
+      breakpoint_set = true;
+      // The top frame is $trigger itself when paused on exception/trap
+      let triggerFrame = msg.params.callFrames[0];
+      let scriptId = triggerFrame.location.scriptId;
+      let columnNumber = triggerFrame.location.columnNumber;
+
+      // Set breakpoint inside trigger at the exact trapping columnNumber!
+      send(JSON.stringify({
+        id: 2,
+        method: 'Debugger.setBreakpoint',
+        params: {
+          location:
+              {scriptId: scriptId, lineNumber: 0, columnNumber: columnNumber}
+        }
+      }));
+    } else {
+      send(JSON.stringify({id: next_id++, method: 'Debugger.resume'}));
+    }
+  } else if (msg.id === 2) {
+    // Once the breakpoint is set, trigger GC while paused to verify the
+    // safepoint layout
+    gc();
+    send(JSON.stringify({id: 3, method: 'Debugger.resume'}));
+  }
+}
+
+function instantiateAndRun() {
+  let builder = new WasmModuleBuilder();
+  let array_type = builder.addArray(kWasmI32);
+
+  // create_array() returns (ref $array_type)
+  builder.addFunction('create_array', makeSig([], [wasmRefType(array_type)]))
+      .addBody([kExprI32Const, 5, kGCPrefix, kExprArrayNewDefault, array_type])
+      .exportFunc();
+
+  // trigger(arr_0, arr_1, ..., arr_29)
+  let num_params = 30;
+  let params = Array(num_params).fill(wasmRefType(array_type));
+  builder.addFunction('trigger', makeSig(params, [kWasmI32]))
+      .addLocals(kWasmI32, 1)
+      .addBody([
+        ...Array(num_params).fill(0).map((_, i) => [kExprLocalGet, i]).flat(),
+        ...wasmI32Const(0x7fffffff),  // OOB index
+        kGCPrefix, kExprArrayGet, array_type, kExprLocalSet, num_params,
+        ...Array(num_params - 1).fill(kExprDrop), kExprLocalGet, num_params
+      ])
+      .exportFunc();
+
+  let module_bytes = builder.toArray();
+  globalThis.body_offset = builder.functions[1].body_offset;
+
+  instance = builder.instantiate({});
+  arr = instance.exports.create_array();
+
+  assertThrows(
+      () => instance.exports.trigger(...Array(30).fill(arr)),
+      WebAssembly.RuntimeError);
+  assertTrue(breakpoint_set);
+}
+
+// Start sequence
+send(JSON.stringify({id: 1, method: 'Debugger.enable'}));
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.