This is my solution for the echo pwn challenge from Google Quals CTF 2020. Since this challenge doesn't involve an unusual vulnerability/exploit, I won't do a full writeup on it, but I will drop the high level plan and exploit for fun:) I'll skip the introduction to the challenge, its interface and how it works - all of that is straightforward, please check out the challenge itself:)
As it turns out, the solution I have shares many similar concepts with the great solution published by RedSocket (check out their awesome writeup!). For example, filling the holes created by a huge allocation of std::string by allocating sizes of decreasing powers of 2 is a classic trick we both use. However, the approach I took triggered the vulnerability twice instead of three times. Generally speaking I tend to prefer to trigger bugs as few times as possible in my exploits - in some cases this results in dramatic increase in the exploit’s stability. Specifically here we are talking about a CTF challenge with a very stable bug to exploit, so it doesn’t matter much. Anyways, it's all about the cool tricks and concepts we can do and learn! :)
This is another great opportunity to thank all of the Google CTF authors for an amazing CTF and great time, as in every year :) Keep up the fantastic work!
The challenge runs on Ubuntu 18.04, however, it uses libc-2.31 (which means tcache has some hardening, for instance the classic double-free trick doesn't hold, but the arbitrary write and the rest are still valid). For fun, I wrote two exploits -- for libc-2.31 (the actual CTF challenge) and for libc-2.28 (with the famous double-free issue). The flag is retrieved of course by the 2.31 exploit:
The challenge exposes a very straightforward vulnerability of incorrect use of iterators in C++. In the main loop, where the challenge iterates over the clients std::vector, it doesn't move the iterator one place back after calling erase():
for (auto it = clients.begin(), end = clients.end(); it != end; ++it) {
ClientCtx& client = *it;
const int fd = client.fd;
if (FD_ISSET(fd, &readset)) {
if (!handle_read(client)) {
close(fd);
it = clients.erase(it);
continue;
}
} else if (FD_ISSET(fd, &writeset)) {
if (!handle_write(client)) {
close(fd);
it = clients.erase(it);
continue;
}
}
}
This of course creates a super exploitable scenario, as the implementation of std::vector::erase() is to swap the removed element and propagate it to the end of the vector, which is still inbounds of the std::vector's heap buffer allocation, but outside of the logic bounds determined by count (for ref, see this). There are many things we can do from here, I chose to do a UAF on the std::string's buffer. POC:
- create connections s1, s2, s3 (trigger allocation of std::vector of size 4 elements, size 0x130)
- send short strings to s1, s2
- close s1
- close s2
- send data to s3 - write to a freed std::string buffer
In libc-2.28, tcache had 0 integrity/security checks whatsoever. In libc-2.31, the double-free issue was mitigated, however, the arbitrary write works just like before.
There are many roads to take here. I used here the classic exploitation primitives provided by tcache/dlmalloc:
- When we write a pointer to a freed chunk, we corrupt the tcache header, which contains absolute pointer of the next allocation, hence gain arbitrary write
- Only libc-2.28: When we double free chunk, instead of abort, tcache will gladly return it twice upon 2 calls to malloc()
- When we free chunk from the unsorted-bins, we have in it's first bytes absolute pointers to the main arena in libc
- When a small chunk is freed, the FD/BK absolute pointers are being set accordingly with a freed chunk
So here I do the following classic exploit:
- leak an heap address
- use trivial shape to locate an unsorted chunk, free it
- gain read primitive, leak libc address
- gain arbitrary write to corrupt *(__free_hook) = system_addr
The exploit is pretty simple. I trigger the vulnerability only twice, in order to:
- Leak libc
- Arbitrary write *(__free_hook)=system
- PROFIT
Again, there are many roads to take here. I chose to do
- Create a number of connections, the first one (called reader) with reader->rd_buf of size 0x10000. This is because I'm about to corrupt the LSB of a heap pointer in tcache header with \x00\x00, and I want to make the next-next allocation to be allocated inside this rd_buf
- Fill holes accordingly (due to std::string reallocations)
- Trigger vulnerability - write to a 0x40-freed chunk -- corrupt 2 LSB bytes of heap address
- allocate s1, s2, with small buffers. S2->rd_buf is allocated inside reader->rd_buf
- send \n to reader, read reader->rd_buf, resolve offset of s2 inside it
- Take advantage of the current memory layout for generic corruption into entire s2 allocation, by simply writing to reader. No need to trigger the vulnerability again for that.
- Corrupt heap header, make it 0x600 size (unsorted)
- free s2, create pointer to libc inside reader->rd_buf
- send \n to reader, read reader->rd_buf, break libc base address
- create connections s1, s2, s3
- send short strings to s1, s2, s3
- Trigger vulnerability - close s1, close s2, send only 8 bytes to s3, write a pointer to the freed std::string buffer of s2, gain arbitrary write
- *corrupt (__free_hook) = system_addr
- PROFIT
Here I decided to trigger the bug 3 times, just to do something cool with double-frees.
- create connections s1, s2, s3
- send short strings to s1, s2
- Trigger vulnerability - close s1, close s2, send long string to s3 --> double free s2->wr_buf
- create s4 connection, send to it long string without "\n", reclaim s2->wr_buf allocation as s4->rd_buf
- create s5 connection, send to it long string without "\n", reclaim s2->wr_buf allocation as s5->rd_buf
- close s5, free s5->rd_buf, create heap address inside s4->rd_buf
- send s4 "\n", trigger read of heap address
- create connection, send a very long string to it, close it. Created a freed unsorted bins chunk
- create connections s1, s2, s3
- send short strings to s1, s2
- Trigger vulnerability - close s1, close s2, send long string to s3 --> double free s2->wr_buf
- create s4, send a long string to it --> reclaim s2->wr_buf, make s4->wr_buf=s2->wr_buf
- create more connections, trigger reallocation of client std::vector's buffer, reclaim s2->wr_buf
- at this point, we have a std::string's buffer collided with std::vector
- corrupt s4->rd_buf with the freed unsorted chunk, contains the address of the main arena
- send s4 "\n", leak libc address
- create connections s1, s2, s3
- send short strings to s1, s2
- Trigger vulnerability - close s1, close s2, send only 8 bytes to s3, write a pointer to the freed std::string buffer of s2, gain arbitrary write
- *corrupt (__free_hook) = system_addr
- PROFIT
Local POC (Ubuntu 18.04, libc-2.28):