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 stack S0
    • f1 calls imp1 (JS), which enters f2 → secondary stack S1

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, and Retired

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 from S2 to S0
  • 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

  1. Create nested promising functions (f1, f2, etc.) to build a chain of secondary stacks.
  2. Skip over an intermediate stack (e.g. S1) during return.
  3. Overwrite the suspended stack (S1) using Wasm stack spraying (i64 locals).
  4. Return into the clobbered stack (S1) to execute attacker-controlled values.
  5. Place a retsled + ROP chain to:
    • Call VirtualProtect to make memory executable
    • Jump to shellcode
  6. Leak addresses using PartitionAlloc metadata, which works regardless of ShadowMetadata.

Basic Flow of Bug + Exploit

  1. Bug in CanonicalEquality::EqualValueType() ignores nullability for indexed reference types. (It does not check if ref can be null)

  2. Leads to hash collisions in TypeCanonicalizer::canonical_group_. (Allows us to treat two hashable recgroups the same even if one is ref null)

  3. Exploitable via a birthday attack on MurmurHash64A. (Precompute + Small Hashing 64-bit)

  4. Nullability confusion lets us turn ref null $t1 into ref $t1. (We don’t check if ref null $t1 is null and thus can cast it into ref $t1)

  5. 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)