Low patch-now ✔︎ verified CVSS 8.8 chrome Type Confusion 🔧 Commit mapped 🧬 Broken deopt/OSR bookkeeping and unsound escape analysis

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 tierpatch-now — corruption primitive, web-reachable in default config (confirmed) — 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 AIConfirmed
Reviewer notes: Commit message and diff both confirm missing MaybeOSR() after interrupt-handling instructions causing stale-OSR/mismatched-stack type confusion; x64-only and interrupt-race-dependent, as claimed.
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker520017306
Fix commit701254bc2334 (v8/v8) +41/-8
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-06-04 (per vendor advisory)
Fix landed2026-06-17 (public git)
Disclosed2026-07-29
Patch gap42 days — fix public in source before the advisory

Background

Liftoff
V8’s baseline WebAssembly compiler that produces machine code quickly with minimal optimization, keeping values in a simple per-instruction stack layout.
OSR (On-Stack Replacement)
the mechanism that swaps a running function’s code for a newly (re)compiled version mid-execution, requiring the current stack state to match the target code’s expectations.
Deferred OSR
the x64 Liftoff scheme where an interrupt writes an OSR target into a stack slot that must be consumed by MaybeOSR() before the next instruction executes, rather than jumping immediately.
Type confusion
a memory-safety error in which a value is accessed as a type it is not, arising here when execution continues in stale code whose assumed stack layout no longer matches reality.

Root Cause Analysis

On x64, Liftoff installs a deferred OSR request into a dedicated stack slot whenever an interrupt fires during a runtime/builtin call, and that request must be picked up by a MaybeOSR() call emitted immediately after the interrupt-handling instruction. Several instructions that call into the runtime — WasmMemoryGrow, the atomic AtomicWait/wait-notify path, RefTest, TableGrow, TableCopy, and various array allocation/initialization builtins — were missing that MaybeOSR() consumption, so a pending OSR target could survive into the next instruction. When the request was finally acted upon, execution resumed in the OSR target (older/mismatched) code while the actual register and stack state corresponded to a different program point, so values were interpreted under the wrong type/layout, yielding type confusion.

The fix adds the missing MaybeOSR() calls after each of those instructions so every deferred request is consumed at the correct point, and it adds a debug-only AssertOSREmpty() at the start of every breakable instruction to catch any future omission. It also isolates the whole deferred mechanism to x64 via #if V8_TARGET_ARCH_X64 guards and the new IF_TARGET_ARCH_X64 macro, since only x64 uses stack-slot deferral.

Key insight
The single mistake is that several interrupt-handling Liftoff instructions left a deferred OSR target sitting in its stack slot without a following MaybeOSR() to consume it, letting a stale OSR fire against a mismatched stack; the fix guarantees consumption by adding the missing MaybeOSR() calls and enforces the invariant with a debug AssertOSREmpty() check.

Attack Path

  1. Prime an OSR-eligible function The attacker crafts a WebAssembly module whose Liftoff-compiled function is marked for tier-up/OSR and reaches one of the un-hardened builtin-calling instructions (e.g. array allocation, TableGrow, or AtomicWait).
  2. Trigger an interrupt during the builtin call While that runtime/builtin call executes, an interrupt fires and the deferred mechanism writes an OSR target into the reserved stack slot.
  3. Skip consumption Because that instruction lacks a trailing MaybeOSR(), the pending OSR target is not consumed and remains live as execution proceeds to subsequent instructions.
  4. Resume in mismatched code When the deferred OSR is eventually taken, execution continues in OSR-target code whose expected stack/register layout no longer matches the actual state.
  5. Exploit type confusion The layout mismatch causes a value to be treated as the wrong type, giving the attacker a memory-corruption primitive that can be shaped toward arbitrary read/write.

Impact Assessment

An attacker who can run crafted WebAssembly (e.g. from a malicious web page) gains a type-confusion primitive inside the V8/renderer process, which is a strong foothold toward arbitrary memory read/write and renderer compromise. The bug is specific to the x64 deferred-OSR path and requires the module to hit an affected interrupt-handling instruction while an OSR-triggering interrupt occurs, so it is a race/timing-dependent condition rather than deterministic. No elevated privileges beyond executing Wasm are required, but the attacker must control the module and provoke the interrupt.

Files Changed

  • src/base/macros.h
  • src/codegen/bailout-reason.h
  • src/wasm/baseline/arm/liftoff-assembler-arm-inl.h
  • src/wasm/baseline/arm64/liftoff-assembler-arm64-inl.h
  • src/wasm/baseline/ia32/liftoff-assembler-ia32-inl.h
  • src/wasm/baseline/liftoff-assembler.h
  • src/wasm/baseline/liftoff-compiler.cc

Audit Directions

  • Builtin/runtime calls in Liftoff without trailing `MaybeOSR()`
    Enumerate every x64 Liftoff instruction that calls into a builtin or runtime function during which an interrupt can fire, and verify each is immediately followed by MaybeOSR(); the new AssertOSREmpty() check is the fastest way to surface stragglers.
  • Deferred/asynchronous state that must be drained before the next step
    Look for other mechanisms that stash pending work in a slot or flag expected to be consumed by a specific follow-up call, and audit every producer for a matching consumer on all control-flow paths.
  • Architecture-specific invariants leaking into shared code
    Review places where a primitive is a no-op on some architectures but load-bearing on another (as MaybeOSR was), since shared call sites can silently omit the operation the one architecture that needs it depends on.
From 701254bc23346416c19c6793da86ba13de35bef4 Mon Sep 17 00:00:00 2001
From: Clemens Backes <clemensb@chromium.org>
Date: Tue, 16 Jun 2026 14:35:34 +0200
Subject: [PATCH] [wasm][liftoff] Harden OSR state on x64

On x64, Liftoff uses a deferred OSR mechanism where an interrupt sets an
OSR target in a stack slot. This request must be consumed by a
MaybeOSR() call before the next instruction, otherwise type confusion
can occur if execution continues in old code with a mismatched stack.

This CL:
1. Adds missing MaybeOSR() calls after instructions that handle
   interrupts (AtomicWait, MemoryGrow, TableGrow, TableCopy, StructWait,
   RefTest, and various array allocation/initialization builtins).
2. Introduces a debug-only AssertOSREmpty() check at the start of every
   breakable instruction (via EmitDebuggingInfo) to ensure no pending
   OSR request was missed.
3. Refactors OSR-related methods to be strictly architecture-isolated
   to x64, where the deferred mechanism is used.
4. Adds IF_TARGET_ARCH_X64 macro to facilitate this isolation.

R=thibaudm@chromium.org

TAG=agy

Bug: 520017306
Change-Id: Iba67891bfcd45b1c7cbf9ab03f2e511c007e0d13
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/7946779
Reviewed-by: Thibaud Michaud <thibaudm@chromium.org>
Commit-Queue: Clemens Backes <clemensb@chromium.org>
Cr-Commit-Position: refs/heads/main@{#108048}
---

diff --git a/src/base/macros.h b/src/base/macros.h
index 4355dec8..c2c24e8 100644
--- a/src/base/macros.h
+++ b/src/base/macros.h
@@ -606,6 +606,15 @@
 #define IF_TARGET_ARCH_32_BIT(V, ...) EXPAND(V(__VA_ARGS__))
 #endif  // V8_TARGET_ARCH_64_BIT
 
+// Defines IF_TARGET_ARCH_X64, to be used in macro lists for elements that
+// should only be there if the target architecture is x64.
+#if V8_TARGET_ARCH_X64
+// EXPAND is needed to work around MSVC's broken __VA_ARGS__ expansion.
+#define IF_TARGET_ARCH_X64(V, ...) EXPAND(V(__VA_ARGS__))
+#else
+#define IF_TARGET_ARCH_X64(V, ...)
+#endif  // V8_TARGET_ARCH_X64
+
 // Defines IF_V8_WASM_RANDOM_FUZZERS and IF_NO_V8_WASM_RANDOM_FUZZERS, to be
 // used in macro lists for elements that should only be there/absent when
 // building the Wasm fuzzers.
diff --git a/src/codegen/bailout-reason.h b/src/codegen/bailout-reason.h
index bdae933..be534d6 100644
--- a/src/codegen/bailout-reason.h
+++ b/src/codegen/bailout-reason.h
@@ -129,6 +129,8 @@
   V(k32BitValueInRegisterIsNotSignExtended,                                   \
     "32 bit value in register is not sign-extended")                          \
   V(kUnexpectedSandboxMode, "The sandboxing mode is not as expected")         \
+  IF_TARGET_ARCH_X64(V, kOSREmptyCheckFailed,                                 \
+                     "OSR target slot should be empty")                       \
   V(kLastReason, "")
 
 #define TERMINAL_BAILOUT_MESSAGES_LIST(V)                                  \
diff --git a/src/wasm/baseline/arm/liftoff-assembler-arm-inl.h b/src/wasm/baseline/arm/liftoff-assembler-arm-inl.h
index 1008602..3de6328 100644
--- a/src/wasm/baseline/arm/liftoff-assembler-arm-inl.h
+++ b/src/wasm/baseline/arm/liftoff-assembler-arm-inl.h
@@ -5134,7 +5134,6 @@
   add(sp, sp, Operand(size));
 }
 
-void LiftoffAssembler::MaybeOSR() {}
 
 void LiftoffStackSlots::Construct(int param_slots) {
   DCHECK_LT(0, slots_.size());
diff --git a/src/wasm/baseline/arm64/liftoff-assembler-arm64-inl.h b/src/wasm/baseline/arm64/liftoff-assembler-arm64-inl.h
index 1ebd338a..a2c220c 100644
--- a/src/wasm/baseline/arm64/liftoff-assembler-arm64-inl.h
+++ b/src/wasm/baseline/arm64/liftoff-assembler-arm64-inl.h
@@ -4659,7 +4659,6 @@
   Drop(size, 1);
 }
 
-void LiftoffAssembler::MaybeOSR() {}
 
 void LiftoffStackSlots::Construct(int param_slots) {
   DCHECK_LT(0, slots_.size());
diff --git a/src/wasm/baseline/ia32/liftoff-assembler-ia32-inl.h b/src/wasm/baseline/ia32/liftoff-assembler-ia32-inl.h
index c1b7750..de73188 100644
--- a/src/wasm/baseline/ia32/liftoff-assembler-ia32-inl.h
+++ b/src/wasm/baseline/ia32/liftoff-assembler-ia32-inl.h
@@ -5343,7 +5343,6 @@
   add(esp, Immediate(size));
 }
 
-void LiftoffAssembler::MaybeOSR() {}
 
 void LiftoffStackSlots::Construct(int param_slots) {
   DCHECK_LT(0, slots_.size());
diff --git a/src/wasm/baseline/liftoff-assembler.h b/src/wasm/baseline/liftoff-assembler.h
index 966498d..3d1b96c 100644
--- a/src/wasm/baseline/liftoff-assembler.h
+++ b/src/wasm/baseline/liftoff-assembler.h
@@ -1639,8 +1639,11 @@
   inline void AllocateStackSlot(Register addr, uint32_t size);
   inline void DeallocateStackSlot(uint32_t size);
 
+#if V8_TARGET_ARCH_X64
   // Instrumentation for shadow-stack-compatible OSR on x64.
   inline void MaybeOSR();
+  inline void AssertOSREmpty();
+#endif
 
   inline bool supports_f16_mem_access();
 
diff --git a/src/wasm/baseline/liftoff-compiler.cc b/src/wasm/baseline/liftoff-compiler.cc
index 91b5892..5b178d9 100644
--- a/src/wasm/baseline/liftoff-compiler.cc
+++ b/src/wasm/baseline/liftoff-compiler.cc
@@ -1556,6 +1556,12 @@
   V8_NOINLINE void EmitDebuggingInfo(FullDecoder* decoder, WasmOpcode opcode) {
     DCHECK(for_debugging_);
 
+#if V8_TARGET_ARCH_X64
+    // If OSR is triggered during a builtin call, it must be consumed by
+    // {MaybeOSR} before the next instruction.
+    __ AssertOSREmpty();
+#endif
+
     if (V8_UNLIKELY(v8_flags.wasm_code_coverage && (opcode != kExprLoop))) {
       // Coverage instrumention for 'loop' instructions is emitted in Loop().
       EmitCoverageInstrumentationIfReachable(decoder, opcode);
@@ -4649,6 +4655,7 @@
     __ CallBuiltin(Builtin::kWasmMemoryGrow);
     DefineSafepoint();
     RegisterDebugSideTableEntry(decoder, DebugSideTableBuilder::kDidSpill);
+    MaybeOSR();
 
     if (kReturnRegister0 != result.gp()) {
       __ Move(result.gp(), kReturnRegister0, kI32);
@@ -6531,6 +6538,7 @@
     RegisterDebugSideTableEntry(decoder, DebugSideTableBuilder::kDidSpill);
 
     __ PushRegister(kI32, LiftoffRegister(kReturnRegister0));
+    MaybeOSR();
   }
 
   void AtomicNotify(FullDecoder* decoder, const MemoryAccessImmediate& imm) {
@@ -6956,6 +6964,7 @@
          VarState{kRef, LiftoffRegister{kReturnRegister0}, 0}},
         decoder->position());
     __ PushRegister(kI32, LiftoffRegister{kReturnRegister0});
+    MaybeOSR();
   }
 
   void WaitqueueNotify(FullDecoder* decoder, const Value& waitqueue,
@@ -7649,6 +7658,7 @@
         decoder->position());
 
     RegisterDebugSideTableEntry(decoder, DebugSideTableBuilder::kDidSpill);
+    MaybeOSR();
   }
 
   void TableGrow(FullDecoder* decoder, const TableIndexImmediate& imm,
@@ -7672,6 +7682,7 @@
                 decoder->position());
 
     RegisterDebugSideTableEntry(decoder, DebugSideTableBuilder::kDidSpill);
+    MaybeOSR();
     __ SmiToInt32(kReturnRegister0);
     if (imm.table->is_table64()) {
       LiftoffRegister result64 = LiftoffRegister(kReturnRegister0);
@@ -7995,6 +8006,7 @@
                    __ cache_state()->stack_state.end()[-1],  // length
                    VarState{kI32, elem_size, 0}},
                   decoder->position());
+      MaybeOSR();
     }
 
     LiftoffRegister obj(kReturnRegister0);
@@ -8250,6 +8262,7 @@
                 {VarState{kRef, rtt, 0}, VarState{kI32, elem_count, 0},
                  VarState{kI32, value_kind_size(elem_kind), 0}},
                 decoder->position());
+    MaybeOSR();
 
     // Initialize the array with stack arguments.
     LiftoffRegister array(kReturnRegister0);
@@ -8418,6 +8431,8 @@
 
     // Pop parameters from the value stack.
     __ DropValues(2);
+
+    MaybeOSR();
     RegisterDebugSideTableEntry(decoder, DebugSideTableBuilder::kDidSpill);
 
     LiftoffRegister result(kReturnRegister0);
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.