Overview

High
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactType Confusion in V8
DescriptionType Confusion in V8
ComponentV8
Sub-componentTurboshaft
Bug ClassType Confusion
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: Internally inconsistent: calls the bad phi type 'over-broad' yet says the fix yields a 'tighter' type; an over-broad type would be sound. The diff shows the wrong snapshot lookup, not the direction of the type error. Memory read/corrupt impact is inferred.
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker383356864
Fix commitf231d83cb3c0 (v8/v8) +27/-6
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
CreditedPopax21
Reported2024-12-11 · bounty $55000 (per vendor advisory)
Fix landed2024-12-12 (public git)
Disclosed2025-01-07
Patch gap26 days — fix public in source before the advisory

Background

`WasmGCTypeAnalyzer`
A Turboshaft compiler analysis that infers precise WebAssembly-GC wasm::ValueType information for each SSA value so later optimizations can drop redundant type checks and casts.
`PhiOp`
A Turboshaft SSA node placed at the start of a block that merges values arriving from different control-flow predecessors, including the backedge of a loop.
Loop header / backedge
A loop’s entry block whose predecessors are the loop’s entry edge and its backedge; a single-block (endless) loop is its own last predecessor, so a backedge PhiOp input can reference a value defined in the same block.
`GetPredecessorValue`
A types_table_ lookup that returns the type an operand held in a specified predecessor block’s snapshot, which is the wrong source when the operand is defined inside the current block itself.

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: Internally inconsistent: calls the bad phi type 'over-broad' yet says the fix yields a 'tighter' type; an over-broad type would be sound. The diff shows the wrong snapshot lookup, not the direction of the type error. Memory read/corrupt impact is inferred.

In WasmGCTypeAnalyzer::ProcessPhi, each PhiOp input type was fetched with types_table_.GetPredecessorValue(ResolveAliases(phi.input(i)), i), which assumes every input is defined in predecessor block i. For a single-block (endless) loop, the loop header is its own predecessor, so the backedge input (input_index == 1) is itself a PhiOp defined earlier in the very same block; calling GetPredecessorValue for that operand read a stale/self value from the predecessor snapshot rather than the value’s actual computed type. This produced an over-broad or otherwise incorrect union type for the phi, violating the invariant that the analyzer’s inferred wasm::ValueType must soundly bound every runtime value the phi can hold.

The fix adds WasmGCTypeAnalyzer::GetTypeForPhiInput, which detects when an input’s OpIndex lies between the current block’s begin and the phi’s own id and, in that in-block backedge case, returns types_table_.Get(input) (the value’s real type) instead of the predecessor lookup. With the correct, tighter type in place, downstream optimizations no longer eliminate casts based on an unsound type assumption.

Key insight
The single mistake was treating a loop-header backedge phi input as if it lived in a predecessor block and reading it via GetPredecessorValue, when for single-block loops that input is defined in the current block; the fix routes phi-input typing through GetTypeForPhiInput, which special-cases the in-block backedge input and reads its actual type with types_table_.Get.

Attack Path

  1. Craft an endless loop An attacker supplies a WebAssembly module whose function contains a single-block (endless) loop whose loop-header PhiOp has a backedge input referencing another phi defined in the same block.
  2. Trigger mis-typing During WasmGCTypeAnalyzer::ProcessPhi, the backedge input (input_index == 1) is resolved through GetPredecessorValue, yielding a stale/self value and an incorrect, over-broad inferred wasm::ValueType.
  3. Optimize away safety checks Later Turboshaft passes trust the bogus wasm-GC type and remove type checks or downcasts that the true type would have required.
  4. Induce type confusion At runtime the phi actually holds an object of an incompatible wasm-GC type, so the un-checked access operates on a differently-typed object, producing a type confusion.
  5. Corrupt memory The type-confused access reads or writes fields at offsets valid only for the assumed type, giving the attacker out-of-bounds or type-mismatched memory access inside the renderer.

Impact Assessment

An attacker who can run a malicious WebAssembly module in the renderer gains a type confusion in V8, letting them access a wasm-GC object as an incompatible type and thereby read or corrupt memory that should have been guarded by checks the optimizer wrongly removed. The context is the sandboxed renderer process, and the only precondition is executing attacker-controlled Wasm (e.g., a crafted web page), making it reachable purely from web content.

Changed Functions

FunctionChangeNotes
for
src/compiler/turboshaft/wasm-gc-typed-optimization-reducer.cc
modified
if
src/compiler/turboshaft/wasm-gc-typed-optimization-reducer.cc
modified

Files Changed

  • src/compiler/turboshaft/wasm-gc-typed-optimization-reducer.cc
  • src/compiler/turboshaft/wasm-gc-typed-optimization-reducer.h

Audit Directions

  • Predecessor-indexed lookups on SSA operands
    Review other uses of GetPredecessorValue or predecessor-snapshot reads that assume an operand is defined in a distinct predecessor block, and check they handle operands defined within the current block.
  • Single-block loop / self-predecessor edge cases
    Audit dataflow and type analyses for loop headers where current_block_->LastPredecessor() == current_block_, since the backedge input can alias an in-block phi and violate cross-block assumptions.
  • Optimizer type inference feeding check elimination
    Examine analyses whose inferred wasm::ValueType (or similar) is later used to drop casts or type checks, since any over-broad inference there can escalate into a type confusion.
From f231d83cb3c08754413b3ee1aa249cebd4d5445f Mon Sep 17 00:00:00 2001
From: Matthias Liedtke <[email protected]>
Date: Thu, 12 Dec 2024 14:11:00 +0100
Subject: [PATCH] [turboshaft][wasm] WasmGCTypeAnalyzer: Fix phi input for single-block loops

Fixed: 383356864
Change-Id: Idc644923c2e09e16b0c4c1cb1cda8f5c3d8189d9
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/6087921
Reviewed-by: Jakob Kummerow <[email protected]>
Reviewed-by: Nico Hartmann <[email protected]>
Commit-Queue: Matthias Liedtke <[email protected]>
Cr-Commit-Position: refs/heads/main@{#97723}
---

diff --git a/src/compiler/turboshaft/wasm-gc-typed-optimization-reducer.cc b/src/compiler/turboshaft/wasm-gc-typed-optimization-reducer.cc
index a051d9b..15a70a3 100644
--- a/src/compiler/turboshaft/wasm-gc-typed-optimization-reducer.cc
+++ b/src/compiler/turboshaft/wasm-gc-typed-optimization-reducer.cc
@@ -307,6 +307,28 @@
                       wasm::ValueType::Ref(type_index), allocate_struct);
 }
 
+wasm::ValueType WasmGCTypeAnalyzer::GetTypeForPhiInput(const PhiOp& phi,
+                                                       int input_index) {
+  OpIndex phi_id = graph_.Index(phi);
+  OpIndex input = ResolveAliases(phi.input(input_index));
+  // If the input of the phi is in the same block as the phi and appears
+  // before the phi, don't use the predecessor value.
+
+  if (current_block_->begin().id() <= input.id() && input.id() < phi_id.id()) {
+    // Phi instructions have to be at the beginning of the block, so this can
+    // only happen for inputs that are also phis. Furthermore, this is only
+    // possible in loop headers of loops with a single block (endless loops) and
+    // only for the backedge-input.
+    DCHECK(graph_.Get(input).Is<PhiOp>());
+    DCHECK(current_block_->IsLoop());
+    DCHECK(current_block_->HasBackedge(graph_));
+    DCHECK_EQ(current_block_->LastPredecessor(), current_block_);
+    DCHECK_EQ(input_index, 1);
+    return types_table_.Get(input);
+  }
+  return types_table_.GetPredecessorValue(input, input_index);
+}
+
 void WasmGCTypeAnalyzer::ProcessPhi(const PhiOp& phi) {
   // The result type of a phi is the union of all its input types.
   // If any of the inputs is the default value ValueType(), there isn't any type
@@ -320,12 +342,10 @@
                         phi);
     return;
   }
-  wasm::ValueType union_type =
-      types_table_.GetPredecessorValue(ResolveAliases(phi.input(0)), 0);
+  wasm::ValueType union_type = GetTypeForPhiInput(phi, 0);
   if (union_type == wasm::ValueType()) return;
   for (int i = 1; i < phi.input_count; ++i) {
-    wasm::ValueType input_type =
-        types_table_.GetPredecessorValue(ResolveAliases(phi.input(i)), i);
+    wasm::ValueType input_type = GetTypeForPhiInput(phi, i);
     if (input_type == wasm::ValueType()) return;
     // <bottom> types have to be skipped as an unreachable predecessor doesn't
     // change our type knowledge.
@@ -343,8 +363,7 @@
   if (v8_flags.trace_wasm_typer) {
     for (int i = 0; i < phi.input_count; ++i) {
       OpIndex input = phi.input(i);
-      OpIndex underlying = ResolveAliases(input);
-      wasm::ValueType type = types_table_.GetPredecessorValue(underlying, i);
+      wasm::ValueType type = GetTypeForPhiInput(phi, i);
       TRACE("- phi input %d: #%u(%s) -> %s\n", i, input.id(),
             OpcodeName(graph_.Get(input).opcode), type.name().c_str());
     }
diff --git a/src/compiler/turboshaft/wasm-gc-typed-optimization-reducer.h b/src/compiler/turboshaft/wasm-gc-typed-optimization-reducer.h
index f8bf6b7..048d508 100644
--- a/src/compiler/turboshaft/wasm-gc-typed-optimization-reducer.h
+++ b/src/compiler/turboshaft/wasm-gc-typed-optimization-reducer.h
@@ -86,6 +86,8 @@
   void ProcessPhi(const PhiOp& phi);
   void ProcessTypeAnnotation(const WasmTypeAnnotationOp& type_annotation);
 
+  wasm::ValueType GetTypeForPhiInput(const PhiOp& phi, int input_index);
+
   void CreateMergeSnapshot(const Block& block);
   bool CreateMergeSnapshot(base::Vector<const Snapshot> predecessors,
                            base::Vector<const bool> reachable);
Loading diff…

Original Bug Report

reported by [email protected]

WasmGCTypeAnalyzer improperly revisits single-block loops, leading to type confusion

WasmGCTypeAnalyzer is a Turboshaft analyzer responsible for inferring known type information for various operations, which can then be used by WasmGCTypedOptimizationReducer to potentially remove some type checks at compile time. To do this, it traverses the Turboshaft graph while keeping track of which types might be encountered at any particular point in the code. When encountering a loop, the analyzer keeps revisiting the loop body indefinitely until the type feedback stabilizes - this is intended to ensure that the loop backedge’s type feedback is properly accounted for. The analyzer revisits the loop’s body by calling iterator.MarkLoopForRevisitSkipHeader() - the loop header has already been revisited while trying to determine whether the type feedback stabilized, so it is skipped here, presumably as an optimization.

However, this assumption / optimization doesn’t account for single-block loops, which can occur in the Turboshaft graph when compiling infinite loops without any branches. For these loops, the loop header contains the entire loop body, effectively turning iterator.MarkLoopForRevisitSkipHeader() into a No-Op, and preventing the loop from being revisited any additional times. This cuts the fixed-point analysis short after two iterations: one initial visit, and one additional visit as part of the check to determine whether the type feedback stabilized. As such, WasmGCTypeAnalyzer might report incorrect type feedback to WasmGCTypedOptimizationReducer when the loop’s type information does not stabilize after two iterations (which can be the case because of, for example, a chain of Phis), which can lead to type checks being incorrectly removed, resulting in type confusion.

See attached two files:

  • poc.js: this contains a minimal POC which utilizes this bug to create and dereference a fake object, resulting in a crash. This POC has been reproduced with an D8 optdebug build (commit 86ffa19a533bdec3b9ce4a56106a2d9e8015e108) on an x86-64 Linux machine.
  • exploit.js: this contains a full Chromium exploit chain, utilizing this bug + unprotected PartitionAlloc metadata to demonstrate an attacker controlled write outside of the sandbox. This exploit has been tested and confirmed to work on Chromium 131.0.6778.108 on an x86-64 Linux machine, using --js-flags="--turboshaft-wasm" to enable Turboshaft for Wasm. Note that this class of sandbox escape (namely manipulating exposed SlotSpanMetadata objects) has been used in other exploit chains to achieve full RCE; this exploit stops short of achieving RCE, only demonstrating attacker controlled writes, since further exploitation requires hardcoding offsets for specific Chromium builds, which reduces the general reproducibility and reliability of the exploit.

A potential fix for this bug would be to not use MarkLoopForRevisitSkipHeader for revisiting single block loops, and instead use MarkLoopForRevisit for this particular edge case. A sample patch of such a fix could look as follows:

@@ -61,8 +61,14 @@ void WasmGCTypeAnalyzer::Run() {
         if (needs_revisit) {
           block_to_snapshot_[loop_header.index()] = MaybeSnapshot(snapshot);
           // This will push the successors of the loop header to the iterator
-          // stack, so the loop body will be visited in the next iteration.
-          iterator.MarkLoopForRevisitSkipHeader();
+          // stack, so the loop body will be visited in the next iteration. If
+          // this is a single-block loop, then there are no successors - as such
+          // revisit the entire loop (consisting of just the header) in this
+          // case.
+          if (block.index() != header.index()) {
+            iterator.MarkLoopForRevisitSkipHeader();
+          } else {
+            iterator.MarkLoopForRevisit();
+          }
         }
       }
     }

Reporter Credit: if applicable, please credit my pseudonym Popax21 in regards to this report.

View on issue tracker