CVE-2025-13228
Overview
Files Changed
src/compiler/turboshaft/wasm-gc-typed-optimization-reducer.ccsrc/compiler/turboshaft/wasm-gc-typed-optimization-reducer.hsrc/wasm/canonical-types.ccsrc/wasm/canonical-types.hsrc/wasm/constant-expression-interface.ccsrc/wasm/function-body-decoder-impl.h
Patch
From 48c3551179299151b044b2c161566465497c0407 Mon Sep 17 00:00:00 2001
From: Jakob Kummerow <jkummerow@chromium.org>
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 <jkummerow@chromium.org>
Reviewed-by: Matthias Liedtke <mliedtke@chromium.org>
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
Regression Test / PoC
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
Original Bug Report
Wasm type confusion due to custom descriptors spec ambiguity in `ref.get_desc` exactness typing
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 spec unsoundness around ref.get_desc exactness typing. Spec describes two different rules for ref.get_desc:
- (Rule #1) Rule in the codeblock indicates that
ref.get_descmust type the result as exact only when the stack type is a subtype of the exact encoded type (thus only allowing the exact encoded type). This rule is sound. - (Rule #2) Rule written in plain English indicates that “If the provided reference is to an exact heap type, then the type of the custom descriptor is known precisely, so the result can be exact as well”. In other words, the provided reference’s exactness directly propagates to the result type. However, the result type in this case MUST be typed as the provided reference’s descriptor type, not the encoded immediate type’s descriptor type. This rule is also sound (if done right) but is different than the first rule.
- However, mixing the two up yields an unsound rule, e.g. by propagating the provided reference’s exactness to the result type (Rule #2), but taking the base result type from the encoded immediate type’s descriptor (Rule #1). The spec is unsound in that it describes two completely different rules as if it is the same
ref.get_desc.
V8 also gets confused and implements the unsound rule, transferring the exactness of the stack type to the result type while using the encoded immediate type’s descriptor. This allows typing the resulting descriptor as an exact supertype, leading to type confusion.
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.
Details
Spec indicates that ref.get_desc must type the result as exact only when the stack type is a subtype of the exact sourcetype:
ref.get_desc typeidx
C |- ref.get_desc x : (ref null (exact_1 x)) -> (ref (exact_1 y))
-- C.types[x] ~ descriptor y ct
For example, take x' <: x with C.types[x'] ~ descriptor y' ct' where C |- ¬(C.types[x'] ~ C.types[x]) - that is, x' is a strict subtype of x. Due to subtyping rules on descriptors, y' <: y holds. If ref exact x' is passed to ref.get_desc x:
Stack: ref exact x'
Match: ref exact x' <: ref null x => exact_1 = inexact
=> ref.get_desc x (ref exact x') -> (ref y)
Since ¬(ref exact x' <: ref null exact x), exact_1 = inexact is the only suitable rule and the resulting descriptor type must be inexact.
However, the spec also describes this rule in plain English but with different typing rules:
> If the provided reference is to an exact heap type, then the type of the custom descriptor is known precisely, so the result can be exact as well. Otherwise, the subtyping rules described above ensure that there will be some custom descriptor value and that it will be a subtype of the custom descriptor type for x, so the result can be a non-null reference to the inexact descriptor type.
This may be interpreted as a weakened, unsound rule that translates to the below rule:
ref.get_desc? typeidx
C |- ref.get_desc? x : (ref null (exact_1 x')) -> (ref (exact_1 y))
-- C.types[x] ~ descriptor y ct
-- C.types[x'] ~ descriptor y' ct'
-- C |- x' <: x // [!] (modified) subtype check
Note that we’ve subtly weakened the rule on the input type, effectively passing on the exactness of whatever was on the stack instead of typechecking stacktype <: ref null exact x. We also derive y from the encoded type x, not the provided type on the stack. This yields the results below:
Stack: ref exact x'
Match: (ref exact x' <: ref null exact x') ∧ (x' <: x) => exact_1 = exact
=> ref.get_desc? x (ref exact x') -> (ref exact y)
V8’s implementation takes the weakened, unsound rule:
// https://source.chromium.org/chromium/chromium/src/+/main:v8/src/wasm/function-body-decoder-impl.h;drc=43df3221d3b0c1ea8f7d54394a00cb8656204085;l=5994
case kExprRefGetDesc: {
// ...
StructIndexImmediate imm(this, this->pc_ + opcode_length, validate);
if (!this->Validate(this->pc_ + opcode_length, imm)) return 0;
const TypeDefinition& type = this->module_->type(imm.index);
if (!VALIDATE(type.has_descriptor())) {
// ...
return 0;
}
Value ref = Pop(ValueType::RefNull(imm.heap_type())); // [!] (modified) subtype check
Value* desc =
Push(ValueType::Ref(this->module_->heap_type(type.descriptor))
.AsExact(ref.type.exactness())); // [!] exactness directly from stack type (x')
CALL_INTERFACE_IF_OK_AND_REACHABLE(RefGetDesc, ref, desc);
return opcode_length + imm.length;
}
Now we have ref exact y' typed as ref exact y where y' <: y. This pokes a hole in the type system exactly as described in the proposal docs, allowing attackers to invalidly type a descriptor y' into an exact supertype y, instantiate struct x, then invalidly cast down to x'. This leads to type confusion between arbitrary Wasm types.
This seems to be a very common misinterpretation of the spec. Development work on Binaryen also shows this misunderstanding: "ref.get_desc inherits its input ref’s exactness…" which is only true when the reference type’s descriptor is used as the return type (which from a cursory glance may be what Binaryen’s doing). This leads me to wonder if the spec was initially designed to represent something like Rule #1, but happened to be written down in a different thing described as Rule #2, and then things got messy? Slides from CG meeting for Phase 2 advancement of the proposal also mix up the two, stating the formal rule as Rule #1 but uses the phrase “propagates exactness of references” which applies on Rule #2.
Bisect
Bug introduced by WebAssembly Custom Descriptors, on Origin Trials from M141 and onwards. More specifically, it is introduced in commit 2f4c043d which implements ref.get_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
- https://crrev.com/c/6401033
- https://docs.google.com/presentation/d/1HtHw4WNEZ4DAt6ythWzhDiPHBc8_-GHT5qKAMlxfcYc/edit?slide=id.g35846d341e4_0_429#slide=id.g35846d341e4_0_429
- https://github.com/WebAssembly/binaryen/pull/7886
- https://github.com/WebAssembly/custom-descriptors/blob/main/proposals/custom-descriptors/Overview.md#exact-types
- https://github.com/WebAssembly/custom-descriptors/blob/main/proposals/custom-descriptors/Overview.md#new-instructions
- https://github.com/WebAssembly/custom-descriptors/issues
- https://source.chromium.org/chromium/chromium/src/+/main:v8/src/wasm/function-body-decoder-impl.h;drc=43df3221d3b0c1ea8f7d54394a00cb8656204085;l=5994