The Great Escape pt3 was a pwnable challenge that I helped my teammate uafio solve during Insomni’hack Teaser CTF 2017. I thought this was a very interesting challenge because the binary is linked with libjemalloc, which uses jemalloc, the same memory allocator that Firefox uses for its heap management.
Most CTF heap exploitation binaries require you to pwn the ptmalloc2 memory allocator by taking advantage of the way ptmalloc2 frees and allocates heap chunks.
This was the first time I’d seen a challenge use jemalloc, so exploiting it was a great learning experience for me.
After the contest was over, I decided to redo the challenge alone from scratch and do a writeup to solidify my understanding of the concepts I learned.
This post is a result of that effort.
The program first allocates 0x8 bytes to store a pointer to an encryption function.
It also allocates 0x1d8 bytes for a struct which will hold user a user’s information.
The struct looks something like the following:
The program asks us to input our name, current location, goal and any last words that we may have before quitting.
It also asks us to specify an encryption algorithm that it uses to XOR input and output:
encrypt0 does nothing
encrypt1 XORs each char by 0x41
encrypt2 XORs each char by 0x78
A pointer to whatever encryption function the user selects is stored in the previously allocated 0x8 bytes of memory.
The name, current location, and goal we provide are stored in the user struct we previously allocated a chunk of size 0x1d8 for.
To be accurate, we will call these heap chunks, “regions”, for the remainder of this post, as that is what jemalloc calls dlmalloc chunks minus their metadata.
“Chunks” in jemalloc refer to something entirely different but we will not discuss this concept in this post, as it is not necessary to solve this challenge.
Another region is malloc()‘d for the user’s location. A pointer to this heap region is then placed at an offset of 0x1d0 from the beginning of the beginning of the user’s region.
Later the program reads in the user’s goal, which is subsequently memcpy()‘d to an offset of 0x104 from the beginning of the user’s region.
After this, the encrypted data is printed back out to our socketfd.
If we set our goal to a length of 0xcc or 204 bytes, we can leak the pointer to the heap region that was used to store the user’s location, as well!
In memory, this should look something like the following after the memcpy() finishes.
After doing this, we are able to get our first leak when our goal region is printed out.
From here, we can actually exploit jemalloc and introduce a use-after-free condition to get control of RIP.
In jemalloc, same-sized regions are placed contiguous to each other, without any metadata information separating them.
For example, 3 malloc()‘d regions of size 0x10 would look like this in memory (taken from Phrack):
With region 1 @ 0xb7003030, region 2 @ 0xb7003040 and region 3 @ 0xb7003050
Therefore, if we set our goal to be a string of size 0x8, we can guarantee that its corresponding heap region will be allocated immediately after the heap region for the encryption_method pointer, which is also malloc()‘d with a size of 0x8!
Furthermore, if we overwrite the pointer to the location heap region with a pointer to the encryption_method heap region, we will force the latter to get freed()‘d.
We can further abuse this later if we set our last_words to be 0x8 bytes, as the program will reallocate the old encryption_method heap region that was just free()‘d, and fill it with data that we control, introducing a subtle use-after-free condition when the program later calls whatever pointer address is stored in the old encryption_method heap region!
Essentially, this is what our user heap region looks like before we overwrite the pointer to the location heap region.
Notice the original pointer is still preserved and that our location heap region borders the encryption_method heap region since they are both malloc()‘d with size 0x8.
Now, after we overwrite the pointer to the location region with a pointer to the encryption_method region, free the encryption_method region, and reallocate the old encryption_method heap region with data that we can control, this is what all our relevant regions will look like.
Because we have now corrupted the encryption0 function pointer, we are able to control RIP the next time the encryption0 function pointer is called!
Stage 1 ROP: Libc Leak
Notice how the RDI register points to the beginning of our user region, where our user’s name resides.
If we set a ROP chain as our name, we can return to a xchg rsp, rdi ; ret gadget to perform a stack pivot and begin moving down our ROP chain to execute arbitrary code.
We can find such a stack pivot gadget in the ELF executable and use it reliably since PIE is not enabled.
Now, for the ROP chain, we need to address a few issues.
First, because our binary is not statically compiled, we don’t have many gadgets to work with from within the ELF executable, which may prove problematic later, depending on what we want our ROP chain to do.
But more importantly, we will need to dynamically calculate the addresses of libc functions to be able to return into them in our ROP chain and do anything meaningful.
Therefore, we need to leak libc somehow.
We can do this if we set our name to be a short ROP chain to leak libc.
In my actual exploit, I couldn’t find the right gadgets within the ELF executable to also control the values in RDX and RCX, which are the 3rd and 4th arguments passed into send(), respectively, but it didn’t matter, as the existing values in those registers were good enough to still be able to call send() successfully.
Once we leaked the address of atoi@libc, we can check to see which libc the server is using.
Now that we’ve found the correct libc, we can generate gadgets from it to craft a more useful ROP chain.
Stage 2 ROP: Dup2() Trick
Unfortunately we can’t just ROP to system("/bin/sh\0"); for this particular challenge because the shell will execute on the host and interact with the stdin and stdout file descriptors, when we can only interact with the socketfd file descriptor.
Therefore, we came up with 2 approaches to bypass this issue. We can either do it the hard way, which involves calling fopen() to read the flag file to a filestream, calling read() to read the contents of the file to a buffer, and finally calling send() to send the contents of the buffer to our socketfd file descriptor, OR we can do it the easy way which simply involves doing a dup2() trick.
The dup2() trick basically involves overwriting file descriptors 0 and 1, or stdin and stdout, with file descriptor 4, which is the socket we are interacting with.
Doing this should redirect all stdin and stdout to our socketfd, giving us an interactive shell.
The following is an example of what the FD’s may look like before the dup2() trick.
And this is what we want it to look like afterward:
The harder way is very straightfoward, but one caveat is, we need a place to write our "flag" and "r" strings to so that we can pass pointers to them into fopen(). In my exploit I just wrote the strings to the .data section, since it is has enough slack space and is writeable. In the solution I’ve included at the end of this post, I’ve commented this ROP chain out, as I believe the dup2() trick is a much easier and more elegant solution.
Putting everything together, we are able to get a shell using the following exploit.
Special shout out to uafio and grazfather, for constantly pushing me to improve my pwning skills and without whom I couldn’t have solved this challenge.