I found this one because I got bored waiting for a blind SQL injection to finish.
I was working through an HTB machine (still active, lips are sealed) that had a ZoneMinder instance. There was a SQLi path, time-based, excruciatingly slow. Since I’m very impatient, I grabbed the source and started reading.
I have a thing with PHP. My internal gatogrep goes looking for exec(), shell_exec(), system(), and passthru(). I’m not fancy, I just look for the thing that can do stuff for me and trace backwards. ZoneMinder had some, so I started tracing.
The first sink
web/includes/download_functions.php, line 119. Monitor name gets
yoinked from the db without so much as a htmlspecialchars() and goes straight into a filename variable:
$mergedFileName = $monitor->Name().' '.$minTime.' to '.$maxTime.'.mp4';
Twenty lines later that very unsanitary filename get passed into exec() as part of an ffmpeg command:
$cmd = ZM_PATH_FFMPEG.' -f concat -safe 0 -i event_files.txt -c copy \''.$export_dir.'/'.$mergedFileName. '\' 2>&1';
exec($cmd, $output, $return);
what do we have here?
Manual quote wrapping instead of escapeshellarg(). Say it ain’t so!
A monitor name with a single quote in it should break out of the quoting entirely. So I tried it. Named a monitor gato_was_here'; touch /tmp/gato; echo ' and triggered an event export.
The ffmpeg call at line 126 became:
ffmpeg ... '/exports/gato_was_here'; touch /tmp/gato; echo ' <time> to <time>.mp4' 2>&1
Three commands:
ffmpegfails…so sad (no input files)touch /tmp/gatosucceeds happy danceechocleans up the place
The non-zero ffmpeg exit gets logged and ignored. File on disk. Code execution achieved. All from a monitor name.
But wait, there’s more!
About 20 lines further down, the same unsanitized $mergedFileName gets appended to the archive command:
if ($command) {
$command .= ' \''.$mergedFileName.'\'';
if (executeShelCommand($command, $deleteFile = $mergedFileName) === false) return false;
}
Which goes to:
function executeShelCommand($command, $deleteFile = '') {
if (!$command) return false;
exec($command, $output, $status);
One source. Two exec() sinks. Both reachable from the same unsanitary monitor name, whether you’re exporting as zip or tar.
The gift that keeps on giving
This isn’t just “privileged user can run commands on a server they probably already have access to.” A few things worth noting:
First, this doesn’t require an administrator account. Any user with Monitors=Create permissions can do it. ZoneMinder has multiple privilege levels and those rights can be granted to non-admin users.
Second, the user who carefully chooses the monitor name and the user who triggers the export don’t even have to be the same person. One account creates a monitor with the malicious name and any other user who exports events from that monitor pulls the trigger. The injecting user can sit completely idle while someone else sets off the payload.
Third, the trigger has an even lower privilege requirement than the setup. The event download functions sit at an authorization level that ZoneMinder apparently decided wasn’t sensitive enough to restrict tightly. The setup requires Monitors=Create, but the trigger just needs a valid user who has Events=View. They don’t even need the web UI because API access is on by default.
Fourth, and this is where my impatience got the better of me again: you don’t actually need to create the event record yourself. ZoneMinder is a camera system. It creates event records constantly 🤣 motion detection, scheduled recordings, whatever your cameras are doing. In the PoC the event gets created manually for speed, but in a real deployment you just create your not maliciously named monitor and wait. The system arms the trap for you. The real privilege requirement for the full attack chain is just Monitors=Create and a beer. In a deployment with active cameras, the wait probably isn’t long.
This has happened before
ZoneMinder has a documented history of command injection via similar paths, several of them also involving ffmpeg commands. This isn’t a novel vulnerability class for this codebase, it’s the same pattern showing up in a code path that didn’t get the same attention as the ones that were previously patched. When you see a history like that it’s worth asking how thoroughly the fix was applied across the whole codebase, not just the specific line that got reported. And that’s what I did.
Attack flow
- Authenticate with an account that has
Monitors=Create - Create a monitor named
poc'; <command>; echo ' - Wait. ZoneMinder creates event records automatically as the camera system runs. Optionally create one manually with
DefaultVideoset to skip a divide-by-zero error inGenerateVideo()whenLength=0, but in a live deployment this step takes care of itself. - A user with
Events=Viewpermissions and a valid session triggers export:GET /index.php?request=event&action=download&eids[]=<id>&exportFormat=zip downloadEvents()builds$mergedFileNamefrom the not maliciously named monitor -> passes it without showering intoexec()at lines 126 and 150- Commands execute as
www-dataor whoever is running the ZoneMinder process.
CVSS
Score: 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) because it’s remotely exploitable with less than administrator privileges.
Either way, escapeshellarg() costs nothing.
and that’s the fix
Both injection points need escapeshellarg() instead of manual quoting:
// Line 126 — ffmpeg command
$cmd = ZM_PATH_FFMPEG.' -f concat -safe 0 -i event_files.txt -c copy '.escapeshellarg($export_dir.'/'.$mergedFileName).' 2>&1';
// Line 150 — archive command
$command .= ' '.escapeshellarg($mergedFileName);
Lines 116 and 211 in generateFileList() have the same pattern and should get the same treatment while you’re in there.
Timeline
- 03/08/2026: Reported via ZoneMinder’s security contact email
- 03/09/2026: Patched in commit
b3a7c05 - 06/08/2026: Public disclosure
😢 Never did get a response from the devs
Discovered by @investigato.