//nefariousplan

CVE-2024-9487: GHES Extracted Signatures Before Decryption. The Inner Assertion's Was Never Extracted.

pattern

cve

proof of concept

A GitHub Enterprise Server administrator enables Encrypted Assertions because the GHES SAML hardening checklist lists the feature as defense in depth. After that switch, every signed SAML response arriving at /saml/consume carries two cryptographic signatures: one over the outer <saml2p:Response>, and one over the inner <saml2:Assertion> riding inside the <saml2:EncryptedAssertion> blob. By the SAML 2.0 profile both are present; either alone would let an honest IdP authenticate the assertion. GHES checks one.

CVE-2024-9487 is what an attacker who can capture a single legitimate SSO response gets to do once they notice which one. Project Discovery's published PoC ships a signed_assertion_xml template with a fourteen-line base64 <ds:SignatureValue> baked in: OYOIw4wMFxm3OaG/n7YbQxcWKAFDmUjD33WIQJ3VgdsWdfV1.... There is no key on the network that produces that value over the assertion's digest. There does not need to be.

GitHub patched this on October 10, 2024 in 3.11.16, 3.12.10, 3.13.5, and 3.14.2. The advisory calls it "improper verification of cryptographic signature." The implementation never extracted the inner signature into the verifier's working set in the first place. Two signatures arrived. The validator received one. The other was fourteen lines of base64 from somebody else's session, signed by nobody, checked by nothing, accepted as authentication.

The build() method extracted signatures before it knew what was in the document

The vulnerable code lives in GHES's Ruby SAML handling under lib/saml/. Project Discovery's writeup reconstructed the relevant lines of Message.build():

signatures = message_class.signatures(doc) # [1]
# ...
plain_doc = message_class.decrypt(doc, options, decrypt_errors)
signatures = message_class.signatures(plain_doc) if signatures.empty? # [2]

Two passes. The first reads signatures out of the document at the top of the function. The second runs only if signatures.empty?. When a SAML response carries an outer <saml2p:Response> signature, the first pass finds that signature and returns a non-empty set. The encrypted assertion still wraps an inner <saml2:Assertion> signature, but at this point in the pipeline the assertion is opaque ciphertext and signatures(doc) cannot see inside it.

Then decrypt() runs. The encrypted blob is decoded with the SP's private key and the inner assertion appears as readable XML. By the SAML profile, that assertion carries its own <ds:Signature> from the IdP. By the GHES code path, the signature-extraction guard has already returned a non-empty set, so the second pass is skipped. The inner signature is now visible in the document and is never read by anything that will check it.

validate_signatures_ghes runs over what was extracted. It calls .valid? on each signature in the set using the xmldsig library. The set contains the outer Response signature only. The library can only verify what is handed to it.

A second routine, validate_assertion_digest_values, walks the decrypted assertion's <ds:DigestValue> against the assertion content and confirms they match. That check survives the encrypted-assertion path because it operates on the decrypted document directly, not on the extracted-signature set. The PoC satisfies it by computing a fresh digest before encryption. The digest check passing was the verifier's evidence that the assertion was authentic. It was evidence that the assertion was self-consistent.

The wrapping carries the outer signature past the swap

Knowing what the verifier will and will not check, the PoC builds a SAML response in three steps:

# Step 1: move the original signed Response into a <ds:Object> under its own signature
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)

# Step 2: replace the encrypted assertion with a forged-and-encrypted assertion
saml_response
  .at_xpath('/saml2p:Response/saml2:EncryptedAssertion', namespaces)
  .replace(encrypted_assertion_node1)

# Step 3: change the outer Response's ID
saml_resp_node['ID'] = saml_resp_node['ID'][0..-3]+"ae"

Step 1 is the XML signature-wrapping primitive. The original signed <saml2p:Response> is duplicated into a child <Object> element placed under its own <ds:Signature>. The signature's Reference URI still points at the original document ID; the bytes at that URI still hash to the digest the IdP signed, because they are the same bytes, just relocated. The outer signature continues to validate against the wrapped copy.

Step 2 swaps the encrypted assertion. The forged inner assertion is built with the IdP's certificate (read from IdP metadata) embedded in its <ds:KeyInfo>, the victim's username substituted into <saml2:NameID>, and a freshly-computed SHA-256 digest in <ds:DigestValue>. The whole thing is encrypted with the SP's public key (read from ${RootURL}/saml/metadata) using AES-256-CBC for the data and RSA-OAEP for the AES key. By construction, GHES will be able to decrypt it.

Step 3 mutates the outer Response ID by two characters. The mutated ID is what the consumer reads. The original ID is what the wrapped Object preserves. Two IDs in the same document, one for the verifier's signature check, one for everything else.

The forged assertion's signature is fourteen lines of base64 from somebody else's session

Look at what the PoC's signed_assertion_xml template carries, and what it does and does not substitute.

signed_assertion_xml = <<-XML
<saml2:Assertion ID="id1423912998721389200353112" IssueInstant="2024-10-13T09:53:46.851Z" ...>
  <saml2:Issuer ...>issuer_replace</saml2:Issuer>
  <ds:Signature ...>
    <ds:SignedInfo>...
      <ds:DigestValue>2n9HGB3mHU+gxo8DJrIw0MwT/Gs7/agpmo+C1sb7mtU=</ds:DigestValue>
    </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>
    <saml2:NameID ...>user_replace</saml2:NameID>
    ...

The PoC substitutes issuer_replace, user_replace, recipient_replace, audience_replace, and cert_replace with values pulled from the captured response and the IdP metadata. It computes a new SHA-256 digest of the modified assertion content and writes that digest into <ds:DigestValue>, replacing the placeholder 2n9HGB3mHU+gxo8DJrIw0MwT/Gs7/agpmo+C1sb7mtU= from the template. It does not substitute <ds:SignatureValue>. The base64 blob remains exactly as it appears in the file. The PoC author copy-pasted these bytes from some earlier signed assertion they had during research. The bytes are stale, the bytes were signed by some private key that does not appear anywhere in this exchange, the bytes are signed over a digest that no longer matches the SignedInfo, and the embedded <ds:X509Certificate> is now the IdP's certificate, which does not own the private key that produced this <ds:SignatureValue>. Every cryptographic property an XML-DSig verifier would check fails.

If GHES were verifying, the verifier would (a) extract the embedded cert, (b) re-canonicalize the <ds:SignedInfo>, (c) compute the SHA-256 of the canonicalized SignedInfo, and (d) RSA-verify the SignatureValue against that hash using the cert's public key. The freshly-computed digest from step (b) makes SignedInfo fresh too, and the stale <ds:SignatureValue> cannot pass step (d) under any RSA key. Nothing reaches step (a). The signature was never extracted into the working set, so the library that would have failed it never received it.

The PoC's IssueInstant is 2024-10-13T09:53:46.851Z. That timestamp is two days after the CVE was published. It is the moment the researcher hit "go" on their reproduction harness. Whatever assertion produced the OYOIw4wMFx blob was signed before that moment, by some other key, against some other digest, and has been functioning as a valid SAML signature in the eyes of every vulnerable GHES instance ever since.

The advisory says "improper verification." The PoC says the signature was never extracted.

GitHub's advisory text:

"An improper verification of cryptographic signature vulnerability was identified in GitHub Enterprise Server that allowed SAML SSO authentication to be bypassed."

"Improper verification" carries an implication, that some verification ran, and the verification was wrong. The PoC's hardcoded OYOIw4wMFx is the proof that no verification ran on the inner assertion at all. A signature that arrives at the validator and fails to verify produces a specific error in the xmldsig library; the GHES audit log would carry that failure, and any post-incident reviewer would find it. No log entry exists. The signature's bytes lived in the document. They never lived in the verifier's working set.

The advisory also says: "This vulnerability was reported via the GitHub Bug Bounty program." It does not name the reporter. Project Discovery's writeup, published on November 12, 2024, names the team: iamnoooob, rootxharsh, pdresearch. The nuclei-template's author field carries the same three handles. Both attributions are public record. One is in the place where defenders read advisories; the other is in the place where defenders read PoCs.

Encryption was supposed to add a check. The implementation removed one.

Encrypted Assertions is a SAML 2.0 feature designed to keep assertion contents (NameIDs, attribute statements, group memberships) off the user's HTTP wire. It exists alongside, not instead of, the assertion's signature. A correct verifier checks both: the assertion is encrypted to the SP's key (so an eavesdropper does not see it), and the assertion is signed by the IdP (so the SP knows who issued it). Two properties, two checks.

The GHES implementation collapsed the two into one. Once the encrypted-assertions code path was active, the inner signature was unreachable by validate_signatures_ghes because of where signature extraction sat in the pipeline. The verifier ran the outer-Response check and called it sufficient. The encryption had become the authentication: the SP's ability to decrypt was the SP's evidence that the IdP signed, and the IdP did not need to be in the loop at all.

This is the pattern we file under signed-but-unextracted. The signature exists, present in the bytes, conformant to the spec, available to any verifier that walks the decrypted document. The verifier never walks. Pipeline ordering, signature extraction at one stage, decryption at a later stage, no re-extraction, leaves the inner signature orphaned by design. The same shape recurs in JWE-then-JWS implementations that verify the JWE envelope's integrity tag and forget the inner JWS, in PKCS#7 nested-envelope flows that drop inner signers, and in two decades of XML signature wrapping CVEs against SAML stacks. Every fix lands in the same place: re-run signature extraction after the wrapper opens, against the document the consumer will actually read.

The encrypted-assertions feature was sold to administrators as defense in depth. Its implementation made the depth feature optional and the surface feature mandatory. The fix is one branch of an if signatures.empty? away.

PoC: projectdiscovery/nuclei-templates

Two signatures arrived. The validator received one. The other was fourteen lines of base64 from somebody else's session, signed by nobody, checked by nothing, accepted as authentication.