Skip to content
S scriptkittens
vulnerability-research php command-injection zoneminder rce

Two Sinks, One Shell: OS Command Injection in ZoneMinder

ZoneMinder's event export concatenates monitor names directly into shell commands. One unsanitized source, two exec() sinks, and a payload that someone else can trigger for you.

I

investigato

2 min read

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);
A screenshot of a man holding a magnifying glass.

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:

  1. ffmpeg fails…so sad (no input files)
  2. touch /tmp/gato succeeds happy dance
  3. echo cleans 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

  1. Authenticate with an account that has Monitors=Create
  2. Create a monitor named poc'; <command>; echo '
  3. Wait. ZoneMinder creates event records automatically as the camera system runs. Optionally create one manually with DefaultVideo set to skip a divide-by-zero error in GenerateVideo() when Length=0, but in a live deployment this step takes care of itself.
  4. A user with Events=View permissions and a valid session triggers export: GET /index.php?request=event&action=download&eids[]=<id>&exportFormat=zip
  5. downloadEvents() builds $mergedFileName from the not maliciously named monitor -> passes it without showering into exec() at lines 126 and 150
  6. Commands execute as www-data or 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.

Share:

Follow along

Stay in the loop — new articles, thoughts, and updates.