This weekend, I played in Zer0pts CTF with my team Crusaders of Rust (aka Richard Stallman and Rust during the competition). Perhaps the most interesting task my friend c3bacd17 and I solved was nasm-kit, which required us to escape an x86_64 emulator written with the unicorn engine.
To start off, we were given source:
Looking over the code, it basically emulates the x86 instructions you send in. You only have 0x1000 bytes at most, and there is a code and a stack region setup by unicorn. There are a few handlers for segfaults and interrupts (which both terminate the program), a special syscall handler, and registers are initialized to null in the beginning. It also prints out your register state when the emulated process crashes (great for debugging!), and only allows mmap, munmap, write, exit, read, and exit_group in the syscall handler. Notice that the return values for all these syscalls are made non-standard by the handler. There's not really any vulnerable code here (I briefly considered that the mismatching new and delete operators in C++ could create issues, but I don't think it could lead to much here).
Below is the server side handler for the process.
Note that we have to send in code for NASM only, and it attaches the lines necessary for 64 bit assembly. Your NASM code length must be under 0x1000, and incbin as well as macros are disabled. The filename for this is also randomized.
So, where exactly is the bug? Well, back from *CTF 2021, there was a riscv64 QEMU userland "escape" challenge (Favorite Architecture 2) I did, and I remember that the emulated process could access the emulator memory mappings; of course, unicorn engine was more strict, but a similar idea does work. Since we do have mmap, this is probably what we can abuse.
Looking through the mmap manpages, a few flags caught my attention. MAP_FIXED allows me replace pre-existing memory pages, and MAP_FIXED_NOREPLACE is the same thing as MAP_FIXED, but won't replace your memory and just return an error if a collision happens (the fixed part means that you want mmap to take your requested address literally rather than as a hint).
Using these two concepts, we can easily find the emulator mapping processes even under ASLR. For example, to get ELF base with PIE, we can perform a search from 0x550000000000 with MAP_FIXED_NOREPLACE, using smaller length requests as you go from leftwards digits to rightwards digits and checking for collisions. One can also write a binary search, but I was working on this challenge at 2 AM and was simply too tired to bother doing so.
Then, once you have some mapping addresses from the emulator, you can now abuse MAP_FIXED to forcibly replace emulator pages. However, since we do not have open or openat to make a new fd, we have to use MAP_ANONYMOUS, which will null out the pages. c3bacd17 and I originally considered writing the shellcode for shell elsewhere in the nasm file, and then use our file fd with offset option to control the new memory there immediately, but the file was closed as soon as the code was read. Another potential way is to find some section of code that isn't usually used during emulation, mmap over that region, write the arb shellcode there, and then hope to have it trigger later on without breaking the process. This is what we did, but it took some bruteforcing and fiddling.
In my exploit, rather than searching for ELF base (as I could not find a good page to perform the aforementioned attack in the binary itself), I searched for libc. I began my search for 0x7f0000000000, and hunted for the third page mapped in that region, as that became a constant offset to libc base due to the behavior of mmap during program initialization. The offset does vary across systems so some bruteforce is necessary when going from local to remote. The first two pages in that range were not a constant offset from libc base; I believe those are used by the unicorn-engine but am not completely sure.
In libc itself, I chose one of the earlier 0x1000 pages of the .text segment, mmap'd over it, and filled the entire region with nops before ending it with an instruction to jmp to my shellcode (which I mmap'd at 0x13370000). This nop sled should really help with increasing reliability, and upon ending the emulated process in my shellcode, a shell did pop!
Here is my exploit:
Of course for remote, you will need to encode this for NASM format. c3bacd17 wrote the following script to just encode the raw binary as dqwords to send to the server.
The script generated the following as our final payload:
And after a few tries since my mmap search was slightly slow, I ended up popping a shell!
Thanks to ptr-yudai for this wonderful challenge, and the rest of the zer0pts CTF team for this amazing CTF!