-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 NEFARIOUSPLAN-CANONICAL-V1 {"body_md":"## The audit-writer can read every table\n\nZabbix Server runs a trapper on TCP 10051. One of the requests that trapper accepts is `command`: an authenticated user submits a JSON body asking the server to execute a global script against a host they have access to. When the script finishes, the server opens a database transaction and inserts a row into the `auditlog` table. The row is the system's record of \"user X executed script Y from address A at time T.\" A SOC analyst reads it during an incident. A compliance audit asks for it once a quarter.\n\nThe connection that performs the INSERT is owned by the `zabbix` database user. `zabbix` has `SELECT` on every table in the application schema. It has `SELECT` on `sessions` (every active session token, including the administrator's). It has `SELECT` on `config` (the application's HMAC signing key for forging session cookies). The audit log's privilege profile is broad because audit logs are broad: anything the system audits, the audit-writer must reach. Zabbix LLC sold this architecture as defensive. The system records what happened.\n\nIn versions 6.0.0 through 6.0.27, 6.4.0 through 6.4.12, and 7.0.0alpha1, the row that the system records is built with a `printf`-style format string. One of the columns is filled in from JSON the request submitted. That column is `ip`. The field is `clientip`. The value is whatever the request says.\n\n## The function and the format string\n\n`zbx_auditlog_global_script` lives in `src/libs/zbxaudit/audit.c`. Pre-patch, the relevant body is a single `DBexecute` call with eleven format specifiers and eleven arguments:\n\n```c\ndetails_esc = DBdyn_escape_string(details_json.buffer);\n\nif (ZBX_DB_OK > DBexecute(\n \"insert into auditlog (auditid,userid,username,clock,action,ip,resourceid,\"\n \"resourcename,resourcetype,recordsetid,details) values \"\n \"('%s',\" ZBX_FS_UI64 \",'%s',%d,'%d','%s',\" ZBX_FS_UI64 \",'%s',%d,'%s','%s')\",\n auditid_cuid, userid, username, (int)time(NULL), AUDIT_ACTION_EXECUTE,\n clientip, hostid, hostname, AUDIT_RESOURCE_SCRIPT, auditid_cuid, details_esc))\n{\n ret = FAIL;\n}\n\nzbx_free(details_esc);\n```\n\nThe eleven columns receive their inputs in order. The `'%s'` placeholders at positions 1, 3, 8, and 10 receive `auditid_cuid`, `username`, `hostname`, and `auditid_cuid` again. Those are server-side strings, generated locally or looked up from the database by user ID and host ID. Position 11, `'%s'`, is `details_esc`, which the function explicitly escapes via `DBdyn_escape_string` two lines above the call.\n\nPosition 6, `'%s'`, is `clientip`. It is not escaped. It is the only string in this INSERT that came from the client's JSON body and is interpolated into the query without sanitization. Maxim Tyukov reported the gap to Zabbix on 2024 Feb 21 through HackerOne. The fix landed on March 18 and shipped publicly in May 2024 as 6.0.28rc1, 6.4.13rc1, and 7.0.0beta2.\n\nThe patch replaces the format string with a parameterized prepared insert:\n\n```diff\n- char *details_esc;\n+ zbx_db_insert_t db_insert;\n ...\n- details_esc = DBdyn_escape_string(details_json.buffer);\n-\n- if (ZBX_DB_OK > DBexecute(\"insert into auditlog (\"\n- \"auditid,userid,username,clock,action,ip,resourceid,\"\n- \"resourcename,resourcetype,recordsetid,details) values \"\n- \"('%s',\" ZBX_FS_UI64 \",'%s',%d,'%d','%s',\" ZBX_FS_UI64\n- \",'%s',%d,'%s','%s')\",\n- auditid_cuid, userid, username, (int)time(NULL),\n- AUDIT_ACTION_EXECUTE, clientip, hostid, hostname,\n- AUDIT_RESOURCE_SCRIPT, auditid_cuid, details_esc))\n- {\n- ret = FAIL;\n- }\n-\n- zbx_free(details_esc);\n+ zbx_db_insert_prepare(&db_insert, \"auditlog\",\n+ \"auditid\", \"userid\", \"username\", \"clock\", \"action\", \"ip\",\n+ \"resourceid\", \"resourcename\", \"resourcetype\", \"recordsetid\",\n+ \"details\", NULL);\n+\n+ zbx_db_insert_add_values(&db_insert, auditid_cuid, userid, username,\n+ (int)time(NULL), ZBX_AUDIT_ACTION_EXECUTE, clientip, hostid,\n+ hostname, ZBX_AUDIT_RESOURCE_SCRIPT, auditid_cuid,\n+ details_json.buffer);\n+\n+ ret = zbx_db_insert_execute(&db_insert);\n+\n+ zbx_db_insert_clean(&db_insert);\n```\n\nAfter the patch, `clientip` is bound as a database parameter rather than concatenated into a query string. The fix is correct. The fix is the smaller half of the commit.\n\n## The other half of the patch is a comment\n\nThe `clientip` value reaches `zbx_auditlog_global_script` by way of `node_process_command` in `src/libs/zbxtrapper/nodecommand.c`. The dispatcher reads the field out of the request JSON. The same commit that switched the INSERT to a parameterized query added three lines of comment above that read, and they are the only change to `nodecommand.c` in the entire patch:\n\n```c\n/* It appears that IPv6 specification allows entries like \"%\" */\n/* which do not pass our current IPv6 address validation. In the future, when we */\n/* fix our IPv6 address validation we could consider adding it here. */\nif (SUCCEED != zbx_json_value_by_name(jp, ZBX_PROTO_TAG_CLIENTIP, clientip,\n sizeof(clientip), NULL))\n *clientip = '\\0';\n```\n\nThe comment is in HEAD today. It documents that the project's IPv6 address validator rejects RFC-legal addresses with zone identifiers (`fe80::1%eth0`), so input validation on `clientip` was abandoned at this site, and may be added back if the validator is improved. The future tense matters. This is a [todo-that-shipped](/patterns/todo-that-shipped) in the precise definition: the comment names a security control that has no implementation in the file, and the comment is the only manifestation of the control. What makes this instance specific is when it landed. It was not committed years ago and forgotten. It was added inside the security patch for an SQL injection caused by the field the comment is about.\n\nThe audit-log INSERT is no longer a SQL injection sink. Every other consumer of `clientip` in the request lifecycle, every place in the Zabbix codebase that takes the same value and writes it into a log line, forwards it across the proxy protocol, or interpolates it into a notification template, is now operating under the explicit, comment-documented assumption that the field is untrusted. There is no central sanitizer the field passes through. The vendor's fix is end-of-pipe at one specific INSERT. The validation gap that fed that INSERT is the validation gap that feeds everything else.\n\n## The PoC, end to end\n\nMaxim Tyukov's public proof of concept ships as W01fh4cker's RCE chain on GitHub, dated May 2024. The premise the README states is plain:\n\n```\n- a low-privilege user\n- Have permission to execute scripts\n```\n\nThe CVSS vector reads `PR:H` because authentication and a hostid in scope are required. In the populations Zabbix is sold to, that bar is met by every NOC operator, every read-only monitoring account that has at least one host in view, and every automation user. The `PR:H` rating is the vendor's polite version of \"needs an account.\"\n\nThe first stage opens a TCP socket to the trapper port and sends the protocol's `ZBXD\\x01` header followed by an eight-byte little-endian length and a JSON body:\n\n```python\nzbx_header = b\"ZBXD\\x01\"\nmessage = {\n \"request\": \"command\",\n \"sid\": sid,\n \"scriptid\": \"2\",\n \"clientip\": \"1' + \" + injection + \" + '1\",\n \"hostid\": hostid\n}\nmessage_json = json.dumps(message)\nmessage_length = struct.pack('\",\n \"id\": 0\n}\n```\n\nThe interactive shell mode loops `script.update` and `script.execute`, prompting `[zabbix_cmd]>>:` and printing the result of each command. The PoC also ships a `LoginAsAdmin` variant that extracts the application's `session_key` from the `config` table via the same time-based primitive, HMAC-signs a forged `zbx_session` cookie, and walks straight into the web UI as the administrator without ever issuing the JSON-RPC chain.\n\n## The boundary the application enforces is bypassed by what the application records\n\nThe Zabbix permission model separates \"low-privilege users who can execute scripts on hosts they own\" from \"administrators who can execute arbitrary commands on the server.\" That boundary is enforced in the application API: `script.create` checks the user's role before accepting the request. The `command` trapper request honors the same permissions for execution, and that is why the PoC needs the second stage at all.\n\nThe audit-log INSERT does not honor the permission model. It runs in the privileged database context that owns the schema. A low-privilege user, by performing an action the system audits, gets a SQL primitive that runs in the context of a database connection with read access to every table the audit-writer needs to observe, which is every table. That is the shape that [the-detector-is-the-target](/patterns/the-detector-is-the-target) names: the SOC tool is the attack surface, because the SOC tool's necessary privilege is broader than the privilege of the user it is recording. It is also a [trust-inversion](/patterns/trust-inversion). The database credentials Zabbix uses to write audit records are now ratifying `SELECT` statements against `sessions` and `config`, on behalf of an attacker who never held those credentials and never could.\n\nThe privilege escalation primitive in CVE-2024-22120 is not a vulnerable script. The primitive is performing an action that the system audits, in a way that turns the audit write into the attacker's read. Two years after disclosure, the nuclei template for CVE-2024-22120 still reaches a population of unpatched 6.0 LTS deployments large enough that ProjectDiscovery ships the probe in their default checks. The patch is mandatory for Zabbix 6.0.x older than 6.0.28, 6.4.x older than 6.4.13, and 7.0.0alpha1.\n\nPoC: [W01fh4cker/CVE-2024-22120-RCE](https://github.com/W01fh4cker/CVE-2024-22120-RCE)","closing_line":"The audit log records who ran the script. CVE-2024-22120 turns the recording into the run.","hook_md":"Zabbix Server's trapper port accepts a `command` request from authenticated users who have permission to execute a global script on a host they can see. When the request lands, the server runs the script and inserts a row into the `auditlog` table recording who, when, where from, what command. In versions 6.0.0 through 6.0.27, 6.4.0 through 6.4.12, and 7.0.0alpha1, that INSERT is built with a `printf`-style format string and the `ip` column is filled in with `clientip`, a field the request supplied as JSON. The audit-writer's database connection has `SELECT` on `sessions`. A low-privilege user with one host in scope sends one packet, and the row Zabbix writes about them runs `select sessionid from sessions where userid=1`, character by character, against the wall clock. The audit log captured the attack faithfully. The capture is the attack.","post_id":43,"slug":"zabbix-cve-2024-22120-audit-log-is-the-read-primitive","title":"CVE-2024-22120: Zabbix's Audit Log Is the Read Primitive","type":"initial","unreadable_sentence":"The audit log captured the attack faithfully. The capture is the attack."} -----BEGIN PGP SIGNATURE----- iHUEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCagSwNQAKCRDeZjl4jgkQ JvvhAP9w6lHhF7AHIW4P5/NQLvdn4EanygAKk1SWXquPzhuh+AEAmkwXb21uJfUD 8vr+Aa2UCU/lbWvc5jwLXbWRBUyBvwA= =/S9U -----END PGP SIGNATURE-----