CVE-2025-5959
WebAssembly Type Canonicalization Bug CanonicalEquality::EqualValueType()
Bug Type
WebAssembly’s type canonicalization function (CanonicalEquality::EqualValueType())
(collapsing structurally equivalent types into a single unique form) does not take into account nullability (references that can be null) for references that are indexed (references that point to a previously defined type by index, like ref $t1
).
This causes one fatal flaw, the engine can’t tell the difference between a nullable type like ref null $t1
and a non-nullable one like ref $t1
for indexed reference types inside a type definition.
This bug can be exploited by crafting a hash collision between two recgroups (recursive type groups in Wasm) that differ only in the nullability of their indexed reference types.
The hash attack used here is a Birthday Attack targeting MurmurHash64A
.
Commit a6e10f589ea63a7276be68c5a62bea2a48805f72
Bug Location
From v8/src/wasm/canonical-types.h
:
bool EqualValueType(CanonicalValueType type1,
CanonicalValueType type2) const {
const bool indexed = type1.has_index();
if (indexed != type2.has_index()) return false;
if (indexed) {
return EqualTypeIndex(type1.ref_index(), type2.ref_index());
// all this does is check the type's index equivalanece
}
return type1 == type2;
}
When we do the if(indexed)
check, we check is that type1
and type2
are the same type but not checking if one reference value can be null
or both reference values can be null
.
We have a CanonicalEquality
bug which is shadowed behind finding a hash collision for CanonicalHashing
due to the use of std::unordered_set<CanonicalGroup>
for TypeCanonicalizer::canonical_group_
.
This means that if we find two CanonicalGroups
who differ only based on the nullability (if one is ref null
and one is ref
) of its indexed reference values types while still being able to hash into the same hash value. This turns into a issue where two recgroup canonicalizes into the same canonical index which causes nullability confusion.
Exploit Step 1: Hash Collision + Primative
For us to actually take advantage of this nullability issue we first need to create a hash collision.
Take this recgroup structure and hash it.
- t0: array { mut kWasmI32 }
- t1: struct { mut kWasmI64 * 8191, mut kWasmI32, mut ref null $t0 }
- t2: struct { mut {kWasmI32, kWasmI64} * BASE_RND_FIELDS, mut ref null? $t1 * BRUTE_FIELDS }
Our goal from this recgroup structure is to attack t1
’s type identity by trying to find a t2
that collides with it in our hash space. We are essentially brute forcing many ref $t1
fields in t2
to find a collision.
mut {kWasmI32, kWasmI64} * BASE_RND_FIELDS
is always fixed for our collision attempt.
As for the birthday attack we need to compute 2^BRUTE_FIELDS
of all nullabilities in mut ref null? $t1 * BRUTE_FIELDS
.
Once we commence the birthday attack, that if a value of BRUTE_FIELDS
of around 32. We will have 2^32 64-bit hash values that are extremely likely to have a hash collision.
We can take this hash collision to confuse ref null $t1
into it being treated as ref $t1
.
Exploit Step 2: WasmNull to Sandbox Primatives
So now that we have the ability to cast ref null $t1
into ref $t1
from the previous hash collision attack. We essentially have now removed null checks on any operations following, since the EqualValueType
function views both ref $t1
and ref null $t1
equivalent because the indicies are equal.
We can exploit this by convering a null value into a non-null type.
For example, borrowing from CVE-2023-4068
we can specify a WasmNull
.
We know that WasmNull
is always at a fixed address of 0xfffd
, following by 0x10000
guard page region that handles/traps null via segfault handling.
Our ref null $t1
points past the guard page at 0x20000
, treating our ref as a valid memory region.
The ref is then interpetered as a WasmArray
object which all contain mutable i32
elements. Since our array length given is very large 0x0c000112
, we get a large chunk of memory in the sandbox.
Since we can r/w
in this array now because the array is trusted we can construct a arb r/w
primative to do anything constrained to the array length/sandbox boundries.
Exploit Step 3: V8 Sandbox Bypass
At this point, we have a fakeObj
primitive and arbitrary memory read/write. However, we’re still confined to the WebAssembly sandbox, meaning our read/write is caged. To escape the sandbox, we exploit a bug in JSPI (JavaScript Promise Integration).
JSPI is a WebAssembly feature that allows Wasm code to call JavaScript functions that return Promises, and pause execution until those Promises resolve. Internally, this requires V8 to suspend and resume WebAssembly execution by switching between stacks, tracking async state, and maintaining a call chain.
We want to exploit JSPI stack switching behavior to switch to a invalid stack full of our user controlled values.
JSPI Stack Switching
When entering a WebAssembly.promising
function, V8:
- Allocates a new secondary stack
- Switches execution from the main JS stack to the secondary one
- Returns to JS by switching back to the main stack
If the Wasm function calls an imported JS function that enters another promising function, the process repeats, creating nested stacks:
- JS →
f1
→ secondary stackS0
f1
callsimp1
(JS), which entersf2
→ secondary stackS1
On return, the stacks must be unwound in the correct order:
S1
→ central stack →S0
→ central stack → back to JS
JSPI Stack Functionality
To track stack state, V8 uses four stack states:
Active
,Suspended
,Inactive
, andRetired
The vulnerability lies in a flaw that allowed returning to an earlier inactive stack while skipping a suspended one. For example:
- Create a chain
S0 → S1 → S2
- Skip
S1
and return directly fromS2
toS0
- While
S1
is suspended, overwrite its stack contents - Later return to
S1
with attacker-controlled data
This gives full control over the execution stack.
Exploit Flow
- Create nested promising functions (
f1
,f2
, etc.) to build a chain of secondary stacks. - Skip over an intermediate stack (e.g.
S1
) during return. - Overwrite the suspended stack (
S1
) using Wasm stack spraying (i64
locals). - Return into the clobbered stack (
S1
) to execute attacker-controlled values. - Place a retsled + ROP chain to:
- Call
VirtualProtect
to make memory executable - Jump to shellcode
- Call
- Leak addresses using PartitionAlloc metadata, which works regardless of ShadowMetadata.
Basic Flow of Bug + Exploit
Bug in
CanonicalEquality::EqualValueType()
ignores nullability for indexed reference types. (It does not check if ref can be null)Leads to hash collisions in
TypeCanonicalizer::canonical_group_.
(Allows us to treat two hashable recgroups the same even if one is ref null)Exploitable via a birthday attack on
MurmurHash64A
. (Precompute + Small Hashing 64-bit)Nullability confusion lets us turn
ref null $t1
intoref $t1
. (We don’t check ifref null $t1
isnull
and thus can cast it intoref $t1
)That gets us a primitive: treating null as a valid object → type confusion → fake object → memory R/W. (Similar to CVE-2023-4068 where we treat JSNull as WasmNull)