# Root Cause Analysis: CVE-2026-23921

## Summary

CVE-2026-23921 is an authenticated blind SQL injection in the Zabbix JSON-RPC API. In Zabbix 7.0.21, legacy `*.get` methods preserve a caller-supplied `groupBy` option and `CApiService::applyQuerySortOptions()` uses that option as the allowlist for `sortfield`. Repeating an SQL expression in both fields makes validation approve attacker input, after which `applyQuerySortField()` places it into `ORDER BY`. This run confirmed the vulnerability through the real `/api_jsonrpc.php` endpoint as a newly created low-privileged User-role account. Matched predicates produced a repeatable timing oracle on 7.0.21, while 7.0.22 rejected the same requests.

## Impact

- **Affected component:** Zabbix JSON-RPC API query generation in `ui/include/classes/api/CApiService.php`, reached through legacy services such as `host.get`.
- **Affected versions:** 7.0.0–7.0.21, 7.2.0–7.2.14, and 7.4.0–7.4.5. Vendor-fixed releases are 7.0.22, 7.2.15, and 7.4.6.
- **Risk level:** High (vendor CVSS 4.0 score 8.7).
- **Consequences:** Any low-privileged account with API access can use true/false SQL predicates and database delay functions to infer arbitrary database values. The disclosed consequences include session identifier disclosure and subsequent administrator-account compromise.

## Impact Parity

- **Disclosed/claimed maximum impact:** Authenticated remote blind SQL injection capable of arbitrary database-data exfiltration.
- **Reproduced impact from this run:** Authenticated low-privilege remote SQL injection through `/api_jsonrpc.php`, demonstrated by three successful true predicates invoking `SLEEP(2)` and three false predicates returning immediately. Because one `host.get` query sorts two rows, each true probe delayed by approximately four seconds. The predicate operated on `version()`, demonstrating database-expression evaluation and the binary timing channel used for extraction.
- **Parity:** `full`.
- **Not demonstrated:** The run did not dump session identifiers or take over an administrator account; doing so is unnecessary once attacker-selected database predicates and the repeatable timing oracle are established.

## Root Cause

The vulnerable 7.0.21 implementation computes:

```php
$sort_columns = $group_by
    ? array_merge($options['groupBy'], $aggregate_sort_columns)
    : $this->sortColumns;
```

The JSON-RPC caller controls `options['groupBy']`. An attacker therefore sends a legitimate field and an SQL expression in `groupBy`, then repeats the expression in `sortfield`. `str_in_array()` accepts the sort value because the application itself copied that value into `sort_columns`. `applyQuerySortField()` subsequently concatenates the accepted string into the SQL `ORDER BY` clause. `countOutput: true` and the legitimate `hostid` group field keep the rest of the generated aggregate query valid.

Zabbix 7.0.22 instead derives the allowed fields from the server-owned schema list:

```php
$allowed_sort_fields = array_intersect($this->sortColumns, $options['groupBy']);
```

It also verifies `groupBy` is an array and each grouped field is a string. The first corrective commit in the release history is [`0dced774b07bb542885787d82ddbdfe26ea782ee`](https://github.com/zabbix/zabbix/commit/0dced774b07bb542885787d82ddbdfe26ea782ee); subsequent DEV-4649 commits refactored the released validation. The tested release identities were source tags `7.0.21` (`6542e21fe6adc8ab7bf903c2a9d1df80e80366cd`) and `7.0.22` (`584690ce104180ead8cd5d4d963a02b5e59bbeed`). The primary runtime targets were official immutable Zabbix image digests recorded in `runtime_manifest.json`.

## Reproduction Steps

1. Run `bundle/repro/reproduction_steps.sh` from any working directory. Docker, `curl`, `jq`, Python 3, `sha256sum`, and `timeout` must be available.
2. The script pulls immutable official image digests, starts isolated MySQL, Zabbix server, and Zabbix web/API containers for 7.0.21, and waits for a successful `apiinfo.version` JSON-RPC health check.
3. It logs in as bootstrap Admin only for setup, creates a normal User-role account with read-only access to the default host group, and confirms that account can perform a baseline `host.get`.
4. Through that low-privileged account, it interleaves three false and three true `version()` timing predicates in attacker-controlled `groupBy` and `sortfield` values sent to `/api_jsonrpc.php`.
5. It destroys the vulnerable stack and repeats the exact procedure against an isolated 7.0.22 stack as the fixed negative control.
6. It succeeds only if 7.0.21 has a median timing delta of at least 1.5 seconds and 7.0.22 either rejects every malicious request or has no timing oracle. It then writes `bundle/repro/runtime_manifest.json` with immutable artifact hashes.

Expected final output includes:

```text
"vulnerable_timing_oracle": true
"fixed_negative_control": true
"confirmed": true
CONFIRMED: CVE-2026-23921 blind SQL injection reached through the real Zabbix API endpoint.
```

## Evidence

- **Final machine verdict:** `bundle/repro/proof/verdict.json`
  - Vulnerable timing delta: `3.973936498980038` seconds.
  - Fixed timing delta: `-0.0018714029574766755` seconds.
  - `vulnerable_timing_oracle=true`, `fixed_rejected_payload=true`, `confirmed=true`.
- **Vulnerable endpoint transcript:** `bundle/repro/proof/vulnerable/timing_results.json`
  - False median: `0.04664472601143643` seconds.
  - True median: `4.020581224991474` seconds.
  - Every false and true request returned a valid JSON-RPC `result`, showing that the vulnerable service executed rather than rejected the values.
- **Fixed negative control:** `bundle/repro/proof/fixed/timing_results.json`
  - All six requests returned JSON-RPC error `-32500`: `Sorting by field ... not allowed.`
  - No sleep-dependent timing difference remained.
- **Attacker requests:** `bundle/repro/proof/vulnerable/false_probe_request.json` and `true_probe_request.json` contain the exact redacted request bodies. The session token is intentionally omitted.
- **Low-privilege precondition:** `bundle/repro/proof/vulnerable/low_user_identity.json` records `type: 1` and role ID `1`; `attempt_metadata.json` records read-only permission over host group ID `4`. `baseline_response.json` confirms the account could read a host.
- **Product and target identity:** `product_version.txt`, `loaded_component.txt`, and `container_identity.json` in each role directory bind the run to real Zabbix 7.0.21 and 7.0.22 web products and the loaded `CApiService.php`. OCI labels identify the vulnerable image as 7.0.21, and the runtime manifest binds it to digest `sha256:0282eba8bac999284672e3abaefbdd28b16026bae6a1673a7f560f35eb2c712d`.
- **Runtime logs:** `web_final.log` and `server_final.log` in each proof directory show the actual HTTP JSON-RPC requests and product startup. `bundle/logs/reproduction_steps.log` remains diagnostic and is deliberately not hash-bound while the script is writing it.
- **Runtime manifest:** `bundle/repro/runtime_manifest.json` has `entrypoint_kind=endpoint`, `service_started=true`, `healthcheck_passed=true`, `target_path_reached=true`, and hashes all 22 finalized proof artifacts.

## Recommendations / Next Steps

1. Upgrade to Zabbix 7.0.22, 7.2.15, 7.4.6, or a later supported release.
2. Never derive identifier allowlists from request values. Intersect requested fields with a server-owned allowlist and map accepted logical names to known SQL identifiers.
3. Enforce strict array/string schemas before all query-construction branches and reject undeclared options rather than preserving them through generic array merging.
4. Add regression tests for duplicate `groupBy`/`sortfield` expressions, scalar/object type confusion, aggregate aliases, and every legacy service sharing `CApiService`.
5. Until upgraded, restrict API access, revoke unnecessary API-capable accounts/tokens, and review web/database logs for `SLEEP`, `pg_sleep`, `CASE`, `IF`, `substring`, or unusual comma-containing sort fields.

## Additional Notes

- The final script revision completed successfully with immutable image digests. Two prior clean full runs also passed, with vulnerable median deltas of `3.9988` and `3.9967` seconds and fixed controls showing no timing oracle.
- Every stack uses a fresh MySQL database, private Docker network, unique container names, and teardown, so vulnerable and fixed observations cannot contaminate each other.
- Timing predicates are intentionally bounded to six probes per target and inspect only the first character of `version()`; the script does not extract sensitive data.
- Docker reported rootless operation without cgroups, but that did not affect service health, endpoint reachability, timing separation, isolation, or cleanup.
