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 stackS0f1callsimp1(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
S1and return directly fromS2toS0 - While
S1is suspended, overwrite its stack contents - Later return to
S1with 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 (i64locals). - Return into the clobbered stack (
S1) to execute attacker-controlled values. - Place a retsled + ROP chain to:
- Call
VirtualProtectto 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 $t1intoref $t1. (We don’t check ifref null $t1isnulland 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)