[하루한줄] 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]

세부 과정으로는 다음 흐름으로 수행됩니다.

  1. from_param 호출
  2. build() : SAML Response 에서 Signature 추출
  3. decrypt() : 암호화된 메시지라면 복호화 수행
  4. 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_valuesvalidate_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