Sploitus

Exploit for PAN-OS GlobalProtect CAS CVE-2026-0265 Vulnerability Checker

metasploit · 2026-05-21

Exploit Code

ruby360 lines
## https://sploitus.com/exploit?id=MSF:AUXILIARY-SCANNER-HTTP-PANOS_CVE_2026_0265-
##
##
# CVE-2026-0265 PAN-OS GlobalProtect CAS Exposure and Version Scanner
#
# Safe scanner: no authentication bypass attempted, no session creation,
# no body modification, and no firewall state changes.
#
# Detection logic derived from:
# https://github.com/BishopFox/CVE-2026-0265-check
#
# Copyright (c) Bishop Fox
# Original utility released under the MIT License.
#
# This Metasploit module is distributed under the Metasploit Framework license.
##
##

class MetasploitModule < Msf::Auxiliary
  include Msf::Exploit::Remote::HttpClient
  include Msf::Exploit::Retry
  include Msf::Auxiliary::Scanner
  include Msf::Auxiliary::Report

  DEFAULT_USER_AGENT = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 ' \
                       '(KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36'

  # Per-base patched-hotfix cutoffs from Bishop Fox scanner / PA advisory logic.
  # Any hotfix strictly below the listed value on the same base is vulnerable.
  ADVISORY_PATCHED_HOTFIX = {
    [10, 2, 4 ] => 44,
    [10, 2, 7 ] => 34,
    [10, 2, 10] => 36,
    [10, 2, 13] => 21,
    [10, 2, 16] => 7,
    [10, 2, 18] => 6,
    [11, 1, 4 ] => 33,
    [11, 1, 6 ] => 32,
    [11, 1, 7 ] => 6,
    [11, 1, 10] => 25,
    [11, 1, 13] => 5,
    [11, 2, 4 ] => 17,
    [11, 2, 7 ] => 13,
    [11, 2, 10] => 6,
    [12, 1, 4 ] => 5
  }.freeze

  # Train-base floors: anything >= this base in the train is patched.
  ADVISORY_BASE_FLOOR = {
    [11, 1] => 15,
    [11, 2] => 12,
    [12, 1] => 7
  }.freeze

  # Highest base release represented by the advisory matrix for each affected train.
  # A later base release is presumed patched so future releases do not fall through
  # to the affected-train fallback below.
  ADVISORY_LAST_KNOWN_BASE = {
    [10, 2] => 18,
    [11, 1] => 15,
    [11, 2] => 12,
    [12, 1] => 7
  }.freeze

  UNAFFECTED_TRAINS = [
    [8, 1],
    [9, 1]
  ].freeze

  DEFAULT_RETRY_TIMEOUT = 15

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'PAN-OS GlobalProtect CAS CVE-2026-0265 Vulnerability Checker',
        'Description' => %q{
          This module checks a PAN-OS GlobalProtect portal for CVE-2026-0265
          using the Bishop Fox scanner decision flow. It performs anonymous
          GET requests to the GlobalProtect prelogin endpoint, retrying transient
          failures and HTTP 503 responses, then checks
          whether CAS authentication is enabled, decodes the embedded SAML/JWT
          token when present, extracts PanOSversion, and compares it against
          the advisory version matrix.
        },
        'Author' => [
          'Bishop Fox Team X', # original scanner logic
          'Rapid7 Research / Deral Heiland adaptation' # Metasploit port workflow
        ],
        'License' => MSF_LICENSE,
        'References' => [
          ['CVE', '2026-0265'],
          ['URL', 'https://security.paloaltonetworks.com/CVE-2026-0265'],
          ['URL', 'https://github.com/BishopFox/CVE-2026-0265-check']
        ],
        'DisclosureDate' => '2026-05-21',
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'Reliability' => [],
          'SideEffects' => [IOC_IN_LOGS]
        }
      )
    )

    register_options(
      [
        OptString.new('TARGETURI', [true, 'GlobalProtect prelogin endpoint path', '/global-protect/prelogin.esp']),
        OptString.new('USERAGENT', [true, 'User-Agent used for the prelogin probe', DEFAULT_USER_AGENT]),
        OptInt.new('RETRY_TIMEOUT', [true, 'Maximum time in seconds to retry transient failures or HTTP 503 responses', DEFAULT_RETRY_TIMEOUT])
      ]
    )
  end

  def run_host(ip)
    check_host(ip)
  end

  def check_host(ip)
    finding = scan_target
    target = target_label(ip)

    print_status("#{target} - CAS enabled: #{finding[:cas_enabled]}")
    print_status("#{target} - PAN-OS version: #{finding[:panos_version] || 'unknown'}")

    case finding[:verdict]
    when 'VULNERABLE'
      print_good("#{target} - Status: VULNERABLE")
      print_status("#{target} - #{finding[:note]}") if finding[:note]
      report_vulnerability(ip, finding)

      Exploit::CheckCode::Appears(
        finding[:note] || "CAS is enabled and PAN-OS #{finding[:panos_version]} is in an affected version range."
      )
    when 'PATCHED', 'NOT-AFFECTED-SAAS', 'NOT-AFFECTED-NO-CAS', 'NOT-AFFECTED-NOT-GLOBALPROTECT'
      print_status("#{target} - Status: NOT VULNERABLE (#{finding[:verdict]})")
      print_status("#{target} - #{finding[:note]}") if finding[:note]

      Exploit::CheckCode::Safe(
        finding[:note] || "The target is not affected (#{finding[:verdict]})."
      )
    else
      print_warning("#{target} - Status: UNDETERMINED (#{finding[:verdict]})")
      print_warning("#{target} - #{finding[:error]}") if finding[:error]
      print_status("#{target} - #{finding[:note]}") if finding[:note]

      Exploit::CheckCode::Unknown(
        finding[:error] || finding[:note] || "Unable to determine vulnerability status (#{finding[:verdict]})."
      )
    end
  rescue ::Rex::ConnectionError, ::Timeout::Error, ::EOFError, ::Errno::EPIPE, ::Errno::ECONNRESET => e
    reason = "Network error: #{e.class}: #{e.message}"
    print_error("#{target_label(ip)} - Status: UNDETERMINED (#{reason})")
    Exploit::CheckCode::Unknown(reason)
  rescue ::StandardError => e
    reason = "Module error: #{e.class}: #{e.message}"
    print_error("#{target_label(ip)} - Status: UNDETERMINED (#{reason})")
    Exploit::CheckCode::Unknown(reason)
  end

  def target_label(ip)
    Rex::Socket.to_authority(ip, datastore['RPORT'])
  end

  def scan_target
    res = fetch_prelogin
    return error_finding('UNDETERMINED-ERROR', 'No HTTP response received') unless res

    body = res.body.to_s

    if body.include?('GlobalProtect portal does not exist')
      return {
        verdict: 'NOT-AFFECTED-NOT-GLOBALPROTECT',
        cas_enabled: 'No',
        panos_version: nil,
        note: 'Response indicates this listener is not a GlobalProtect portal.'
      }
    end

    if body.include?('Valid client certificate is required')
      return {
        verdict: 'UNDETERMINED-MTLS-GATED',
        cas_enabled: 'Unknown',
        panos_version: nil,
        note: 'mTLS gate hides the authentication profile configuration.'
      }
    end

    if body.include?('CAS is not supported by the client')
      return {
        verdict: 'UNDETERMINED-VERSION-GATED',
        cas_enabled: 'Yes',
        panos_version: nil,
        note: 'CAS is attached but the probe was User-Agent/client-version gated.'
      }
    end

    cas_auth_value = body[%r{<cas-auth>([^<]*)</cas-auth>}i, 1]
    unless cas_auth_value == 'yes'
      return {
        verdict: 'NOT-AFFECTED-NO-CAS',
        cas_enabled: 'No',
        panos_version: nil,
        note: 'prelogin response did not contain <cas-auth>yes</cas-auth>.'
      }
    end

    claims = decode_token_from_prelogin(body)
    unless claims
      return error_finding('UNDETERMINED-ERROR', 'cas-auth=yes but Token decode from prelogin response failed', 'Yes')
    end

    panos_version = claims['PanOSversion']
    verdict = advisory_verdict_for_version(panos_version.to_s)

    if verdict == 'ERROR'
      return error_finding(
        'UNDETERMINED-ERROR',
        "PanOSversion=#{panos_version.inspect} could not be parsed against advisory matrix",
        'Yes',
        panos_version
      )
    end

    patch_reference = advisory_patch_reference_for_version(panos_version.to_s)

    note = case verdict
           when 'VULNERABLE'
             if patch_reference && patch_reference[:type] == :hotfix
               "#{panos_version} is below the advisory patched hotfix #{patch_reference[:version]}."
             else
               "#{panos_version} is within an affected PAN-OS release train."
             end
           when 'PATCHED'
             if patch_reference && patch_reference[:type] == :hotfix
               "#{panos_version} is at or above the advisory patched hotfix #{patch_reference[:version]}."
             elsif patch_reference && patch_reference[:type] == :base
               "#{panos_version} is at or above the advisory patched base release #{patch_reference[:version]}."
             else
               "#{panos_version} is not affected per advisory version logic."
             end
           when 'NOT-AFFECTED-SAAS'
             'SaaS builds are not affected per advisory logic.'
           end

    {
      verdict: verdict,
      cas_enabled: 'Yes',
      panos_version: panos_version,
      note: note
    }
  end

  def fetch_prelogin
    retry_timeout = datastore['RETRY_TIMEOUT'].to_i

    unless retry_timeout.positive?
      print_warning("RETRY_TIMEOUT must be greater than 0; using the default of #{DEFAULT_RETRY_TIMEOUT} seconds.")
      retry_timeout = DEFAULT_RETRY_TIMEOUT
    end

    retry_until_truthy(timeout: retry_timeout) do
      res = send_request_cgi(
        {
          'method' => 'GET',
          'uri' => normalize_uri(datastore['TARGETURI']),
          'headers' => {
            'User-Agent' => datastore['USERAGENT']
          }
        }
      )

      res if res && res.code != 503
    rescue ::Rex::ConnectionError, ::Timeout::Error, ::EOFError, ::Errno::EPIPE, ::Errno::ECONNRESET => e
      vprint_warning("Transient request failure: #{e.class}: #{e.message}")
      nil
    end
  end

  def decode_token_from_prelogin(body)
    saml_request = body[%r{<saml-request>([^<]+)</saml-request>}i, 1]
    return nil unless saml_request

    html_form = Rex::Text.decode_base64(saml_request.strip).to_s
    token = html_form[/name="Token"\s+value="([^"]+)"/, 1]
    return nil unless token

    parts = token.split('.')
    return nil unless parts.length == 3

    payload_json = Rex::Text.decode_base64url(parts[1])
    JSON.parse(payload_json)
  rescue ::StandardError
    nil
  end

  def advisory_patch_reference_for_version(panos_version)
    match = panos_version.match(/^(\d+)\.(\d+)\.(\d+)(?:-h(\d+))?$/)
    return nil unless match

    maj = match[1].to_i
    min = match[2].to_i
    pat = match[3].to_i

    floor = ADVISORY_BASE_FLOOR[[maj, min]]
    return { type: :base, version: "#{maj}.#{min}.#{floor}" } if floor && pat >= floor

    cutoff = ADVISORY_PATCHED_HOTFIX[[maj, min, pat]]
    return { type: :hotfix, version: "#{maj}.#{min}.#{pat}-h#{cutoff}" } if cutoff

    nil
  end

  def advisory_verdict_for_version(panos_version)
    return 'ERROR' if panos_version.empty?
    return 'NOT-AFFECTED-SAAS' if panos_version.include?('.saas')

    match = panos_version.match(/^(\d+)\.(\d+)\.(\d+)(?:-h(\d+))?$/)
    return 'ERROR' unless match

    maj = match[1].to_i
    min = match[2].to_i
    pat = match[3].to_i
    hf = match[4] ? match[4].to_i : 0

    return 'PATCHED' if UNAFFECTED_TRAINS.include?([maj, min])

    floor = ADVISORY_BASE_FLOOR[[maj, min]]
    return 'PATCHED' if floor && pat >= floor

    cutoff = ADVISORY_PATCHED_HOTFIX[[maj, min, pat]]
    return (hf >= cutoff ? 'PATCHED' : 'VULNERABLE') if cutoff

    last_known_base = ADVISORY_LAST_KNOWN_BASE[[maj, min]]
    return 'PATCHED' if last_known_base && pat > last_known_base

    return 'VULNERABLE' if [[10, 2], [11, 1], [11, 2], [12, 1]].include?([maj, min])

    'PATCHED'
  end

  def error_finding(verdict, error, cas_enabled = 'Unknown', panos_version = nil)
    {
      verdict: verdict,
      cas_enabled: cas_enabled,
      panos_version: panos_version,
      error: error
    }
  end

  def report_vulnerability(ip, finding)
    report_vuln(
      host: ip,
      port: datastore['RPORT'],
      proto: 'tcp',
      name: 'PAN-OS GlobalProtect CAS CVE-2026-0265',
      refs: references,
      info: "CAS enabled: #{finding[:cas_enabled]}, PAN-OS version: #{finding[:panos_version]}, verdict: #{finding[:verdict]}"
    )
  end
end