Out of Shift: How a Shared State Bug in V8’s AsmJS Parser Broke the Ubercage
Out of Shift: How a Shared State Bug 2026-6-22 15:59:57 Author: blog.exodusintel.com(查看原文) 阅读量:3 收藏


Out of Shift: How a Shared State Bug in V8’s AsmJS Parser Broke the Ubercage

By Chanh Pham

Overview

In this blog post we take a look at a vulnerability patched in a Chrome release on 3rd March 2026 and assigned CVE-2026-3542. The vulnerability arose from a flaw in state management when processing an AsmJS module, which results in a corrupted WebAssembly stream when it is fed to the decoder to instantiate the WebAssembly module.

Preliminaries

This section provides some background on AsmJS and WebAssembly.

AsmJS

AsmJS[1] (or asm.js) is a strict subset of JavaScript designed for high performance, enabling near-native execution of code compiled from languages like C/C++.

A module is defined using the "use asm" directive, which enforces a statically typed model through explicit coercions (e.g., x|0+x) and a restricted set of control flow constructs.

To ensure predictability and allow for aggressive optimization, AsmJS excludes many dynamic JavaScript features. It does not support strings, arbitrary objects, closures, dynamic property access, or garbage-collected allocations. Instead, it provides a low-level, C-like environment where values are limited to numeric types and function calls are statically determined. Memory is implemented via a contiguous ArrayBuffer accessed through typed arrays, emulating a linear memory model similar to native programs.

The following JavaScript snippet demonstrates the usage of AsmJS modules.

function AsmModule(stdlib, foreign, heap) {
  "use asm";

[1]

  var HEAP32 = new stdlib.Int32Array(heap);

[2]

  function f(a) {

[3]

    a = a | 0;

[4]

    a = a + 1;

[5]

    return a | 0;
  }

  return {f:f};
}
var heap = new ArrayBuffer(0x10000);
var m = AsmModule({Int32Array: Int32Array}, {}, heap);

An AsmJS module, AsmModule, is defined using the "use asm" directive. It defines a heap memory HEAP32, at [1], and a function f, at [2]. In the f function, at [3], explicit type coercion a | 0 is used to declare the parameter a as a 32-bit integer. The a parameter is then used in an arithmetic expression, result of which is stored back into the parameter a, at [4].

Finally, the explicit type coercion a | 0 is applied again upon the return statement at [5] to guarantee the function’s return value is a 32-bit integer.

V8’s AsmJS parser processes the JavaScript token stream and constructs the corresponding WebAssembly opcode stream, which is later written to a WebAssembly module. This WebAssembly module is processed as a regular module by the WebAssembly engine.

The WebAssembly Modules

The WebAssembly binary format organizes modules [2] into a sequence of sections. Each section defines a specific module’s component.
Function definitions are split across the function section (which declares function types) and the code section (which contains function bodies).
Each section is prefixed by a one-byte identifier, followed by a u32 value indicating the size of the section’s content, and then the actual contents of the section. The section IDs and their corresponding sections are as follows:

  • 0: Custom section
  • 1: Type section
  • 2: Import section
  • 3: Function section
  • 4: Table section
  • 5: Memory section
  • 6: Global section
  • 7: Export section
  • 8: Start section
  • 9: Element section
  • 10: Code section
  • 11: Data section
  • 12: Data count section

The WebAssembly Type Section

The WebAssembly type section, identified as section 1 in the binary encoding of modules, is responsible for defining the function signatures and other custom types used within a module.
The type section provides the list of function types which represents the type of arguments of the function (parameter types) and the types of values returned from the function (result types).

The WebAssembly Code Section

The WebAssembly Code section, identified by ID 10, contains the actual code for each function defined in the module.
The code for each function must correspond to the function signature declared in the Type section.

The Code section consists of the following:

  • The section code number: 0x0a
  • The size of the section in bytes, encoded as a LEB128 integer.
  • The number of function entries in the section.
  • For each function entry:
    • The size of the function code in bytes, encoded as a LEB128 integer.
    • A list of local variable declarations.
    • A sequence of opcodes representing the function body, terminated by an end opcode.

WebAssembly Opcodes

WebAssembly instructions are encoded as opcodes.[3] Most opcodes are a single byte, while some are multi-byte sequences that represent extensions to the core instruction set.

Examples of simple opcodes include:

  • 0x20: Local get operation.
  • 0x21: Local set operation.
  • 0x23: Global get operation.
  • 0x24: Global set operation.
  • 0xa0: 64-bit float add operation
  • 0xa1: 64-bit float subtract operation.

Multi-byte opcodes often begin with a prefix byte. For instance, opcodes starting with 0xfb are part of the garbage collection (GC) extension proposal. Some of these include:

  • 0xfb 0x01: New structure operation.
  • 0xfb 0x03: Structure get operation.
  • 0xfb 0x06: Structure set operation.
  • 0xfb 0x17: Array length operation.

Other extensions introduce opcodes for features like reference-typed strings, SIMD (Single Instruction, Multiple Data), and multithreading.
It is important to note that extended opcodes are often experimental WebAssembly features and may not be supported on all platforms.
Their values can also vary between implementations or proposal versions.

Within V8, WebAssembly opcodes are referenced by enumerations. Some example opcodes include:

  • kExprI32Const: pushing an 32-bit numeric constant onto the stack.
  • kExprI64Const: pushing an 64-bit numeric constant onto the stack.
  • kExprI32And: performing bitwise AND between two 32-bit numeric constant, pushing the result onto the stack.
  • kExprI64And: performing bitwise AND between two 64-bit numeric constant, pushing the result onto the stack.
  • kExprCallFunction: performing a function call using the function reference on top of the stack.
  • kExprLocalGet: retrieving the value of an argument/local variable at the specified index.
  • kExprLocalGet: setting the value at the top of the stack to the argument/local variable at the specified index.

Vulnerability

When an expression is used to dynamically access values from the heap, the AsmJsParser::ValidateHeapAccess() method is invoked to validate the constraints. This behavior is described in the AsmJS specification[4].

Consider the following AsmJS expression: HEAP32[n >> 2]. In this expression, HEAP32 is an ArrayBuffer that is wrapped inside a 32-bit typed array, acting as the heap of the AsmJS module. The n >> 2 expression is evaluated at runtime to determine which memory location is accessed.

Because the 32-bit typed array operates on 32-bit elements, the index n is right-shifted by two bits to convert the byte offset into an element index. When the token stream is parsed, the AsmJS parser replaces this shift with a bitwise AND expression: n & ~(4-1), where 4represents the byte size of each element. This substitution is performed by the AsmJsParser::ValidateHeapAccess() method.

The AsmJsParser::ShiftExpression() method, which is responsible for handling shift expressions, is invoked by the AsmJsParser::ValidateHeapAccess() method to process the index expression used for the heap access. This method assigns the current position of the WebAssembly opcode stream to the heap_access_shift_position_ attribute of the AsmJsParser instance if the current expression is a right-shift expression, and its second operand is an immediate value. Otherwise, the kNoHeapAccessShift value (-1) is assigned to the heap_access_shift_position_ attribute.

When the AsmJsParser::ShiftExpression() method returns, the execution flow shifts back to the AsmJsParser::ValidateHeapAccess() method. It passes the heap_access_shift_position_ variable to the WasmFunctionBuilder::DeleteCodeAfter()method to truncate the stream, effectively replacing the opcodes inserted by the right-shift expression with kExprI32Const and kExprI32And opcodes.

Because a shift[5] expression expects its second operand to be either an immediate value or an additive[6] expression, the AsmJsParser::ShiftExpression() method also invokes the AsmJsParser::AdditiveExpression() method to check and parse if its second operand is an additive expression. After a long call chain, the AsmJsParser::ShiftExpression() method can be called a second time. This makes it possible to construct a call chain such as the one shown in the following listing.

AsmJsParser::ValidateHeapAccess() -> AsmJsParser::ShiftExpression() -> AsmJsParser::AdditiveExpression() -> ... -> AsmJsParser::ShiftExpression()

The first invocation of the AsmJsParser::ShiftExpression() method, which is made by the AsmJsParser::ValidateHeapAccess() method, may originate from parsing a left-shift expression, while the second recursive invocation may originate from parsing a right-shift expression. This scenario happens when nested shift expressions are evaluated. Because the heap_access_shift_position_ attribute is an attribute of the current AsmJsParser parser instance, its state is shared across all recursive parsing calls. Thus, the value assigned during the initial invocation can be overwritten by a subsequent recursive invocation.

Thus, when control returns to the first invocation of the AsmJsParser::ShiftExpression()method, the position stored in heap_access_shift_position_ is incorrect. Subsequent opcodes have been appended to the stream before the first invocation returns, leading to a desynchronized state.

Upon the return of the initial AsmJsParser::ShiftExpression() call, the AsmJsParser::ValidateHeapAccess() method invokes WasmFunctionBuilder::DeleteCodeAfter() to truncate the opcode stream starting from the position stored in the overwritten heap_access_shift_position_ variable.

As V8 assumes that the invocation of the AsmJsParser::ShiftExpression() method that is made by the AsmJsParser::ValidateHeapAccess() method always processes a right-shift operation, it does not account for the case where new opcodes are added to the stream between the assignment of the heap_access_shift_position_ attribute and the invocation of the WasmFunctionBuilder::DeleteCodeAfter() method. As a result, the truncation leaves the remaining opcode stream stale and misaligned. This misalignment can be further exploited to obtain arbitrary read/write primitives outside the Ubercage sandbox.

Code Analysis

In the following we analyzing the vulnerable code in more detail. When a heap access expression is encountered, such as HEAP32[n >> 2], the AsmJsParser::ValidateHeapAccess() method is invoked to validate the constraints, ensuring that the access is valid. Its implementation is shown in the following listing.

// File: v8/src/asmjs/asm-parser.cc

// 6.10 ValidateHeapAccess
void AsmJsParser::ValidateHeapAccess() {
  VarInfo* info = GetVarInfo(Consume());
  int32_t size = info->type->ElementSizeInBytes();
  EXPECT_TOKEN('[');
  uint32_t offset;

[1]

  if (CheckForUnsigned(&offset)) {
    // TODO(bradnelson): Check more things.
    // TODO(asmjs): Clarify and explain where this limit is coming from,
    // as it is not mandated by the spec directly.
    if (offset > 0x7FFFFFFF ||
        static_cast<uint64_t>(offset) * static_cast<uint64_t>(size) >
            0x7FFFFFFF) {
      FAIL("Heap access out of range");
    }
    if (Check(']')) {
      current_function_builder_->EmitI32Const(
          static_cast<uint32_t>(offset * size));
      // NOTE: This has to happen here to work recursively.
      heap_access_type_ = info->type;
      return;
    } else {
      scanner_.Rewind();
    }
  }
  AsmType* index_type;

[2]

  if (info->type->IsA(AsmType::Int8Array()) ||
      info->type->IsA(AsmType::Uint8Array())) {
    RECURSE(index_type = Expression(nullptr));
  } else {

[3]

    RECURSE(index_type = ShiftExpression());

[4]

    if (heap_access_shift_position_ == kNoHeapAccessShift) {
      FAIL("Expected shift of word size");
    }

[5]

    if (heap_access_shift_value_ > 3) {
      FAIL("Expected valid heap access shift");
    }

[6]

    if ((1 << heap_access_shift_value_) != size) {
      FAIL("Expected heap access shift to match heap view");
    }

[7]

    // Delete the code of the actual shift operation.
    current_function_builder_->DeleteCodeAfter(heap_access_shift_position_);

[8]

    // Mask bottom bits to match asm.js behavior.
    current_function_builder_->EmitI32Const(~(size - 1));
    current_function_builder_->Emit(kExprI32And);
  }
  if (!index_type->IsA(AsmType::Intish())) {
    FAIL("Expected intish index");
  }
  EXPECT_TOKEN(']');
  // NOTE: This has to happen here to work recursively.
  heap_access_type_ = info->type;
}

At [1], the method invokes CheckForUnsigned() to check and retrieve the next token if it is an unsigned immediate. If it is not, the execution continues at [2], which checks if the unit of the current heap is 8-bit.

If the current heap unit is larger than 8 bits, the AsmJsParser::ShiftExpression() method is invoked, at [3]. Upon the return of the AsmJsParser::ShiftExpression() method, the AsmJsParser::ValidateHeapAccess() method checks for three constraints:

  • The heap_access_shift_position_ attribute must not equal kNoHeapAccessShift (-1), at [4].
  • The heap_access_shift_value_ attribute must be less than 3, at [5].
  • The expression (1 << heap_access_shift_value_) must be equal to the size variable [6], which is the size of each element of the heap.

The implementation of the AsmJsParser::ShiftExpression() method is shown in the following listing.

// File: v8/src/asmjs/asm-parser.cc

// 6.8.10 ShiftExpression
AsmType* AsmJsParser::ShiftExpression() {
  AsmType* a = nullptr;
  RECURSEn(a = AdditiveExpression());
  heap_access_shift_position_ = kNoHeapAccessShift;
  // TODO(bradnelson): Implement backtracking to avoid emitting code
  // for the x >>> 0 case (similar to what's there for |0).
  for (;;) {
    switch (scanner_.Token()) {

[9]

      case TOK(SAR): {
        EXPECT_TOKENn(TOK(SAR));
        // Remember position allowing this shift-expression to be used as part
        // of a heap access operation expecting `a >> n:NumericLiteral`.
        bool imm = false;
        size_t old_pos;
        size_t old_code;
        uint32_t shift_imm;

[10]

        if (a->IsA(AsmType::Intish()) && CheckForUnsigned(&shift_imm)) {
          old_pos = scanner_.Position();

[11]

          old_code = current_function_builder_->GetPosition();
          scanner_.Rewind();
          imm = true;
        }
        AsmType* b = nullptr;
        RECURSEn(b = AdditiveExpression());

[12]

        // Check for `a >> n:NumericLiteral` pattern.
        if (imm && old_pos == scanner_.Position()) {

[13]

          heap_access_shift_position_ = old_code;
          heap_access_shift_value_ = shift_imm;
        } else {
          heap_access_shift_position_ = kNoHeapAccessShift;
        }

        if (!(a->IsA(AsmType::Intish()) && b->IsA(AsmType::Intish()))) {
          FAILn("Expected intish for operator >>.");
        }

[14]

        current_function_builder_->Emit(kExprI32ShrS);
        a = AsmType::Signed();
        continue;
      }

[15]

#define HANDLE_CASE(op, opcode, name, result)                        \
  case TOK(op): {                                                    \
    EXPECT_TOKENn(TOK(op));                                          \

[16]

    heap_access_shift_position_ = kNoHeapAccessShift;                \
    AsmType* b = nullptr;                                            \

[17]

    RECURSEn(b = AdditiveExpression());                              \
    if (!(a->IsA(AsmType::Intish()) && b->IsA(AsmType::Intish()))) { \
      FAILn("Expected intish for operator " #name ".");              \
    }                                                                \

[18]

    current_function_builder_->Emit(kExpr##opcode);                  \
    a = AsmType::result();                                           \
    continue;                                                        \
  }
        HANDLE_CASE(SHL, I32Shl, "<<", Signed);
        HANDLE_CASE(SHR, I32ShrU, ">>>", Unsigned);
#undef HANDLE_CASE
      default:
        return a;
    }
  }
}

The logic for right and left-shift expressions is implemented at [9] and [15], respectively. If it is a right-shift expression, the method checks if the first operand is of type intish[7], and the second operand is an immediate value, at [10]. If the condition holds, the current position of the opcode stream is stored in the old_code variable, at [11].

At [12], if the second operand evaluates to an immediate value, the method assigns the value of old_code to the heap_access_shift_position_ member variable and the value of shift_immto the heap_access_shift_value_ member variable at [13]. A kExprI32ShrS opcode is then emitted at [14].

Conversely, for a left-shift expression, the method expects the second operand to be an additive expression, invoking AsmJsParser::AdditiveExpression(), at [17]. A kExprI32Shlopcode is then emitted, at [18].

When the AsmJsParser::ShiftExpression() method returns, the AsmJsParser::ValidateHeapAccess() method checks the constraints at [4], [5], and [6]. Then, at [7], the WasmFunctionBuilder::DeleteCodeAfter() method is invoked with the heap_access_shift_position_ attribute. Its implementation is shown in the following listing.

// File: v8/src/wasm/wasm-module-builder.cc

void WasmFunctionBuilder::DeleteCodeAfter(size_t position) {
  DCHECK_LE(position, body_.size());

[19]

  body_.Truncate(position);
}

At [19], the ZoneBuffer::Truncate() method is invoked to execute the following statement: pos_ = buffer_ + size. The pos_ attribute of a ZoneBuffer object holds a pointer to the current write position in the buffer (buffer_).

This invocation resets the pos_ pointer to the position stored in the heap_access_shift_position_ attribute, effectively discarding any opcodes emitted after that point (including the opcode emitted at [14]).

This truncation prepares the opcode stream to replace the original shift operation with the bitwise AND operation emitted at [8].

This design has a flaw. At [16], the assignment of the kNoHeapAccessShift value to the heap_access_shift_position_ attribute is executed before the invocation of the AsmJsParser::AdditiveExpression() method, at [17]. However, it is possible that the AsmJsParser::AdditiveExpression() method, after several other invocations, calls the AsmJsParser::ShiftExpression() method a second time.

Consider the following expression: HEAP32[1 << (n >> 2)].

The invocation of the AsmJsParser::ShiftExpression() method, at [3], handles the 1 << ...expression, which is a left-shift expression. The heap_access_shift_position_ attribute is assigned kNoHeapAccessShift, at [16], followed by an invocation of the AsmJsParser::AdditiveExpression() method.

Because the nested expression ((n >> 2)) is a right-shift expression, the AsmJsParser::ShiftExpression() method is invoked again. Since 2 is an immediate value, the assignment to the heap_access_shift_position_ attribute is performed, at [13], overwriting the kNoHeapAccessShift value that was assigned to it at [16].

kExprI32ShrS opcode is then emitted, at [14]. Then, upon the return of the AsmJsParser::AdditiveExpression() method at [17], a I32Shl opcode is also emitted, at [18]. Subsequent expressions that follow the nested shift expression also cause more opcodes to be emitted to the opcode stream.

When the WasmFunctionBuilder::DeleteCodeAfter() method is invoked, at [7], it uses the stale position that is pointing in the middle of the opcode stream, where subsequent opcodes have been inserted since the last time the position was saved. As a result, the opcode at [8] replaces the inserted opcodes with itself and stale the opcode stream.

Furthermore, when the opcodes at [8] are emitted, it may be followed by an immediate value, which is an operand left by other opcodes.

The following listing shows the state of the opcode stream before and after the insertion of the opcodes at [8].

stream (before): [ I32Const 0x2 I32Const 0xc7 0x95 0xa6 0x05 ]

current_function_builder_->EmitI32Const(~(size - 1));
current_function_builder_->Emit(kExprI32And);

stream (after): [ I32Const 0x2 I32Const 0x7c kExprI32And 0xa6 0x05 ]

As a result, the byte 0xa6, which was previously part of the immediate operand of a kExprI32Const opcode, becomes the next opcode to be executed after the inserted kExprI32And opcode as kExprI32And does not have any operand.

Triggering the Vulnerability

Because of the invalid truncation, the opcode stream becomes stale, causing the immediate values to be potentially treated as an opcode. Consider the following AsmJS module.

function AsmModule(stdlib, foreign, heap) {
  "use asm";
  var HEAP32 = new stdlib.Int32Array(heap);
  function g() { return 1.25; }
  function f(a,b,c) {
    a = a | 0;
    b = b | 0;
    c = c | 0;

[1]

    HEAP32[1 << (b >> 2) + ~~+g()] = c;

[2]

    1.1;
    return c | 0;
  }
  return {f:f};
}

The heap access expression, at [1], contains a nested-shift expression 1 << (b >> 2) causing the opcode stream to be stale.

The float immediate value 1.1 at [2] is represented as 0x3ff199999999999a in hex. As such, when the WebAssembly decoder encounters the byte 0xf1, an exception is raised as 0xf1does not map to any valid opcode.

Exploitation

Normally, it requires more than one vulnerability achieve arbitrary code execution due to the Ubercage sandbox. However, this vulnerability can penetrate the sandbox as the corrupted opcode stream is “trusted” by the WebAssembly engine.

Executing Unvalidated WebAssembly Opcodes

As mentioned, when the opcode stream has been successfully constructed from the AsmJS module, it is passed to the WebAssembly decoder to be processed as a regular WebAssembly module.

Normally, when the WebAssembly opcodes are directly provided from the JavaScript land, the opcodes are considered untrusted, and must be validated. For example, consider the kExprCallRef opcode.

// File: v8/src/wasm/function-body-decoder-impl.h

  DECODE(CallRef) {
    this->detected_->add_typed_funcref();
    SigIndexImmediate imm(this, this->pc_ + 1, validate);
    if (!this->Validate(this->pc_ + 1, imm)) return 0;

[3]

    Value func_ref = Pop(ValueType::RefNull(imm.heap_type()));
    PoppedArgVector args = PopArgs(imm.sig);
    Value* returns = PushReturns(imm.sig);
    CALL_INTERFACE_IF_OK_AND_REACHABLE(CallRef, func_ref, imm.sig, args.data(),
                                       returns);
    MarkMightThrow();
    return 1 + imm.length;
  }

The handler for this opcode invokes the Pop() method to retrieve a function reference from the stack, at [3]. Its implementation is shown in the following listing.

// File: v8/src/wasm/function-body-decoder-impl.h

  Pop(ValueTypes... expected_types) {
    constexpr int kCount = sizeof...(ValueTypes);
    EnsureStackArguments(kCount);
    DCHECK_LE(control_.back().stack_depth, stack_size());
    DCHECK_GE(stack_size() - control_.back().stack_depth, kCount);
    // Note: Popping from the {FastZoneVector} does not invalidate the old (now
    // out-of-range) elements.
    stack_.pop(kCount);
    auto ValidateAndGetNextArg = [this, i = 0](ValueType type) mutable {

[4]

      ValidateStackValue(i, stack_.end()[i], type);
      return stack_.end()[i++];
    };
    return {ValidateAndGetNextArg(expected_types)...};
  }

Before the value is returned, the WasmFullDecoder::ValidateStackValue() method is invoked to check if the type of the top stack value matches the expected type, at [4]. Its implementation is shown next.

// File: v8/src/wasm/function-body-decoder-impl.h

  V8_INLINE void ValidateStackValue(int index, Value value,
                                    ValueType expected) {

[5]

    if (!VALIDATE(IsSubtypeOf(value.type, expected, this->module_) ||
                  value.type == kWasmBottom || expected == kWasmBottom)) {
      PopTypeError(index, value, expected);
    }
  }

This method invokes the IsSubtypeOf() function to check if the value’s type is a subtype of the expected type. The expression is wrapped by the VALIDATE macro, which expands to the expression shown in the following listing.

// File: v8/src/wasm/function-body-decoder-impl.h

#define VALIDATE(condition) (!ValidationTag::validate || V8_LIKELY(condition))

The condition at [5] is only evaluated if the ValidationTag::validate is true. In essence, the condition can be bypassed if ValidationTag::validate evaluates to false.

When the WebAssembly opcodes are directly provided from the JavaScript land, ValidationTag::validate is set to true, as the opcodes are untrusted and must be validated.

However, for AsmJS modules, opcodes are emitted by the AsmJS parser instead of directly provided by the JavaScript land. As such, V8 bypasses the validation of these opcodes by setting the ValidationTag::validate to false.

As a result, the attacker can leverage opcodes such as CallRef to treat a 64-bit number on the stack as an address, escaping the sandbox.

Obtaining Arbitrary Read/Write Primitives

Because AsmJS only operates on primitive values, no structure/array is defined. As a result, even though the kExprStructGet/kExprStructSet opcodes do not check the type of the value it operates on (and thus can be tricked to perform read/write on a crafted 64-bit address), usages of the opcodes lead to dereference of null pointers. There are function types defined in the module, but they are incompatible with struct types.

During the decoding phase, the module’s type object, which consists of function types, array types and structure types, is stored in the types attribute of the WasmModule object, which represents the current WebAssembly module. The types attribute is a vector, elements of which are of type TypeDefinition.

The TypeDefinition structure represents a type, it is allocated on a dedicated zone memory named canonical type zone. Because the kExprStructGet/kExprStructSet opcodes do not validate whether the type index is within the bounds of the current module’s type vector, an index can be provided to the opcodes such that it reads the type structure of another module.

gdb> p this->module_->types->__begin_[0]
$21 = {
  {
    function_sig = 0x30dc0123b820, <= the type pointer
    struct_type = 0x30dc0123b820,
    array_type = 0x30dc0123b820,
    cont_type = 0x30dc0123b820
  },
  supertype = {
    <v8::internal::wasm::TypeIndex> = {
      index = 4294967295,
      static kInvalid = 4294967295
    }, <No data fields>},
  descriptor = {
    <v8::internal::wasm::TypeIndex> = {
      index = 4294967295,
      static kInvalid = 4294967295
    }, <No data fields>},
  describes = {
    <v8::internal::wasm::TypeIndex> = {
      index = 4294967295,
      static kInvalid = 4294967295
    }, <No data fields>},
  kind = v8::internal::wasm::TypeDefinition::kFunction, <= the type's kind
  is_final = true,
  is_shared = false,
  subtyping_depth = 0 '\000'
}

gdb> tele 0x30dc0000cc40 30
00:0000│     0x30dc0000cc40 —▸ 0x30dc0123b820 ◂— 1 <= the type pointer on zone heap
01:0008│     0x30dc0000cc48 ◂— 0xffffffffffffffff
02:0010│     0x30dc0000cc50 ◂— 0x101ffffffff
03:0018│     0x30dc0000cc58 —▸ 0x30dc0123b848 ◂— 1
04:0020│     0x30dc0000cc60 ◂— 0xffffffffffffffff
05:0028│     0x30dc0000cc68 ◂— 0x101ffffffff
06:0030│     0x30dc0000cc70 —▸ 0x30dc0123b860 ◂— 0
07:0038│     0x30dc0000cc78 ◂— 0xffffffffffffffff
08:0040│     0x30dc0000cc80 ◂— 0x101ffffffff
09:0048│     0x30dc0000cc88 ◂— 0
... ↓     3 skipped
0d:0068│     0x30dc0000cca8 ◂— 1
0e:0070│     0x30dc0000ccb0 ◂— 0xf0ca000000000000
0f:0078│     0x30dc0000ccb8 ◂— 0xf35ffffffffffff
10:0080│     0x30dc0000ccc0 ◂— 0
11:0088│     0x30dc0000ccc8 —▸ 0x30dc0000ccc0 ◂— 0
12:0090│     0x30dc0000ccd0 —▸ 0x30dc001d0c00 ◂— 0
13:0098│     0x30dc0000ccd8 —▸ 0x30dc0109c018 ◂— 0x6e75662d6d736177 ('wasm-fun')
14:00a0│     0x30dc0000cce0 —▸ 0x7ffcbd4d7e90 ◂— 0
15:00a8│     0x30dc0000cce8 —▸ 0x7ffcbd4d7ed0 ◂— 6
16:00b0│     0x30dc0000ccf0 ◂— 0x1e2223381c8bac27
17:00b8│     0x30dc0000ccf8 ◂— 0x2d358dccaa6c78a5
18:00c0│     0x30dc0000cd00 ◂— 0x8bb84b93962eacc9
19:00c8│     0x30dc0000cd08 ◂— 0x4b33a62ed433d4a3
1a:00d0│     0x30dc0000cd10 —▸ 0x7ffcbd4d7a20 ◂— 0
1b:00d8│     0x30dc0000cd18 ◂— 0
1c:00e0│     0x30dc0000cd20 ◂— 0xb0cc00007fffffff
1d:00e8│     0x30dc0000cd28 ◂— 0

Multiple structure definitions can be sprayed on the zone heap, such that one of the structure definitions TypeDefinition is allocated near the type vector of the AsmJS module. Arbitrary read/write primitives can be implemented by placing a 64-bit number on the stack using the kExprI64Const opcode, and using the kExprStructGet/kExprStructSet opcodes to perform read/write on the placed address.

gdb> tele 0x1804022fb3a0 100

[6]

00:0000│     0x1804022fb3a0 —▸ 0x1804024b4020 ◂— 1    <= the function type
01:0008│     0x1804022fb3a8 ◂— 0xffffffffffffffff
02:0010│     0x1804022fb3b0 ◂— 0x101ffffffff          <= ((0x101ffffffff >> 32) & 0xff) == 0x1 (TypeDefinition::kFunction)
03:0018│     0x1804022fb3b8 —▸ 0x1804024b4048 ◂— 1
04:0020│     0x1804022fb3c0 ◂— 0xffffffffffffffff
05:0028│     0x1804022fb3c8 ◂— 0x101ffffffff
06:0030│     0x1804022fb3d0 —▸ 0x1804024b4060 ◂— 0
07:0038│     0x1804022fb3d8 ◂— 0xffffffffffffffff
08:0040│     0x1804022fb3e0 ◂— 0x101ffffffff
09:0048│     0x1804022fb3e8 ◂— 0
... ↓     3 skipped
0d:0068│     0x1804022fb408 ◂— 1
0e:0070│     0x1804022fb410 ◂— 0x50b22f0200000000
0f:0078│     0x1804022fb418 ◂— 0xaf4dd0fdffffffff
10:0080│     0x1804022fb420 ◂— 0xbadbad00badbad00
... ↓     3 skipped
14:00a0│     0x1804022fb440 —▸ 0x7ffe2d55c480 ◂— 0
15:00a8│     0x1804022fb448 —▸ 0x7ffe2d55c4c0 ◂— 6
16:00b0│     0x1804022fb450 ◂— 0x1e2223381c8bac27
17:00b8│     0x1804022fb458 ◂— 0x2d358dccaa6c78a5
18:00c0│     0x1804022fb460 ◂— 0x8bb84b93962eacc9
19:00c8│     0x1804022fb468 ◂— 0x4b33a62ed433d4a3
1a:00d0│     0x1804022fb470 —▸ 0x7ffe2d55c050 —▸ 0x1804024a9028 —▸ 0x7ffe2d55bd10 ◂— 0
1b:00d8│     0x1804022fb478 ◂— 0
1c:00e0│     0x1804022fb480 ◂— 0x10b42f027fffffff
1d:00e8│     0x1804022fb488 ◂— 0
... ↓     11 skipped
29:0148│     0x1804022fb4e8 ◂— 1
2a:0150│     0x1804022fb4f0 —▸ 0x180400008000 —▸ 0x594cd5745f60 (vtable for v8::base::PageAllocator+16) —▸ 0x594cd3c7a0c0 (malloc_stats) ◂— push rbp
2b:0158│     0x1804022fb4f8 —▸ 0x2b8f7fc8f000 ◂— jmp 0x2b8f7fc8f980 /* 0xcccccc0000097be9 */
2c:0160│     0x1804022fb500 ◂— 0x2000

[7]

2d:0168│     0x1804022fb508 —▸ 0x1804024a4748 ◂— 0x64 /* 'd' */ <= the sprayed struct type, belonging to another module
2e:0170│     0x1804022fb510 ◂— 0xffffffffffffffff
2f:0178│     0x1804022fb518 ◂— 0x2ffffffff                      <= ((0x2ffffffff >> 32) & 0xff) == 0x2 (TypeDefinition::kStruct)
30:0180│     0x1804022fb520 —▸ 0x1804024a4b00 ◂— 0x65 /* 'e' */
31:0188│     0x1804022fb528 ◂— 0xffffffffffffffff
32:0190│     0x1804022fb530 ◂— 0x2ffffffff
33:0198│     0x1804022fb538 —▸ 0x1804024a4eb8 ◂— 0x66 /* 'f' */
34:01a0│     0x1804022fb540 ◂— 0xffffffffffffffff
35:01a8│     0x1804022fb548 ◂— 0x2ffffffff
36:01b0│     0x1804022fb550 —▸ 0x7ffe2d55c050 —▸ 0x1804024a9028 —▸ 0x7ffe2d55bd10 ◂— 0
37:01b8│     0x1804022fb558 ◂— 1
38:01c0│     0x1804022fb560 ◂— 0xf0b42f027fffffff

The function type at [6] is at types[0]. Because the decoder does not perform any validation on whether this type index points to a type defined by the current module, it performs an out-of-bounds read that load the cross-module type that we prepared, which is at types[17] in this case ([7]).

Leaking The Base Address Of The Trusted Cage

We have obtained arbitrary read and write primitives, we need an address leak the wrap up the exploit. In order to leak the base address of the trusted cage, where 64-bit pointers to JIT pages and other critical objects resides, we place multiple Drop opcodes in a WebAssembly function to drain all of its stack value (if any).

Because the AsmJS function is defined to return one value, whereas all the return values have been drained from exceeding Drop opcodes, the AsmJS function returns its only value in the stack slots, which is the WasmTrustedInstanceData object. A WebAssembly function stack looks like the following (note that this is the call descriptor, not the decoder’s stack).

By dropping all return values and parameters, we reach the WasmTrustedInstanceData object in the first stack slot. Since the WasmTrustedInstanceData object is stored in the fixed register RSI (x64, done by the wrapper before calling the code pointer), a gap move emits the instruction mov rax, rsi. The generated machine code looks like the following.

0x2a02451dca40    push   rbp
   0x2a02451dca41    mov    rbp, rsp     RBP => 0x7ffde2c5faa8 —▸ 0x7ffde2c5fad0 —▸ 0x7ffde2c5fb18 —▸ 0x7ffde2c5fc10 ◂— ...
   0x2a02451dca44    push   0x30
   0x2a02451dca46    push   rsi

[8]

   0x2a02451dca47    mov    rax, rsi     RAX => 0xd790104ff99 ◂— 0x7100469000000020
   0x2a02451dca4a    mov    rsp, rbp     RSP => 0x7ffde2c5faa8 —▸ 0x7ffde2c5fad0 —▸ 0x7ffde2c5fb18 —▸ 0x7ffde2c5fc10 ◂— ...
   0x2a02451dca4d    pop    rbp          RBP => 0x7ffde2c5fad0
   0x2a02451dca4e    ret                                <0x2a02451dcac0>


gdb> job 0xd790104ff99
0xd790104ff99: [WasmTrustedInstanceData]
 - map: 0x0f1300002021 <Map[152](WASM_TRUSTED_INSTANCE_DATA_TYPE)>
 - instance_object: 0x0f1301aafcd1 <Instance map = 0xf13010132d9>
 - native_context: 0x0f13010047dd <NativeContext[305]>
 - shared_part: 0x0d790104ff99 <Other heap object (WASM_TRUSTED_INSTANCE_DATA_TYPE)>
 - memory_objects: 0x0f130172019d <FixedArray[1]>
 - tables: 0x0f13000007bd <FixedArray[0]>
 - dispatch_table0: 0x0d790104ff71 <WasmDispatchTable[0]>
 - dispatch_tables: 0x0d7901000029 <ProtectedFixedArray[0]>
 - dispatch_table_for_imports: 0x0d790104ff41 <WasmDispatchTableForImports[0]>
 - func_refs: 0x0f1301720181 <FixedArray[5]>
 - managed_object_maps: 0x0f13017201c5 <FixedArray[3]>
 - feedback_vectors: 0x0f1301aafd05 <FixedArray[5]>
 - well_known_imports: 0x0f13000007bd <FixedArray[0]>
 - memory0_start: 0xf18002f0000
 - memory0_size: 16777216
 - globals_start: 0x1012ffffffff
 - imported_mutable_globals: 0x0f1300000ef1 <ByteArray[0]>
 - jump_table_start: 0x2a02451dc000
 - data_segment_starts: 0x0f1300000ef1 <ByteArray[0]>
 - data_segment_sizes: 0x0f1300000ef1 <ByteArray[0]>
 - element_segments: 0x0f13000007bd <FixedArray[0]>
 - hook_on_function_call_address: 0x1c6c0003c609
 - tiering_budget_array: 0x1c6c023cf440
 - memory_bases_and_sizes: 0x0d790104ff4d <Other heap object (TRUSTED_BYTE_ARRAY_TYPE)>
 - break_on_entry: 0

The mentioned instruction can be observed at [8].

An AsmJS function can only return SMI (32-bit), float (32-bit) and double (64-bit) values. If the AsmJs function is defined to return a 32-bit SMI, it truncates the high 32-bits (the base address) from the return value; thus, the base address cannot be obtained. Defining the AsmJs function to return 64-bit double value will not work, because the return value is double value, whereas the pointer to the WasmTrustedInstanceData object has the representation of MachineRepresentation::kTaggedPointer.

When the ConstraintBuilder::AllocateFixed() method is invoked during the MeetRegisterConstraintsPhase phase, the MachineRepresentation::kTaggedPointerrepresentation does not match any of the expected representation. As such, unreachable code is reached, causing an abort. Therefore, we can only obtain the lower 32-bit value of the WasmTrustedInstanceData object, which is insufficient.

Upon returning from the AsmJS function defined to return a 32-bit SMI value, the high 32-bit of the RAX register is not cleared. As such, the caller can perform a right-shift operation to extract the high 32-bit from the register via nested calls, and return it to JavaScript land, leaking the trusted cage base.

Achieving Arbitrary Code Execution

By chaining the arbitrary read/write primitives and the leaked base address of the trusted cage, one can overwrite the jump table of the WebAssembly module with shellcode to achieve arbitrary code execution.

About Exodus Intelligence

Our world class team of vulnerability researchers discover hundreds of exclusive Zero-Day vulnerabilities, providing our clients with proprietary knowledge before the adversaries find them. We also conduct N-Day research, where we select critical N-Day vulnerabilities and complete research to prove whether these vulnerabilities are truly exploitable in the wild. Our researchers create and use in-house agentic AI tooling to supplement parts of their vulnerability research and exploit development workflow. In addition to efficiency gains, we’re able to ensure AI-enabled research output maintains the same standards of quality as traditional research.

For more information on our products and how we can help your vulnerability efforts, visit www.exodusintel.com or contact [email protected] for further discussion.


文章来源: https://blog.exodusintel.com/2026/06/22/out-of-shift-how-a-shared-state-bug-in-v8s-asmjs-parser-broke-the-ubercage/
如有侵权请联系:admin#unsafe.sh