High chrome Type Confusion 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactType Confusion in V8
DescriptionType Confusion in V8
ComponentV8
Bug ClassType Confusion
Tracker446113732
Fix commit48c355117929 (v8/v8) +79/-63
CISA KEVNot listed
CreditedSeunghyun Lee (@0x10n)
Disclosed2025-10-28

Files Changed

  • src/compiler/turboshaft/wasm-gc-typed-optimization-reducer.cc
  • src/compiler/turboshaft/wasm-gc-typed-optimization-reducer.h
  • src/wasm/canonical-types.cc
  • src/wasm/canonical-types.h
  • src/wasm/constant-expression-interface.cc
  • src/wasm/function-body-decoder-impl.h
From 48c3551179299151b044b2c161566465497c0407 Mon Sep 17 00:00:00 2001
From: Jakob Kummerow <[email protected]>
Date: Tue, 23 Sep 2025 15:39:41 +0200
Subject: [PATCH] [wasm-custom-desc] Fix subtyping

This updates the restrictions on subtyping of descriptors, in
anticipation of upcoming spec changes:
- descriptors and described types must have matching subtyping
- ref.func for imported functions returns inexact types (for
  now; long-term solution TBD)

And it fixes our implementation:
- `ProcessBranchOnTarget` erroneously still thought it could
  compute reachability based on static types when Custom
  Descriptors are in play
- `JSToWasmObject` was missing support for exact types
- `ref.get_desc` must not return an exact type when the actual
  type on the stack is exact, but a non-trivial subtype of the
  instruction's type immediate.

Bug: 403372470
Fixed: 446113731, 446113732, 446122633, 446124892, 446124893
Change-Id: Ic79ab08d906a2e21e66b76e9d96eebb4ebb7a8e5
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/6973586
Commit-Queue: Jakob Kummerow <[email protected]>
Reviewed-by: Matthias Liedtke <[email protected]>
Cr-Commit-Position: refs/heads/main@{#102697}
---

diff --git a/src/compiler/turboshaft/wasm-gc-typed-optimization-reducer.cc b/src/compiler/turboshaft/wasm-gc-typed-optimization-reducer.cc
index c8d6693..f6905e6 100644
--- a/src/compiler/turboshaft/wasm-gc-typed-optimization-reducer.cc
+++ b/src/compiler/turboshaft/wasm-gc-typed-optimization-reducer.cc
@@ -445,7 +445,10 @@
       } else {
         DCHECK_EQ(branch.if_false, &target);
         if (wasm::IsSubtypeOf(GetResolvedType(check.object()), check.config.to,
-                              module_)) {
+                              module_) &&
+            // When checking for a particular custom descriptor, static types
+            // cannot predict the outcome.
+            !(IsCastToCustomDescriptor(module_, check.config))) {
           // The type check always succeeds, the target is impossible to be
           // reached.
           DCHECK_EQ(target.PredecessorCount(), 1);
diff --git a/src/compiler/turboshaft/wasm-gc-typed-optimization-reducer.h b/src/compiler/turboshaft/wasm-gc-typed-optimization-reducer.h
index a571fe4..2ba8fbe 100644
--- a/src/compiler/turboshaft/wasm-gc-typed-optimization-reducer.h
+++ b/src/compiler/turboshaft/wasm-gc-typed-optimization-reducer.h
@@ -18,6 +18,13 @@
 
 namespace v8::internal::compiler::turboshaft {
 
+inline bool IsCastToCustomDescriptor(const wasm::WasmModule* module,
+                                     WasmTypeCheckConfig config) {
+  return config.to.has_index() &&
+         module->type(config.to.ref_index()).has_descriptor() &&
+         config.exactness == compiler::kExactMatchOnly;
+}
+
 // The WasmGCTypedOptimizationReducer infers type information based on the input
 // graph and reduces type checks and casts based on that information.
 //
@@ -179,7 +186,7 @@
       bool to_nullable = cast_op.config.to.is_nullable();
       if (wasm::IsHeapSubtypeOf(type.heap_type(), cast_op.config.to.heap_type(),
                                 module_) &&
-          !IsCastToCustomDescriptor(cast_op.config)) {
+          !IsCastToCustomDescriptor(module_, cast_op.config)) {
         if (to_nullable || type.is_non_nullable()) {
           // The inferred type is already as specific as the cast target, the
           // cast is guaranteed to always succeed and can therefore be removed.
@@ -247,7 +254,7 @@
                                 type_check.config.to.heap_type(), module_) &&
           // When checking for a particular custom descriptor, static types
           // cannot guarantee success.
-          !(IsCastToCustomDescriptor(type_check.config))) {
+          !(IsCastToCustomDescriptor(module_, type_check.config))) {
         if (to_nullable || type.is_non_nullable()) {
           // The inferred type is guaranteed to be a subtype of the checked
           // type.
@@ -435,12 +442,6 @@
   }
 
  private:
-  bool IsCastToCustomDescriptor(WasmTypeCheckConfig config) {
-    return config.to.has_index() &&
-           module_->type(config.to.ref_index()).has_descriptor() &&
-           config.exactness == compiler::kExactMatchOnly;
-  }
-
   Graph& graph_ = __ modifiable_input_graph();
   const wasm::WasmModule* module_ = __ data() -> wasm_module();
   WasmGCTypeAnalyzer analyzer_{__ data(), graph_, __ phase_zone()};
diff --git a/src/wasm/canonical-types.cc b/src/wasm/canonical-types.cc
index 359ca5e..50120e0 100644
--- a/src/wasm/canonical-types.cc
+++ b/src/wasm/canonical-types.cc
@@ -243,15 +243,19 @@
 }
 
 bool TypeCanonicalizer::IsCanonicalSubtype(CanonicalTypeIndex sub_index,
-                                           CanonicalTypeIndex super_index) {
+                                           CanonicalValueType super_type) {
+  DCHECK(super_type.has_index());
   // Fast path without synchronization:
-  if (sub_index == super_index) return true;
+  if (sub_index == super_type.ref_index()) return true;
+  // If the supertype is exact, then only the equality case above is
+  // successful.
+  if (super_type.is_exact()) return false;
 
   // Multiple threads could try to register and access recursive groups
   // concurrently.
   // TODO(manoskouk): Investigate if we can improve this synchronization.
   base::MutexGuard mutex_guard(&mutex_);
-  return IsCanonicalSubtype_Locked(sub_index, super_index);
+  return IsCanonicalSubtype_Locked(sub_index, super_type.ref_index());
 }
 bool TypeCanonicalizer::IsCanonicalSubtype_Locked(
     CanonicalTypeIndex sub_index, CanonicalTypeIndex super_index) const {
@@ -266,16 +270,6 @@
   return false;
 }
 
-bool TypeCanonicalizer::IsCanonicalSubtype(ModuleTypeIndex sub_index,
-                                           ModuleTypeIndex super_index,
-                                           const WasmModule* sub_module,
-                                           const WasmModule* super_module) {
-  CanonicalTypeIndex canonical_super =
-      super_module->canonical_type_id(super_index);
-  CanonicalTypeIndex canonical_sub = sub_module->canonical_type_id(sub_index);
-  return IsCanonicalSubtype(canonical_sub, canonical_super);
-}
-
 bool TypeCanonicalizer::IsHeapSubtype(CanonicalTypeIndex sub,
                                       CanonicalTypeIndex super) const {
   DCHECK_NE(sub, super);
diff --git a/src/wasm/canonical-types.h b/src/wasm/canonical-types.h
index b992652..5301224 100644
--- a/src/wasm/canonical-types.h
+++ b/src/wasm/canonical-types.h
@@ -85,17 +85,12 @@
   V8_EXPORT_PRIVATE const CanonicalArrayType* LookupArray(
       CanonicalTypeIndex index) const;
 
-  // Returns if {canonical_sub_index} is a canonical subtype of
-  // {canonical_super_index}.
+  // Returns if {sub_index} is a canonical subtype of {super_type}, which must
+  // be an indexed type. Interprets {sub_index} as (exact sub_index), which is
+  // appropriate for checking the actual type of a thing against a required
+  // type.
   V8_EXPORT_PRIVATE bool IsCanonicalSubtype(CanonicalTypeIndex sub_index,
-                                            CanonicalTypeIndex super_index);
-
-  // Returns if the type at {sub_index} in {sub_module} is a subtype of the
-  // type at {super_index} in {super_module} after canonicalization.
-  V8_EXPORT_PRIVATE bool IsCanonicalSubtype(ModuleTypeIndex sub_index,
-                                            ModuleTypeIndex super_index,
-                                            const WasmModule* sub_module,
-                                            const WasmModule* super_module);
+                                            CanonicalValueType super_type);
 
   // Deletes recursive groups. Used by fuzzers to avoid accumulating memory, and
   // used by specific tests e.g. for serialization / deserialization.
diff --git a/src/wasm/constant-expression-interface.cc b/src/wasm/constant-expression-interface.cc
index 0bd4c138..757b4a1 100644
--- a/src/wasm/constant-expression-interface.cc
+++ b/src/wasm/constant-expression-interface.cc
@@ -124,8 +124,13 @@
   bool function_is_shared = module_->type(sig_index).is_shared;
   CanonicalValueType type =
       CanonicalValueType::Ref(module_->canonical_type_id(sig_index),
-                              function_is_shared, RefTypeKind::kFunction)
-          .AsExactIfEnabled(decoder->enabled_);
+                              function_is_shared, RefTypeKind::kFunction);
+  // Imported functions can be subtypes of their static import type,
+  // for non-imported functions we can return an exact type.
+  if (decoder->enabled_.has_custom_descriptors() &&
+      function_index >= module_->num_imported_functions) {
+    type = type.AsExact();
+  }
   DirectHandle<WasmFuncRef> func_ref =
       WasmTrustedInstanceData::GetOrCreateFuncRef(
           isolate_,
diff --git a/src/wasm/function-body-decoder-impl.h b/src/wasm/function-body-decoder-impl.h
index 6aa071e..5a6a2ec 100644
--- a/src/wasm/function-body-decoder-impl.h
+++ b/src/wasm/function-body-decoder-impl.h
@@ -4243,9 +4243,17 @@
     if (!this->ValidateFunction(this->pc_ + 1, imm)) return 0;
     ModuleTypeIndex index = this->module_->functions[imm.index].sig_index;
     const TypeDefinition& type_def = this->module_->type(index);
-    Value* value =
-        Push(ValueType::Ref(index, type_def.is_shared, RefTypeKind::kFunction)
-                 .AsExactIfEnabled(this->enabled_));
+    ValueType result_type =
+        ValueType::Ref(index, type_def.is_shared, RefTypeKind::kFunction);
+    // For imported functions, we must return an inexact type, because
+    // importing checks subtyping, i.e. for function types f1 <: f2, it is
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/test/mjsunit/wasm/custom-descriptors-validity.js b/test/mjsunit/wasm/custom-descriptors-validity.js
index 9b3118f2..204d0a5 100644
--- a/test/mjsunit/wasm/custom-descriptors-validity.js
+++ b/test/mjsunit/wasm/custom-descriptors-validity.js
@@ -119,7 +119,7 @@
   builder.addStruct({describes: 2, supertype: 1});   // 3
 });
 
-CheckValid((builder) => {
+CheckInvalid(/type 4 has invalid explicit supertype 2/, (builder) => {
   builder.addStruct({final: false});  // 0
 }, (builder) => {
   builder.addStruct({descriptor: 2});  // 1
Loading diff…

Original Bug Report

reported by [email protected]

Wasm type confusion due to spec unsoundness in `cast_desc` operations

…and a nail in the coffin for the custom descriptors proposal.

README

This is a Wasm spec-level unsoundness issue. Any runtime implementing the same spec faithfully will have the same bug. I am reporting this privately to Google Chrome/V8 as Google seems to be leading the spec work and implementation of custom descriptors the first and practically has the most affected users in the wild.

As this is a spec-level unsoundness which the Wasm community needs to know, I believe that the issue necessitates a quicker disclosure deadline than what is enforced by default by Chrome’s disclosure policies. It would be great if we can coordinate the disclosure within a week from the reported date - please contact me through email or through the comments. Unless otherwise coordinated, the spec unsoundness issue is subject to a 14-day disclosure deadline after which the issue, without any specific mentions of Chrome/V8, will be disclosed publicly on https://github.com/WebAssembly/custom-descriptors/issues. The Chrome/V8 security team is allowed to responsibly disclose this to other vendors and personnel involved in WebAssembly work (at CG meetings or whatnot) under the conditions that 1. the reporter is credited, and 2. is given at least a day’s advance notice.

VULNERABILITY DETAILS

Summary

Wasm type confusion due to fundamental spec unsoundness in custom descriptors. Described structs may be unrelated but have descriptors in subtyping relationship, where the descriptors’ subtype relationship may be used to unsoundly cast between the described structs using ref.cast_desc or br_on_cast_desc*.

Custom descriptors feature is exposed in the wild by default through Origin Trials from M141, which is currently at Beta and very soon reaches (Early) Stable. This bug is not caused by a recent code change and has existed from the very first feature implementation (approx. 6 months) due to an inherent spec unsoundness.

> Note: Since the custom descriptors spec itself is unsound, discussing about other implementation bugs may be pointless. However, each and every one of the bugs that I’ve reported are independent and triggers even without cast_desc.

Details

WebAssembly Custom Descriptors proposal introduces descriptors and descriptor-based type checks. These casts/checks require that the described struct has a descriptor that matches the given descriptor at runtime. By spec definition, these operations allow subtype descriptors. Take for example ref.cast_desc:

ref.cast_desc reftype

C |- ref.cast_desc rt : (ref null ht) (ref null (exact_1 y)) -> rt
-- rt = (ref null? (exact_1 x))
-- C |- C.types[x] <: ht
-- C.types[x] ~ descriptor y ct

In the above rule:

  • Descriptor on the stack is allowed to be any subtype of (ref null (exact_1 y)), the descriptor type of rt.
  • ht may also be chosen as any, allowing any references within the anyref hierarchy to be casted through this operation even without a direct subtype relationship between rt, the described type, given that the descriptor actually matches.

Let us revisit subtyping rules on custom descriptors:

  • A declared supertype of a type with a (descriptor $x) clause must either not have a descriptor clause or have a (descriptor $y) clause, where $y is a declared supertype of $x.
  • A declared supertype of a type without a descriptor clause must also not have a descriptor clause.
  • A declared supertype of a type with a describes clause must have a describes clause.
  • A declared supertype of a type without a describes clause must also not have a describes clause.
  • With shared-everything-threads, … (omitted)

…additionally with the following type validation rule:

  • Descriptor and describes clauses must agree, i.e. a describing type must have a describes clause referring to its described type, which in turn must have a descriptor clause referring to the describing type.

Note how describes clause are not subject to subtyping. Thus, a type definition as shown below is perfectly legal:

(rec
  (type $sup (sub (descriptor $sup.rtt (struct (field $sup-only i32)))))
  (type $sup.rtt (sub (describes $sup (struct))))

  (type $sub (sub (descriptor $sub.rtt (struct))))
  (type $sub.rtt (sub $sup.rtt (describes $sub (struct))))
)

We do not declare $sub <: $sup nor enforce it transitively through $sub.rtt <: $sup.rtt$sub and $sup are unrelated. The spec also explicitly calls out such cases as legal, although with a broken example (type $other in the spec overview which lacks a matching descriptor). A non-broken example is in Issue #29.

Now consider a case where we execute the following:

(func $unsound (result i32)
  (local $rtt (ref $sub.rtt))
  ;; Instantiate $sub.rtt => $rtt
  (local.set $rtt (struct.new $sub.rtt))
  ;; Instantiate $sub with $rtt
  (struct.new $sub (local.get $rtt))
  ;; Get $rtt again as the descriptor to compare on cast
  (local.get $rtt)
  ;; Cast $sub to $sup using $rtt. This will succeed because $sub.rtt <: $sup.rtt
  (ref.cast_desc (ref $sup))
  ;; Out-of-bounds read.
  (struct.get $sup $sup-only)
)

All of the steps are perfectly legal based on the current custom descriptor spec, but is obviously unsound. Essentially, ref.cast_desc as well as any br_on_cast_desc* variants transitively applies subtyping relationship of the descriptors to its described struct type. This trivially results in type confusion between unrelated described struct types.


I am unsure what the spec intends to do here as the formulation is fundamentally broken. Maybe the spec intended to enforce a describes declared subtype check in the modified subtyping rules – but an example in the spec overview explicitly calls out against this idea. Maybe it needs a stricter typecheck against the value on the stack by only allowing subtypes of rt (in this case, checking if (struct.new $sub (local.get $rtt)) is a subtyped object of ref $sup) – but the spec overview explicitly relaxes this in issue #37, and even before this ref.cast_desc was broken from the start. Either might be a valid fix but with wildly different semantics which I am uncertain what is even intended here.

But another concern is: Are there no formally verified version of this proposal, either through SpecTec or by manual work? What is the spec trying to model with custom descriptors, and what is V8 currently implementing? We got away in b/365802567 by saying that it’s just a terminology confict of the term “match” across different proposal spec evolved independently, but in this case the spec is just flat out unsound. I can understand implementations and minor spec revisions being a trial-and-error process, but almost the entirety of the spec being unsound while going all the way through deployment into stable channel (albeit OT) is honestly a bit concerning. Or am I missing something?

Bisect

Bug introduced by WebAssembly Custom Descriptors, on Origin Trials from M141 and onwards. More specifically, it is introduced in commit 7cb98588 which implements ref.cast_desc.

VERSION

Chrome Version: M141~
Operating System: All

REPRODUCTION CASE

Attached as poc.js which exploits this issue to alias two unrelated struct types, then uses this type confusion to trigger an arbitrary caged write within the sandbox.

Also attached is rce.html which exploits this issue, together with the wrapper-wasmcpt-uaf v8sbx bypass, to gain RCE and print out /flag/flag to stdout.

You might want to pass --experimental-wasm-custom-descriptors on d8 or --enable-blink-features=WebAssemblyCustomDescriptors on Chrome to simulate Origin Trials behavior.

FOR CRASHES, PLEASE INCLUDE THE FOLLOWING ADDITIONAL INFORMATION

Type of crash: Renderer
Crash State: Crashes on arbitrary caged write attempt from JIT-compiled Wasm function with poc.js / RCE with rce.html

CREDIT INFORMATION

Reporter credit: Seunghyun Lee (@0x10n) of CMU CSD / CyLab

View on issue tracker