CVE-2025-5959

Patch
1 | diff --git a/src/wasm/canonical-types.h b/src/wasm/canonical-types.h |
Before the patch, the comparison logic in CanonicalType::Equals()
allowed two reference types like (ref T)
and (ref null T)
to be treated as equal if they shared the same index, regardless of their nullability or other distinguishing bits. This was because it only compared their ref_index()
values:
1 | if (indexed) { |
However, this behavior is unsafe. While two types may share the same canonical index, their bitfield flags (e.g., nullability) still matter. Treating them as interchangeable can lead to type confusion, particularly when a non-nullable reference is accessed assuming it can’t be null
, while it’s actually coming from a (ref null T)
context.
The Bitfield
The bit_field_
is a bit-packed field that encodes various properties of a WebAssembly value type. This is defined in src/wasm/value-type.h
.
1 | namespace value_type_impl { |
So the non-index bits on bit_field_ are this:
Field | Bits | Purpose |
---|---|---|
TypeKind |
0–1 | e.g., ref, primitive |
IsNullable |
2 | nullable ref? |
IsExact |
3 | exact subtype check (used internally) |
IsShared |
4 | shared array |
RefTypeKind |
5–7 | struct/array/func/extern etc. |
To understand how this vulnerability works, we need to see how EqualValueType
is reached and why it matters.
We can trigger the vulnerable logic by simply creating two semantically identical WebAssembly types in JavaScript — in this case, two function types that both take a ref null struct
argument. Even though they appear identical, their internal representation in V8 may differ slightly at the bit level.
1 | //Add struct types |
Using a gdb, we can inspect how V8 encodes the types internally. Let’s compare WasmRefType
and WasmRefNullType
bit representations after we set breakpoint on EqualValueType
:
1 | WasmRefType |
1 | WasmRefNullType |
Now let’s analyze the bits different
1 | bin(0x343) = 0b1101000011 //WasmRefType with index (0x343 >> 8) = 0x3 |
Can see the difference is only 1 bit? yes the different is only on bit index 2. Here’s how bit_field_
is structured in V8 (bits 0–7 only):
Field | Bits | Result |
---|---|---|
TypeKind |
0–1 | 1, 1 |
IsNullable |
2 | 0 —> means not nullable |
IsExact |
3 | 0 |
IsShared |
4 | 0 |
RefTypeKind |
5–7 | 0, 1, 0 —> means struct |
So, Our goal is to ensure that when the EqualValueType
function is called, type1.bit_field_ = 0x343
and type2.bit_field_ = 0x347
(or vice versa).
Path of EqualValueType
Let’s explore what happens when we create two different structs: one using a non-nullable reference and the other using a nullable reference to the same type.
1 | const $T = builder.addStruct([ |
Unfortunately, these two structs should be treated distinct. That’s because they differ in their non-index bits (specifically, the IsNullable
bit). Consequently, they produce different hash values and go down separate paths during canonicalization.
So, does this trigger EqualValueType
? Sadly, No
Let’s look at the actual path that leads to EqualValueType
:
1 | DecodeTypeSection |
As shown above, EqualValueType
is only reached after a hash collision, inside the equality check of canonical types. Since $structA
and $structB
have different hashes, they’ll never reach the EqualValueType
check.
To reach EqualValueType
, we must bypass the hash rejection step i.e, we need to forge a hash collision between two semantically distinct but structurally similar types.
This is exactly what was described in a past vulnerability report by Xion:
“…we must find two different recursion groups that are considered equal by CanonicalEquality, but which at the same time also have a hash collision when hashed via CanonicalHashing…”
The core idea is:
- V8 uses
std::unordered_set<CanonicalGroup>
to store previously seen types. - The set relies on
CanonicalHashing
for fast lookups. CanonicalHashing
is based on MurmurHash64A, which outputs 64-bit values.- A Birthday Attack on a 64-bit hash requires ~2³² samples for a 50% chance of collision which is computationally feasible.
So, We can generate hash collisions by preparing many structurally “equal” types (according to CanonicalEquality
) that differ only in the order or pattern of ref
/ref null
fields.
Example patterns:
1 | addStruct([makeField(ref, true), makeField(refNull, true), ...]) //1 |
By repeating and permuting these patterns across >32 fields, and computing their hashes offline, you can:
- Generate 2³²+ unique structs
- Filter for those that collide under
CanonicalHashing
- Canonicalize two distinct struct types to the same index, thus bypassing the type system
This results in arbitrary Wasm type confusion, because a value of one type may be misinterpreted as another.
I won’t explain how the Birthday Attack works in depth here, you can easily ask an AI assistant to help you walk through it.
The Hash Collison:
1 | 1 1 1 0 0 0 1 0 0 0 1 1 0 1 1 1 1 1 1 1 1 0 1 1 0 1 0 1 1 1 0 1 1 1 1 1 1 0 0 1 1 0 0 0 1 0 1 1 0 1 0 1 1 1 1 0 1 1 0 1 0 0 0 1 0 |
After we got the Hash Collision we can create the structA & structB using that pattern in result as you can see image below we got the type1.bit_field_ & type2.bit_field_ are different when EqualValueType called.
PoC Crash
Now that we’ve crafted a hash collision between two structurally different types (structA
and structB
), let’s recap the key difference between the two types:
1 | wasmRefType --> Can only stored object and can't be null (obj) |
We’ve found two types structA
and structB
that:
- Have the same canonical type index (due to hash collision)
- But differ semantically:
structA[0]
is of type(ref null T)
whilestructB[0]
is of type(ref T)
Then we can construct a WebAssembly function like so:
1 |
|
What Happens Here?
structA[0]
is allowed to benull
, and we storenull
into it.- Later, we reinterpret the same object as
structB
, which expects a non-nullable reference at field 0. - So when V8 tries to access that null pointer, it results in a SIGSEGV (segmentation fault) — a native crash.
If we change the final StructGet
to refer to $structA
instead of $structB
, like this:
1 | kGCPrefix, kExprStructGet, $structA, ... |
Then the WebAssembly engine correctly recognizes the nullable field, and throws a safe WebAssembly runtime error for null dereference no crash occurs.
With a working crash, we are just a few steps away from:
- Building arbitrary read/write primitives
- Gaining OOB memory access inside the V8 sandbox
- Or potentially escaping the sandbox entirely
But since the issue is still under closed, I won’t share the full exploit chain here.
Full script crash:
1 | load('../../test/mjsunit/wasm/wasm-module-builder.js'); |