Wednesday, June 5, 2024

Exploring Google Chrome RCE via WASM Canonical Type Confusion


Introduction

WebAssembly (WASM) has revolutionized web development by enabling near-native performance for complex applications running in modern browsers, such as Google Chrome. However, with great power comes great responsibility, and the increasing complexity of the WebAssembly engine introduces new challenges for ensuring security. This paper focuses on a critical Remote Code Execution (RCE) vulnerability in Google Chrome, which is rooted in the confusion between different WASM types during JavaScript-to-WASM (JS-to-WASM) conversion processes. This vulnerability arises due to a mismanagement of wasm::HeapType and wasm::ValueType, leading to dangerous type confusion between arbitrary WASM types.

This vulnerability is similar to the well-known CVE-2024-2887, discovered by Manfred Paul during Pwn2Own Vancouver 2024. By leveraging type confusion, attackers can achieve arbitrary code execution, bypassing Chrome's security mechanisms, including the sandbox.

Bug / Root Cause Analysis

WebAssembly's Type System and Canonicalization

WebAssembly introduces a complex type system, especially with the advent of WasmGC (Garbage Collection) types. WASM supports recursive types, and to ensure type comparison across different modules, types are canonicalized. Canonicalization allows types defined in different WASM modules to be compared structurally. For instance, (type $t1 (struct (mut i32) (mut i64))) defined in module M1 should be equivalent to (type $t2 (struct (mut i32) (mut i64))) defined in module M2.

In Chrome's V8 engine, this process is managed through a system where each WASM type is assigned a globally unique identifier (uint32_t). These identifiers are used to track types and ensure consistency across different WASM modules. The code responsible for this is located in the canonical types module:

TypeCanonicalizer* GetTypeCanonicalizer() {

  return GetWasmEngine()->type_canonicalizer();

}


class TypeCanonicalizer {

 public:

  static constexpr uint32_t kPredefinedArrayI8Index = 0;

  static constexpr uint32_t kPredefinedArrayI16Index = 1;

  static constexpr uint32_t kNumberOfPredefinedTypes = 2;

  

 private:

  std::vector<uint32_t> canonical_supertypes_;

  std::unordered_map<CanonicalGroup, uint32_t, base::hash<CanonicalGroup>> canonical_groups_;

  std::unordered_map<CanonicalSingletonGroup, uint32_t, base::hash<CanonicalSingletonGroup>> canonical_singleton_groups_;

};

Here, the TypeCanonicalizer class is responsible for assigning and managing the globally unique type IDs (uint32_t). These IDs allow V8 to enforce type consistency when comparing types across modules. The system uses structures like canonical_supertypes_ to represent the subtyping relationships between different WASM types, where canonical_supertypes_[sub] = super indicates that super is the supertype of sub.

Canonical Type IDs and Their Use in WASM Modules

Every WASM module maintains a list of internal type definitions and their corresponding canonical IDs. This list allows WASM modules to map their internal type indices to the global canonical type IDs. The following structure represents this mapping in V8:

struct V8_EXPORT_PRIVATE WasmModule {

  std::vector<TypeDefinition> types;  // Internal type definitions

  std::vector<uint32_t> isorecursive_canonical_type_ids;  // Maps each type to its canonical type ID

};

For example, isorecursive_canonical_type_ids[t] = c indicates that the internal type index t has been canonicalized to the global type ID c. This mapping allows WASM modules to ensure that types with the same structure, even across different modules, are treated equivalently.

However, this system introduces a subtle but critical vulnerability. The canonical type id is a full uint32_t value, but during certain JS-to-WASM conversion processes, this value is truncated to fit into a smaller 20-bit field, leading to type confusion between different canonical type IDs. This mismanagement of type IDs leads to exploitable situations, as we will discuss in detail.

The Core Issue: Type Confusion During JS-to-WASM Conversion

One of the main vulnerabilities arises from the confusion between the wasm::HeapType and wasm::ValueType during JS-to-WASM conversion. These conversion functions are responsible for ensuring that JavaScript values passed to WASM modules are properly converted to WASM types. However, due to the improper handling of canonical type IDs, type confusion occurs.

Take, for example, the following code from the V8 engine’s FromJS() function, which handles type conversions:

Node* FromJS(Node* input, Node* js_context, wasm::ValueType type,

             const wasm::WasmModule* module, Node* frame_state = nullptr) {

  switch (type.kind()) {

    case wasm::kRef:

    case wasm::kRefNull: {

      switch (type.heap_representation_non_shared()) {

        case wasm::HeapType::kNone:

        case wasm::HeapType::kNoFunc:

        case wasm::HeapType::kI31:

        case wasm::HeapType::kAny:

        case wasm::HeapType::kFunc:

        case wasm::HeapType::kStruct:

        case wasm::HeapType::kArray:

        case wasm::HeapType::kEq:

        default: {

          if (type.has_index()) {

            DCHECK_NOT_NULL(module);

            uint32_t canonical_index = module->isorecursive_canonical_type_ids[type.ref_index()];

            type = wasm::ValueType::RefMaybeNull(canonical_index, type.nullability());  // [!] Truncated type ID used as HeapType

          }

          Node* inputs[] = {input, mcgraph()->IntPtrConstant(IntToSmi(static_cast<int>(type.raw_bit_field())))};

          return BuildCallToRuntimeWithContext(Runtime::kWasmJSToWasmObject, js_context, inputs, 2);

        }

      }

    }

  }

}

Here, during a JS-to-WASM conversion, the type is fetched from the module’s list of canonical type IDs. However, the canonical ID (uint32_t) is truncated to fit within a 20-bit field, as seen in the RefMaybeNull() function, which encodes the heap type. This results in the first major vulnerability—type confusion between different canonical type IDs.

How Type Confusion Occurs

To better understand how this confusion leads to an exploit, let’s consider the internal structure of the wasm::ValueType class:

class ValueType {

 public:

  static constexpr ValueType RefMaybeNull(uint32_t heap_type, Nullability nullability) {

    return ValueType(KindField::encode(nullability == kNullable ? kRefNull : kRef) |

                     HeapTypeField::encode(heap_type));  // [!] Only 20 bits of heap_type are used

  }


 private:

  using KindField = base::BitField<ValueKind, 0, kKindBits>;

  using HeapTypeField = KindField::Next<uint32_t, kHeapTypeBits>;  // [!] HeapType is only 20 bits wide

};

Here, heap_type is truncated to 20 bits, even though the canonical type ID is a full uint32_t. This means that for any canonical type IDs t1 and t2, if (t1 & 0xFFFFF) == (t2 & 0xFFFFF), the type check will fail, leading to type confusion. Specifically, this can occur when the system checks the type of an object passed from JS to WASM. If the truncated heap_type value matches, the object will pass the type check, even if it’s of an entirely different type.

Exploitable Vulnerabilities

There are two key exploitable vulnerabilities that arise from this situation:

  1. Type Confusion in JS-to-WASM Conversion: As discussed, the truncation of heap_type during type checks leads to type confusion. An attacker can craft WASM modules with specific type indices that, when canonicalized, result in IDs that appear identical when truncated. This allows the attacker to bypass type checks and inject malicious objects into WASM functions.

  2. Misuse of Canonical Type IDs as wasm::HeapType: In addition to the truncation issue, there is another vulnerability where canonical type IDs are incorrectly treated as wasm::HeapType. This results in situations where the system mistakenly treats a type as a supertype (such as HeapType::kAny), allowing attackers to pass arbitrary objects as valid WASM types.

For example, in the following JSToWasmObject() function, we see how a canonical type ID is confused with HeapType::kAny, allowing almost any object to bypass the type check:

namespace wasm {

MaybeHandle<Object> JSToWasmObject(Isolate* isolate, Handle<Object> value,

                                   ValueType expected_canonical, const char** error_message) {

  switch (expected_canonical.heap_representation_non_shared()) {

    case HeapType::kAny: {  // [!] Canonical type IDs confused with HeapType::kAny

      if (IsSmi(*value)) return CanonicalizeSmi(value, isolate);

      if (IsHeapNumber(*value)) return CanonicalizeHeapNumber(value, isolate);

      if (!IsNull(*value, isolate)) return value;

      *error_message = "null is not allowed for (ref any)";

      return {};

    }

  }

}

This second vulnerability is simpler to exploit. The attacker can craft objects with canonical type IDs that are mistaken for HeapType::kAny, allowing them to bypass checks that would normally prevent the execution of arbitrary code.

Exploitation

Leveraging Type Confusion for Arbitrary Read/Write

Once an attacker successfully triggers type confusion through the JS-to-WASM conversion process, they can gain arbitrary read/write access. This capability is fundamental in modern exploit development, as it allows further control over the target system.

Triggering Type Confusion

The first step in exploiting this vulnerability is crafting WebAssembly (WASM) modules with intentionally confusing types. Due to the improper handling of canonical type IDs, the V8 engine mistakenly treats different types as the same, leading to exploitable type confusion. For instance, an attacker can create confusion between a simple type, such as (type $t1 (struct (mut i32))), and a more complex type, such as (type $t2 (struct (ref $t1))), to bypass type checks.

In the vulnerable FromJS() function, the canonical type index is incorrectly truncated:

Node* FromJS(Node* input, Node* js_context, wasm::ValueType type,

             const wasm::WasmModule* module, Node* frame_state = nullptr) {

  if (type.has_index()) {

    uint32_t canonical_index = module->isorecursive_canonical_type_ids[type.ref_index()];

    type = wasm::ValueType::RefMaybeNull(canonical_index, type.nullability());

  }

  Node* inputs[] = {input, mcgraph()->IntPtrConstant(IntToSmi(static_cast<int>(type.raw_bit_field())))};

  return BuildCallToRuntimeWithContext(Runtime::kWasmJSToWasmObject, js_context, inputs, 2);

}

By crafting a WASM module that manipulates these type indices, the attacker can force V8 to interpret objects of one type as another, thus bypassing critical security checks. This misinterpretation allows the attacker to create conditions where memory can be accessed or modified in ways that the original type system should have prevented.

Achieving Arbitrary Read/Write

Once type confusion is in place, the attacker can perform arbitrary reads and writes to the V8 engine’s memory. One common exploitation strategy involves confusing a simple type, such as an i32 integer, with a pointer (ref) type. This allows the attacker to manipulate memory addresses as if they were integer values, effectively gaining control over critical memory locations.

// A vulnerable path that leads to arbitrary memory write due to type confusion

Node* fake_pointer = mcgraph()->HeapConstant(...);  // Fake a memory address (pointer)

Node* value_to_write = mcgraph()->Int32Constant(...);  // Value to write to the address

Node* result = BuildStore(fake_pointer, value_to_write);  // Write the value to the fake address

By constructing WASM objects with confused types, the attacker can issue read/write operations on arbitrary memory locations. This allows them to overwrite critical structures, such as function tables, and eventually gain control over the program’s execution flow.

Escaping the V8 Sandbox

Chrome’s V8 engine isolates WebAssembly execution within a sandbox to protect the system from malicious code. However, by exploiting the type confusion vulnerability and corrupting the V8 heap, attackers can bypass this sandbox and gain broader control over the system.

Abusing PartitionAlloc for Arbitrary Memory Corruption

The V8 engine uses PartitionAlloc as its memory allocator. By corrupting PartitionAlloc metadata through type confusion, attackers can manipulate how memory is allocated and freed, leading to arbitrary memory corruption.

PartitionAlloc is not part of the V8 heap's 4GB pointer compression cage, making it more accessible for exploitation. Specifically, by modifying the ArrayBuffer object’s backing_store field, attackers can redirect it to arbitrary memory locations. This results in leakage of sensitive addresses or direct memory corruption.

The relevant structure is as follows:

struct SlotSpanMetadata {

 private:

  PartitionFreelistEntry* freelist_head = nullptr;


 public:

  SlotSpanMetadata* next_slot_span = nullptr;

  PartitionBucket* const bucket = nullptr;  // [!] chrome.dll address leakage point


  uint32_t marked_full : 1;

  uint32_t num_allocated_slots : kMaxSlotsPerSlotSpanBits;

  uint32_t num_unprovisioned_slots : kMaxSlotsPerSlotSpanBits;

};

By manipulating the bucket field, an attacker can control memory operations performed by PartitionAlloc, leading to an arbitrary address write vulnerability.

The following code snippet demonstrates how memory is freed within PartitionAlloc:

PA_ALWAYS_INLINE void SlotSpanMetadata::Free(

    uintptr_t slot_start, PartitionRoot* root, const PartitionFreelistDispatcher* freelist_dispatcher) {

  if (marked_full || num_allocated_slots == 0) {

    FreeSlowPath(1);  // [!] Target vulnerable path

  }

}


void SlotSpanMetadata::FreeSlowPath(size_t number_of_freed) {

  if (marked_full) {

    marked_full = 0;

    bucket->active_slot_spans_head = this;  // [!] Arbitrary address write

  }

}

By controlling the bucket field and triggering the FreeSlowPath() function, the attacker can overwrite arbitrary memory locations with crafted data. This allows further escalation, including sandbox escapes or control of the broader Chrome process.

Full Exploitation: Achieving Remote Code Execution

After corrupting critical PartitionAlloc metadata, the attacker’s next step is to hijack V8’s control flow, leading to Remote Code Execution (RCE).

Hijacking the CodePointerTable

One common strategy for achieving RCE is to hijack the CodePointerTable (CPT), which stores function pointers used by V8’s JIT compiler. By overwriting entries in this table, the attacker can direct execution to attacker-controlled code, such as a Return-Oriented Programming (ROP) chain or shellcode.

Prepare a ROP chain or shellcode and store it in an attacker-controlled memory region.

Overwrite the CPT table to point to the controlled memory region, using the previously acquired arbitrary write primitive.

Trigger a function call that invokes the CPT entry, causing V8 to execute the attacker’s ROP chain or shellcode.

// Overwrite the CPT to point to attacker-controlled memory

PartitionBucket* bucket = fake_partition_bucket();

bucket->active_slot_spans_head = this;  // [!] Modify memory through the bucket

trigger_v8_function_call();  // Trigger a function call to execute the malicious code

By hijacking the control flow, the attacker gains full execution capabilities within the Chrome process, effectively achieving RCE.

Mitigations and Fixes

Correct Handling of Canonical Type IDs

To mitigate this vulnerability, developers must ensure that Canonical Type IDs are handled correctly during JS-to-WASM conversion. Currently, the heap_type field is incorrectly truncated, leading to type confusion. Fixing this requires ensuring that the Canonical Type ID is treated as a full 32-bit uint32_t value, rather than being truncated to 20 bits.

static constexpr ValueType RefMaybeNull(uint32_t heap_type, Nullability nullability) {

    return ValueType(

        KindField::encode(nullability == kNullable ? kRefNull : kRef) |

        HeapTypeField::encode(heap_type));  // [!] Fix: Use full 32-bit value

}

Introducing wasm::CanonicalType

To further prevent confusion between wasm::HeapType and Canonical Type IDs, developers should introduce a new type, wasm::CanonicalType, specifically for handling canonical IDs. This separation would prevent future bugs related to type confusion.

Hardening PartitionAlloc Metadata

In addition to fixing the type confusion bug, Chrome developers should harden PartitionAlloc against memory corruption attacks. This can be achieved by adding additional integrity checks for critical fields like bucket and SlotSpanMetadata.

Using mechanisms like ExternalPointerTable can prevent direct manipulation of sensitive metadata, making it more difficult for attackers to gain control over memory allocation operations.


Unveiling CVE-2024-38112 in the Shadows of Internet Explorer

Overview Recent security research uncovered a new vulnerability within Windows systems that exploits Internet Explorer to execute remote cod...