I participate in Capture the Flag (CTF) events in a non-serious way in my free time. Unfortunately, I wasn’t able to play in the recent jeopardy-style CSI CTF–you can find more details about the event here–but I did get to do some interesting things with it after the fact.
After the CTF closed, I had a coworker ask me to check over his exploit for the pwn-intended-0x3 challenge to see what was going wrong and offer some pointers. As I was going through the provided exploit, it was so close to correct that I thought, “Surely he should have gotten the flag from trial and error. Maybe the challenge is actually harder than intended?”
To show you what I mean, in this blog post I will first provide a walk-through of the exploit and what turns out to be the intended solution, followed by showing you how I went beyond with arbitrary remote code execution (RCE).
This is an easy challenge, so you could just go straight to the disassembly of
main and know just about everything. However, it’s usually best to start with a high level perspective before you get excited and start running after false paths.
I use radare2; the
i command gives you all the good stuff you want to know early.
e cfg.fortunes.clippy = true to
~/.radare2rc so clippy will encourage you when you start your reversing journey.
So, the file is a Linux ELF with 64 bit x86 instructions. The
nx value tells us that the stack is non-executable. We can assume the system on which this is running has ASLR (added space layout randomization) but since
false, all the locations in the binary will be unchanged.
Let’s see what functions are used.
Life is good! We have a reference to the libc functions
system. Surely the vulnerability is in
gets and now we don’t need to leak the address of
Also, notice the
sym.flag function. There is no
imp in the name, so it is not an imported function; it is in the binary itself. Let’s disassemble that.
No need for the Ghidra decompiler; this is super simple. The
system function is called on the string “cat flag.txt”. If we get the instruction pointer anywhere near here, we should get the flag. We still haven’t found the vulnerability, but surely it will be in the main function.
I will spare you the disassembly of main, it is also trivial. Really it just calls
gets with a stack address. This is a basic stack overflow. We simply have to overwrite the return address at the bottom of the stack frame with
sym.flag and we should get the flag.
So, my buddy sent me his broken exploit. I checked it and everything seemed good. What? Why isn’t it working?
The exploit was in Python and already had the pwnlib imported. So I added a
gdb.attach call to get a debugger before the exploit was sent. I continued in gdb until I was at the last instruction in main. Then I checked what was on the stack using a command like
x/10gx $rsp-0x20. Turns out, my buddy missed his offset by 4 bytes.
Here is a working exploit.
Off by 4
I do not mention this mistake to insult my friend. Anyone who has ever attempted a binary exploitation challenge will know the pain of being off by a few bytes. Anyone who has attempted a second challenge will know the pain again. Even experts in this will continue to make this mistake. If you are new to binary exploitation, don’t let this discourage you. Keep at it–you have a long road of pain ahead. 🙂
I have another reason for mentioning this, though…
Going beyond via foolish assumptions
While the exploit works on my system, will it work on the real CTF? I don’t know; the CTF was over, so the challenge server was likely down.
That raises a question, though: what if my exploit failed?
Maybe my buddy did get the offset correct at one time, but started moving it around because the exploit failed on the real server. Could
sym.flag be a false path? If the server had the
flag.txt file in a different directory, then
cat flag.txt would fail. This challenge does appear very conducive to arbitrary code execution. Maybe there is even a secret extra flag or a pwn-intended-0x4 challenge where you need arbitrary code execution.
Turns out the answer to all these questions is no. However, since I did not know any better, I went ahead and got arbitrary remote code execution. Here is how I did it:
Arbitrary RCE exploit
The exploit above proves we can overwrite the return address with a call to system. Since
gets is lax about NULL bytes, we can send a nearly arbitrarily long ROP chain. All we need is a ROP chain that calls
system with “/bin/sh\x00” as the only parameter.
Getting control of the parameter is easy. Linux 64 bit executables use the
rdi register as the first parameter. Nearly every linux executable will have a
pop rdi ROP gadget as part of the so called BROP gadget. Radare2 can quickly find this location with the following command:
All we need now is an address to pop into
rdi that will point to the 8 byte string “/bin/sh\x00”. This string will occur in libc, but libc will be at a random offset due to ASRL.
I don’t want to have to construct a leak primitive, so let’s just shove the “/bin/sh\x00” string into a memory ourselves. This is easy because we have the
gets function, which only accepts one parameter. We already found the gadget to control the parameter, so we need a memory location in the binary that has read/write permissions (use
iS command in r2). I chose the space just after the stdout pointer.
The following exploit code is surprisingly straightforward and should be easy to follow. This will get arbitrary code execution on the challenge.
Challenges are back up!
Turns out the challenge servers are up after everything is said and done. I’d guess this is a kindness to people like me who are writing things up. This means I can actually check if my exploit works on the real server and if the flag.txt file is really where it should be!
Yup… My buddy was just off by 4.
There are two big takeaways here. First, offsets are hard. You have to be perfect and it’s really easy to commit off-by-one errors. There’s a lot to keep in mind. Forbidden bytes, endianness, and architecture size. Don’t be discouraged; hacking is all about failing until you get it right. Consider it all debugger practice. Personally, I consider the ability to debug and correct such mistakes to be of much higher value than the ability to get it right on the first try.
The other lesson here is about proper programming practices. The use of the system function was lazy. I am not criticizing the real developer here. The developers intention here was to design something vulnerable, and they did a great job.
Now, of course, if the file was compiled with pie and stack cookies, as is default nowadays, this exploit likely would have been impossible. Just avoiding the system call through would have made the exploit quite a bit more difficult. If the flag file was just opened and read to stdout, we would not know the location of the system function. We would have had to use multiple ROP gadgets to leak addresses in libc, all the while keeping a program state that won’t crash. Then, we would have to use the information we gained in a second stage ROP chain to execute our payload. Even little things like avoiding the system can go a long way toward frustrating attackers.
Hopefully, I showed how fun exploitation can be. It is like a puzzle, dividing the problems and then conquering them independently. Thanks for reading!