{"repro_id":"REPRO-2026-00360","version":6,"title":"Blind SQL injection in Zabbix API `CApiService.php` via the `sortfield` parameter allows low-privileged API users to exfiltrate database data and potentially compromise administrator accounts.","repro_type":"security","status":"published","severity":"high","cvss_score":8.7,"description":"Authenticated time-based blind SQL injection through the JSON-RPC API sortfield parameter (CWE-89).","root_cause":"# Root Cause Analysis: CVE-2026-23921\n\n## Summary\n\nCVE-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.\n\n## Impact\n\n- **Affected component:** Zabbix JSON-RPC API query generation in `ui/include/classes/api/CApiService.php`, reached through legacy services such as `host.get`.\n- **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.\n- **Risk level:** High (vendor CVSS 4.0 score 8.7).\n- **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.\n\n## Impact Parity\n\n- **Disclosed/claimed maximum impact:** Authenticated remote blind SQL injection capable of arbitrary database-data exfiltration.\n- **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.\n- **Parity:** `full`.\n- **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.\n\n## Root Cause\n\nThe vulnerable 7.0.21 implementation computes:\n\n```php\n$sort_columns = $group_by\n    ? array_merge($options['groupBy'], $aggregate_sort_columns)\n    : $this->sortColumns;\n```\n\nThe 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.\n\nZabbix 7.0.22 instead derives the allowed fields from the server-owned schema list:\n\n```php\n$allowed_sort_fields = array_intersect($this->sortColumns, $options['groupBy']);\n```\n\nIt 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`.\n\n## Reproduction Steps\n\n1. Run `bundle/repro/reproduction_steps.sh` from any working directory. Docker, `curl`, `jq`, Python 3, `sha256sum`, and `timeout` must be available.\n2. 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.\n3. 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`.\n4. 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`.\n5. It destroys the vulnerable stack and repeats the exact procedure against an isolated 7.0.22 stack as the fixed negative control.\n6. 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.\n\nExpected final output includes:\n\n```text\n\"vulnerable_timing_oracle\": true\n\"fixed_negative_control\": true\n\"confirmed\": true\nCONFIRMED: CVE-2026-23921 blind SQL injection reached through the real Zabbix API endpoint.\n```\n\n## Evidence\n\n- **Final machine verdict:** `bundle/repro/proof/verdict.json`\n  - Vulnerable timing delta: `3.973936498980038` seconds.\n  - Fixed timing delta: `-0.0018714029574766755` seconds.\n  - `vulnerable_timing_oracle=true`, `fixed_rejected_payload=true`, `confirmed=true`.\n- **Vulnerable endpoint transcript:** `bundle/repro/proof/vulnerable/timing_results.json`\n  - False median: `0.04664472601143643` seconds.\n  - True median: `4.020581224991474` seconds.\n  - Every false and true request returned a valid JSON-RPC `result`, showing that the vulnerable service executed rather than rejected the values.\n- **Fixed negative control:** `bundle/repro/proof/fixed/timing_results.json`\n  - All six requests returned JSON-RPC error `-32500`: `Sorting by field ... not allowed.`\n  - No sleep-dependent timing difference remained.\n- **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.\n- **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.\n- **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`.\n- **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.\n- **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.\n\n## Recommendations / Next Steps\n\n1. Upgrade to Zabbix 7.0.22, 7.2.15, 7.4.6, or a later supported release.\n2. Never derive identifier allowlists from request values. Intersect requested fields with a server-owned allowlist and map accepted logical names to known SQL identifiers.\n3. Enforce strict array/string schemas before all query-construction branches and reject undeclared options rather than preserving them through generic array merging.\n4. Add regression tests for duplicate `groupBy`/`sortfield` expressions, scalar/object type confusion, aggregate aliases, and every legacy service sharing `CApiService`.\n5. 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.\n\n## Additional Notes\n\n- 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.\n- Every stack uses a fresh MySQL database, private Docker network, unique container names, and teardown, so vulnerable and fixed observations cannot contaminate each other.\n- 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.\n- Docker reported rootless operation without cgroups, but that did not affect service health, endpoint reachability, timing separation, isolation, or cleanup.\n","cve_id":"CVE-2026-23921","cwe_id":"CWE-89","source_url":"https://nvd.nist.gov/vuln/detail/CVE-2026-23921","package":{"name":"zabbix/zabbix","ecosystem":"unknown","affected_versions":"7.0.0 through 7.0.21, 7.2.0 through 7.2.14, and 7.4.0 through 7.4.5.","fixed_version":"Unknown"},"reproduced_at":"2026-09-24T07:21:05.438928+00:00","duration_secs":2306.0,"tool_calls":265,"handoffs":2,"total_cost_usd":6.819488,"agent_costs":{"claim_matcher":0.0189,"judge":0.589911,"learning_policy":0.014551,"repro":3.045364,"support":0.241704,"vuln_variant":2.909058},"cost_breakdown":{"claim_matcher":{"gpt-5.4-mini-2026-03-17":0.0189},"judge":{"gpt-5.6-sol":0.589911},"learning_policy":{"gpt-5.4-mini-2026-03-17":0.014551},"repro":{"gpt-5.6-sol":3.045364},"support":{"gpt-5.6-sol":0.241704},"vuln_variant":{"gpt-5.6-sol":2.909058}},"vulnerable_version_variant_outcome":"unknown","fix_bypass_outcome":"unknown","variant_disclosure_state":"unknown","quality":{"confidence":"high","idempotent_verified":false,"community_verifications":0},"evidence":{"workflow":{"profile":"known_vulnerability","schema_version":2,"stages":["support","claim_contract","repro","judge","vuln_variant"]}},"environment":{"sandbox_image":"ghcr.io/n3mes1s/pruva-sandbox@sha256:8096b2518d6022e13d68f885c3b8ded6b4fe607098b1a1ccbfb99abc004d1dc1"},"published_at":"2026-09-24T07:21:06.343182+00:00","retracted":false,"artifacts":[{"path":"bundle/repro/rca_report.md","filename":"rca_report.md","size":8771,"category":"analysis"},{"path":"bundle/repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":17367,"category":"reproduction_script"},{"path":"bundle/logs/reproduction_steps.log","filename":"reproduction_steps.log","size":1711,"category":"log"},{"path":"bundle/repro/proof/fixed/attempt_metadata.json","filename":"attempt_metadata.json","size":194,"category":"other"},{"path":"bundle/repro/proof/fixed/container_identity.json","filename":"container_identity.json","size":3479,"category":"other"},{"path":"bundle/repro/proof/fixed/false_probe_request.json","filename":"false_probe_request.json","size":308,"category":"other"},{"path":"bundle/repro/proof/fixed/loaded_component.txt","filename":"loaded_component.txt","size":174,"category":"other"},{"path":"bundle/repro/proof/fixed/low_user_identity.json","filename":"low_user_identity.json","size":136,"category":"other"},{"path":"bundle/repro/proof/fixed/product_version.txt","filename":"product_version.txt","size":7,"category":"other"},{"path":"bundle/repro/proof/fixed/server_final.log","filename":"server_final.log","size":16482,"category":"log"},{"path":"bundle/repro/proof/fixed/timing_results.json","filename":"timing_results.json","size":2265,"category":"other"},{"path":"bundle/repro/proof/fixed/true_probe_request.json","filename":"true_probe_request.json","size":304,"category":"other"},{"path":"bundle/repro/proof/fixed/web_final.log","filename":"web_final.log","size":7964,"category":"log"},{"path":"bundle/repro/proof/verdict.json","filename":"verdict.json","size":263,"category":"other"},{"path":"bundle/repro/proof/vulnerable/attempt_metadata.json","filename":"attempt_metadata.json","size":199,"category":"other"},{"path":"bundle/repro/proof/vulnerable/baseline_response.json","filename":"baseline_response.json","size":55,"category":"other"},{"path":"bundle/repro/proof/vulnerable/container_identity.json","filename":"container_identity.json","size":3494,"category":"other"},{"path":"bundle/repro/proof/vulnerable/false_probe_request.json","filename":"false_probe_request.json","size":308,"category":"other"},{"path":"bundle/repro/proof/vulnerable/loaded_component.txt","filename":"loaded_component.txt","size":174,"category":"other"},{"path":"bundle/repro/proof/vulnerable/low_user_identity.json","filename":"low_user_identity.json","size":141,"category":"other"},{"path":"bundle/repro/proof/vulnerable/product_version.txt","filename":"product_version.txt","size":7,"category":"other"},{"path":"bundle/repro/proof/vulnerable/server_final.log","filename":"server_final.log","size":17189,"category":"log"},{"path":"bundle/repro/proof/vulnerable/timing_results.json","filename":"timing_results.json","size":1109,"category":"other"},{"path":"bundle/repro/proof/vulnerable/true_probe_request.json","filename":"true_probe_request.json","size":304,"category":"other"},{"path":"bundle/repro/proof/vulnerable/web_final.log","filename":"web_final.log","size":7733,"category":"log"},{"path":"bundle/repro/runtime_manifest.json","filename":"runtime_manifest.json","size":4694,"category":"other"},{"path":"bundle/repro/validation_verdict.json","filename":"validation_verdict.json","size":1196,"category":"other"}]}