❌

Reading view

There are new articles available, click to refresh the page.

CVE-2024-2887: A Pwn2Own Winning Bug in Google Chrome

In this guest blog from Master of Pwn winner Manfred Paul, he details CVE-2024-2887 – a type confusion bug that occurs in both Google Chrome and Microsoft Edge (Chromium). He used this bug as a part of his winning exploit that led to code execution in the renderer of both browsers. This bug was quickly patched by both Google and Microsoft. Manfred has graciously provided this detailed write-up of the vulnerability and how ghe exploited it at the contest.


In this blog, I describe a means of exploiting the V8 JavaScript and WebAssembly engine to gain execution of arbitrary shellcode inside the renderer process. This includes a bypass of the V8 memory sandbox (Ubercage), though code execution is still constrained by the process isolation-based browser sandbox. For demonstration purposes, this limitation can be removed by running the browser with the --no- sandbox flag.

Root Cause of the WebAssembly Universal Type Confusion

A WebAssembly module may contain a type section that defines a list of custom β€œheap types”. In the base specification, this is used only to declare function types, but with the adoption of the garbage collection (GC) proposal [PDF], this section can additionally define struct types, allowing for the use of composite, heap-allocated types in WebAssembly.

Normally, a struct declared in this section may only reference structs that precede it (structs with a lower type index). To support mutually recursive data structures, a feature called recursive type groups is available. Instead of declaring the (potentially) mutually recursive types as individual entries in the type section, a recursive group is declared as a single type section entry. Within this group, individual types are declared, which are thereby allowed to reference each other.

With this in mind, consider the function responsible for parsing the type section from the binary WebAssembly format in v8/src/wasm/module-decoder-impl.h:

At (1), the limit kV8MaxWasmTypes (currently equal to 1,000,000) is passed as a maximum to consume_count(), ensuring that at most this many entries are read from the type section. When recursive type groups were added, this check became insufficient. While this code will permit only kV8MaxWasmTypes entries of the type section to be read, each of those can potentially be a recursive type group containing more than one individual type definition.

This insufficiency was clearly noticed at the time of this change, as together with recursive type groups a second check was added at (2). Here, for each recursive type group, it is checked that the addition of the constituent types would not exceed the kV8MaxWasmTypes limit.

However, this second check is still not enough. While it protects the indices of each type allocated inside a recursive group, the presence of those groups also has implications for types declared outside this group, as each recursive group adds to the total count of declared types.

To make this clearer, imagine a type section consisting of two entries: one recursive group containingkV8MaxWasmTypes entries, and following that group, one non-recursive type. The check at (1) is passed, as the section only has two entries. While processing the recursive group, the check at (2) is also passed, as the section has exactly kV8MaxWasmTypes entries. For the following single type, there is no further check: at (3) the type is simply allocated at the next free index. In this case, the index will be kV8MaxWasmTypes, exceeding the usual maximum of kV8MaxWasmTypes-1. If there were more than one non-recursive type at the end of the type section, they would similarly get assigned kV8MaxWasmTypes+1, kV8MaxWasmTypes+2, and so forth, as type indices.

Impact of the Root Cause

Exceeding the maximal number of declared heap types might seem like a very harmless resource exhaustion bug at first. However, due to some internal details of how V8 handles WebAssembly heap types, it directly allows constructing some very powerful exploit primitives.

In v8/src/wasm/value-type.h, the encoding of heap types is defined:

Here, V8 assumes that all user-defined heap types will be assigned indices smaller than kV8MaxWasmTypes. Larger indices are reserved for fixed, internal heap types (beginning with kFunc). This results in our own type declarations aliasing one of these internal types, leading to many opportunities for type confusion.

Universal WebAssembly Type Confusion

To leverage this encoding ambiguity into a full type confusion, let’s first consider the struct.new opcode, which produces a reference to a new struct created from fields given on the stack. The caller specifies the desired struct type by passing its type index. The relevant check on the type index can be found in v8/src/wasm/function-body-decoder-impl.h:

Following the validation logic into the has_struct() method from v8/src/wasm/wasm-module.h:

Since we can make types.size() exceed the usual limit of kV8MaxWasmTypes, we can make the check pass even if when passing an index larger than this value. This allows us to create a reference of an arbitrary internal type that points to the struct we can freely define.

On the other hand, consider now the handling of the ref.cast instruction:

Here, a type check elimination is performed. If TypeCheckAlwaysSucceeds returns true, then no actual type check is emitted and the value is simply reinterpreted as the target type.

The function TypeCheckAlwaysSucceeds ultimately calls IsHeapSubtypeOfImpl defined in v8/src/wasm/wasm-subtyping.cc:

This means that if our declared type index aliases the constant HeapType::kNone, the type check will always be elided if we cast to any non-function, non-external reference. In combination, we can use this to turn any reference type into any other by the following steps:

  1. In the type section, define a structure type with a single field of type anyref, and make this struct have a type index equal to HeapType::kNone using the bug described above.

  2. Place a non-null reference value of any type on the top of the stack and call struct.new with the type index set to HeapType::kNone. This will succeed, as has_struct() validates the index against the index established via the previous step.

  3. Also, declare a struct with a normal type index lower than kV8MaxWasmTypes with a single field of the target reference type. Call ref.cast with this this struct’s type index. The engine will not perform any type check, as the input value is at this point understood to be reference type HeapType::kNone.

  4. Finally, read back the reference stored in the struct by executing struct.get.

This arbitrary casting of reference types allows transmuting any value type into any other by referencing it, changing the reference type, and then dereferencing it – a universal type confusion.

In particular, this directly contains nearly all usual JavaScript engine exploitation primitives as special cases:

β€’ Transmuting int to int* and then dereferencing results in an arbitrary read.

β€’ Transmuting int to int* and then writing to that reference results in an arbitrary write.

β€’ Transmuting externrefto int is the addrOf() primitive, obtaining the address of a JavaScript object.

β€’ Transmuting int to externref is the fakeObj() primitive, forcing the engine to treat an arbitrary value as a pointer to a JavaScript object.

While casting from HeapType::kNone to an externref is not allowed, remember that we are actually operating on one more level of indirection - transmuting to externref involves casting to a reference to a struct containing one externref member.

Note however that these β€œarbitrary” reads and writes are still contained in the V8 memory sandbox, as all involved pointers to heap-allocated structures are tagged, compressed pointers inside the heap cage, not full 64-bit raw pointers.

Integer Underflow Leading to V8 Sandbox Escape

The primitives described above allow for freely manipulating and faking most JavaScript objects. However, all of this happens inside the limited memory space of the V8 sandbox. β€œTrusted” objects such as WebAssembly instance data cannot yet be manipulated. We will now turn our attention to a bug that can be used to escape the memory sandbox.

An often-used object for JavaScript engine exploits is ArrayBuffer and its corresponding views, (i.e. typed arrays), as it allows for direct, untagged access to some region of memory.

To prevent access to pointers outside the V8 sandbox, sandboxed pointers are used to designate a typed array’s corresponding backing store. Similarly, an ArrayBuffer’s length field is always loaded as a β€œbounded size access”, inherently limiting its value to a maximum of 235 βˆ’ 1.

However, in modern JavaScript, the handling of typed arrays has become quite complex due to the introduction of resizable ArrayBuffers (RABs) and their sharable variant, growable SharedArrayBuffers (GSABs). Both variants feature the ability to change their length after the object has been created with the shared variant being restricted to never shrink. In particular, for typed arrays with these kinds of buffers, the array length can never be cached and must be recomputed on each access.

Additionally, ArrayBuffers also feature an offset field, describing the start of the data in the actual underlying backing store. This offset must be taken into account when computing the length.

Let’s now look at the code responsible for building a TypedArray’s length access in the optimizing Turbofan compiler. It can be found in v8/src/compiler/graph-assembler.cc. Note that most non-RAB/GSAB cases and the code responsible for dispatching are omitted for simplicity:

For arrays backed by a resizable ArrayBuffer, we can see at (1) that the length is computed as floor((byte_length - byte_offset) / element_size). Crucially, there is an underflow check. If byte_offset exceeds byte_length, then 0 is returned instead.

Curiously though, in the case of a GSAB-backed array, the corresponding underflow check is missing. Thus, if byte_offset is larger than byte_length, an underflow occurs and the subtraction wraps around to something close to the maximum unsigned 64-bit integer 264. As both of these fields are found in the (by now) attacker-controlled array object, we can easily trigger this using the sandboxed arbitrary read/write primitives discussed previously. This results in access to the whole 64-bit address space, as the length computed by this function is used to bound any typed array accesses (in JIT-compiled code).

Exploitation for Arbitrary Shellcode Execution

Using the two bugs described above, exploitation becomes fairly straightforward. The primitives described in the Universal WebAssembly Type Confusion section directly give arbitrary reads and writes within the V8 memory sandbox. This can then be used to manipulate a growable SharedArrayBuffer to have an offset greater than its length. A previously JIT-compiled read/write function can then be used to access and overwrite data anywhere in the process’s address space. An appropriate target for overwrite is the compiled code of a WebAssembly module, since that resides in an RWX (read-write-execute) page and can be overwritten with shellcode.


Thanks again to Manfred for providing this thorough write-up. He has contributed multiple bugs to the ZDI program over the last few years, and we certainly hope to see more submissions from him in the future. Until then, follow the team on Twitter, Mastodon, LinkedIn, or Instagram for the latest in exploit techniques and security patches.

❌