[하루한줄] CVE-2024-9487: GitHub Enterprise SAML Authentication Bypass 취약점
URL
Target
- Github Enterprise Server ≤ 3.11.15
- Github Enterprise Server ≤ 3.12.9
- Github Enterprise Server ≤ 3.13.4
- Github Enterprise Server ≤ 3.14.1
Explain
GitHub Enterprise 의 SAML 인증에서 검증 과정 미흡으로 인증 우회 취약점이 발견되었습니다. 이전에 GitLab 에서 발견된 SAML 인증 우회와 비슷한 벡터인데요. GitLab SAML 인증 우회 취약점이 궁금하시다면 이 글을 참고해주세요.
GitHub SAML 인증
SAML 인증은 IdP 에서 발급한 SAML Response 를 SP가 검증하여 사용한다는 것을 기억하시나요? GitHub Enterprise 도 이와 같은 방식을 사용합니다. SAML Response 의 Assertion 에는 권한 정보가 담기고 Signature 검증을 통해 무결성을 보장합니다.
다음은 취약점을 발견한 연구원들이 GitHub Enterprise 로컬 환경에서 SAML 인증 테스트를 위해 작성한 코드 중 일부입니다. resp.xml 이 SAML Response 이고 파싱은 [1] 에서 호출하는 from_param 을 통해 이뤄집니다. 이를 통해 xml 객체로 변환되며 valid?
메소드를 통해 검증을 수행합니다.
saml_resp = Base64.encode64(File.open('resp.xml').read())
xml = ::SAML::Message::Response.from_param(saml_resp, @prop1) [1]
puts "Signature verified: " + String(xml.valid?(@props)) [2]
세부 과정으로는 다음 흐름으로 수행됩니다.
from_param
호출build()
: SAML Response 에서 Signature 추출decrypt()
: 암호화된 메시지라면 복호화 수행parse()
:<samlp:Response/saml:Assertion
블록을 파싱하며 메시지 정보를 처리.
EncryptedAssertion
암호화된 메시지는 아래 예시 SAML Response 에서 saml:EncryptedAssertion
블록 안의 내용을 뜻합니다. 권한 정보가 기재되는 Assertion 을 암호화하여 보안성을 더 챙기려고 하는 것이죠.
<samlp:Response ID="123">
<ds:Signature>
<ds:SignedInfo>
<ds:Refernce URI="#123"></ds:Refernce>
</ds:SignedInfo>
</ds:Signature>
<saml:EncryptedAssertion>
enc assertion here
</saml:EncryptedAssertion>
</samlp:Response>
build 함수의 코드를 보면 암호화된 Assertion 을 사용하는 경우, 먼저 Signature 를 추출하고 복호화를 수행하는데요. 처음 Signature 가 추출되지 않으면 (값이 비어있으면) 복호화된 값에서 Signature 를 추출하여 사용합니다.
def self.build(xml, options = {})
if GitHub.enterprise? && GitHub.saml_encrypted_assertions?
signatures = message_class.signatures(doc)
decrypt_errors = []
plain_doc = message_class.decrypt(doc, options, decrypt_errors)
signatures = message_class.signatures(plain_doc) if signatures.empty?
...
end
end
SAML Response 검증 과정
SAML Response 의 검증을 수행하는 valid?
는 내부적으로 다음 검증들을 거칩니다.
validate_has_signature
has_root_sig_and_matching_ref?
또는all_assertions_signed_with_matching_ref?
함수 결과가 참일 경우 통과합니다.
validate_assertion_digest_values
: 각 Assertion 의 DigestValue (hash) 를 검증합니다.validate_signatures_ghes
:build()
에서 추출된 SignatureValue (서명) 을 검증합니다.
validate_has_signature
의 내부에서 호출되는has_root_sig_and_matching_ref?
메소드는 Assertion 블록 밖에 있는 Signature 가 root (Response 블록) ID와 같은지 검사합니다.
all_assertion_signed_with_matching_ref?
메소드는 모든 Assertion 블록이 Signature 를 갖는지와 Signature URI가 부모 Assertion ID와 매칭이 되는지 검사합니다.
# Validate that the SAML message (root XML element of SAML response)
# or all contained assertions are signed
#
# Verification of signatures is done in #validate_signatures
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 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
def all_assertions_signed_with_matching_ref?
assertions = document.xpath("//saml2:Assertion", namespaces)
assertions.all? do |assertion|
ref = assertion.at("./ds:Signature/ds:SignedInfo/ds:Reference", namespaces)
return false unless ref
assertion_id = String(assertion["ID"])
ref_uri = String(String(ref["URI"])[1..-1]) # chop off leading #
return false unless ref_uri.length > 1
ref_uri == assertion_id
end
end
조금 복잡하지만 천천히 살펴봅시다. 다음은 예시 SAML Response 입니다. has_root_sig_and_matching_ref?
은 [1] 의 Signature 블록내에 Reference URI 가 Response ID 와 같은지 검사를 하는 역할이구요. all_assertion_signed_with_matching_ref?
는 [2] 와 같은 Assertion 블록들이 Signature 를 갖고 Reference URI 가 해당 Assertion ID와 매칭이 되는지 검사하는 메서드입니다.
validate_assertion_digest_values
와 validate_signatures_ghes
는 각 Assertion 블록의 DigestValue 를 레퍼런스 되는 노드의 Signature 와 검증합니다. Assertion [2] 를 검증하는 것이죠.
<Response ID="123">
<Signature> // [1]
<SignedInfo>
<Refernce URI="#123"></Refernce>
</SignedInfo>
</Signature>
<Assertion ID="789">
<Signature> // [2]
<SignedInfo>
<Refernce URI="#789"></Refernce>
</SignedInfo>
</Signature>
</Assertion>
</Response>
검증 우회
Signature 와 Assertion 쌍을 검사하는 validate_has_signature
우회를 위해 <ds:Object>
를 사용할 수 있습니다.
Root Response 의 ID를 임의의 값으로 변경하고 Object 블록 안에 원본 Response 를 삽입합니다. 그리고 최상위 Signature 의 Refrence URI 는 삽입된 Response 의 ID로 맞춥니다. [1] 그런 다음 삽입된 Response 아래의 Assertion 과 그 다음 Signature 의 ID, URI 를 맞춥니다. [2]
<Response ID="11111111">
<Signature>
<SignedInfo>
<Refernce URI="#123"></Refernce> [1]
</SignedInfo>
<Object>
<Response ID="123"> [1]
<Signature>
<SignedInfo>
<Refernce URI="#123"></Refernce>
</SignedInfo>
</Signature>
<Assertion ID="789"> [2]
<Signature>
<SignedInfo>
<Refernce URI="#789"></Refernce> [2]
</SignedInfo>
</Signature>
</Assertion>
</Response>
</Object>
</Signature>
---- THIS WILL BE ENCRYPTED ----
<Assertion ID="789"> [3]
<Signature>
<SignedInfo>
<Refernce URI="#789"></Refernce> [3]
</SignedInfo>
</Signature>
</Assertion>
---- THIS WILL BE ENCRYPTED ----
</Response>
[1] 은 원본 Response 이기 때문에 검증을 통과하는 DigestValue 와 서명이 포함되어 있습니다.
[3] 은 변조된 Assertion 을 구성해서 GitHub Enterprise 의 공개키로 암호화하여 서버에서 복호화할 수 있게 합니다.
변조된 SAML Response 를 로드할 때에는 암호화된 Assertion 을 사용하도록 되어 있지만 .valid?
메서드는 처음으로 등장하는 Reference URI 의 Signature 를 선택하여 서명 검증을 수행합니다. 즉, [1] 이 검증에 사용이 된다는 것이죠.
이를 이용해 서명 검증을 우회할 수 있습니다.
Reference
본 글은 CC BY-SA 4.0 라이선스로 배포됩니다. 공유 또는 변경 시 반드시 출처를 남겨주시기 바랍니다.