| 항목 | 내용 |
|---|---|
| CVE ID | CVE-2026-9090 |
| 영향 제품/버전 | Casdoor 2.362.0 이하 (CERT VU#780781) |
| CVSS v3.1 | 9.1 (Critical) — AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N |
| CWE | CWE-287 (Improper Authentication) |
| 공개일 | 2026-05-28 (CERT/NVD) |
| 수정 상태 | upstream 커밋 d14674e가 신뢰 원본을 변경함. 공개 권고문이 특정 수정 릴리스는 제시하지 않아 배포 버전은 확인 필요 |
| 취약 지점 | object/saml_sp.go의 buildSp() 및 buildSpCertificateStore() |
Casdoor는 SAML Identity Provider(IdP)가 서명한 응답을 검증해 로그인 세션을 만든다. 그러나 취약한 구현은 미리 설정한 IdP 인증서가 아니라, 검증 대상인 들어오는 SAMLResponse 안에서 추출한 인증서를 신뢰 저장소에 넣었다. 공격자는 자신의 키로 서명한 assertion과 그에 대응하는 인증서를 함께 보내, 코드가 바로 그 공격자 인증서로 서명을 검증하게 만들 수 있었다. 이는 “서명이 유효한가”보다 먼저 지켜야 할 “누구의 키를 신뢰하는가”라는 인증 경계가 깨진 인증 우회다.
SAML Service Provider(SP)는 사용자가 직접 입력한 비밀번호 대신, 신뢰 관계를 맺은 IdP의 인증 결과를 받는다. IdP는 사용자·그룹·유효 시간 등이 든 XML assertion에 개인키로 서명하고, SP는 사전에 등록해 둔 IdP 공개키 인증서로 그 서명을 검증한다. 검증이 성공하면 SP는 “등록한 IdP가 이 사용자의 인증 결과를 발행했다”고 판단해 세션을 만든다.
[정상 신뢰 경계]
관리자가 등록한 IdP 인증서 ───────┐
▼
IdP ── SAMLResponse(사용자 assertion + 서명) ──► Casdoor SP
│
├─ 등록된 공개키로만 서명 검증
└─ 성공 시 해당 assertion의 사용자로 세션 생성
인증서가 XML의 X509Certificate 요소에 실려 올 수는 있다. 하지만 그것은 전송 형식의 일부일 뿐, SP가 그 값을 신뢰 기준으로 채택해도 된다는 뜻은 아니다. TLS 서버가 접속자가 제시한 인증서를 보고 “이 인증서로 서명했으니 신뢰한다”고 판단할 수 없는 것과 같다. 신뢰할 키는 로그인 요청을 받기 전에 관리자 설정·IdP metadata 같은 독립된 경로로 고정되어 있어야 한다.
공개키 서명 검증은 메시지와 공개키가 주어졌을 때, 그 공개키에 대응하는 개인키가 서명에 사용됐는지를 확인한다. 따라서 아래 둘은 서로 다른 질문이다.
1. assertion 서명이 인증서 C의 개인키로 만들어졌는가? → 암호학적 무결성 검사
2. 인증서 C는 우리가 신뢰하기로 한 IdP의 것인가? → 신뢰 앵커 검사
공격자는 새 키 쌍과 자기 인증서를 자유롭게 만들 수 있다. SP가 응답에서 얻은 인증서 C를 곧바로 신뢰 앵커로 삼으면 1번은 통과하지만 2번은 전혀 검사되지 않는다. 이 CVE는 암호 알고리즘을 깨는 문제가 아니라, 키 검증에 사용할 신뢰 앵커의 출처를 공격자 입력으로 바꾼 논리 오류다.
Casdoor에서 ParseSamlResponse()는 URL 인코딩을 푼 응답과 Provider 설정을 buildSp()에 넘긴 뒤, 반환된 SAMLServiceProvider의 RetrieveAssertionInfo()로 assertion을 처리한다. buildSpCertificateStore()가 만든 MemoryX509CertificateStore.Roots가 바로 라이브러리의 IdP 인증서 저장소가 된다. 그러므로 이 함수가 어떤 바이트를 Roots[0]에 넣는지가 SAML 서명 검증의 신뢰 경계를 결정한다.
SAMLResponse + Provider 설정
│
▼
ParseSamlResponse()
│
▼
buildSp() ──► IDPCertificateStore = certStore
│ │
▼ ▼
RetrieveAssertionInfo() ───────► assertion 서명 검증 후 사용자 정보 반환
패치 전 buildSp()는 같은 samlResponse를 신뢰 저장소 생성 함수에 전달했다. 응답이 비어 있지 않으면 getCertificateFromSamlResponse()가 base64로 복호화한 XML에서 X509Certificate 요소를 정규식으로 추출했고, 그 인증서를 Roots에 넣었다.
func buildSpCertificateStore(provider *Provider, samlResponse string) (...) {
certEncodedData := ""
if samlResponse != "" {
certEncodedData, err = getCertificateFromSamlResponse(samlResponse, provider.Type)
} else if provider.IdP != "" {
certEncodedData = provider.IdP
}
// ... x509.ParseCertificate(certData)
certStore = dsig.MemoryX509CertificateStore{
Roots: []*x509.Certificate{idpCert},
}
}정상 흐름에서 provider.IdP는 관리자가 사전에 설정한 신뢰 IdP 인증서여야 한다. 하지만 실제 SAML 로그인 응답은 항상 비어 있지 않으므로, 위 분기의 우선순위는 provider.IdP보다 공격자가 제공한 samlResponse 내부 인증서에 있었다. 그 결과 RetrieveAssertionInfo()가 받는 IDPCertificateStore는 설정값이 아니라 검증 대상 XML이 정한 값이 됐다.
이 경로가 지켜야 할 불변식은 다음과 같다.
로그인 assertion의 서명은 해당 Provider에 미리 등록된 IdP 인증서(또는 그 인증서로 검증된 metadata)로만 검증한다.
패치 전에는 “응답이 담은 인증서로 그 응답의 서명을 검증한다”로 바뀌었다. CERT와 NVD 설명에 따르면 공격자는 임의의 서명 인증서를 제공하고, 그 키로 서명한 assertion을 위조할 수 있다. 아래는 공개 설명과 소스에 근거한 정적 데이터 흐름이며, 재현 절차나 공격용 페이로드는 포함하지 않는다.
[패치 전]
공격자
├─ 공격자 개인키로 서명한 assertion
└─ 같은 키의 X509Certificate를 넣은 SAMLResponse
│
▼
getCertificateFromSamlResponse()
│ 공격자 인증서 추출
▼
IDPCertificateStore.Roots[0] = 공격자 인증서
│
▼
RetrieveAssertionInfo()
└─ 공격자 개인키 서명 ↔ 공격자 공개키: 일치
│
▼
위조 assertion의 사용자 정보가 세션 생성 경로로 전달
서명 자체는 검증될 수 있으나, 그것은 공격자 자신의 키에 대해서만 참이다. 원래 IdP의 키인지 확인하지 않기 때문에, 공격자가 임의 사용자 identity를 assertion에 넣을 수 있는 인증 우회가 된다. 영향 범위는 Casdoor의 SAML SP 기능을 사용하고 공격자가 해당 SAML 응답 처리 경로에 도달할 수 있는 배포에 한정된다. NVD는 네트워크·무권한·사용자 상호작용 불필요로 평가했다.
upstream 커밋 d14674e는 samlResponse 매개변수와 getCertificateFromSamlResponse()를 제거하고, 인증서 원본을 무조건 provider.IdP로 고정했다. 설정된 인증서가 없으면 오류를 반환해 인증서 저장소를 빈 값으로 조용히 만들지 않는다.
- certStore, err := buildSpCertificateStore(provider, samlResponse)
+ certStore, err := buildSpCertificateStore(provider)
- if samlResponse != "" { certEncodedData = getCertificateFromSamlResponse(...) }
- else if provider.IdP != "" { certEncodedData = provider.IdP }
+ certEncodedData := provider.IdP
+ if certEncodedData == "" { return error }[패치 후]
관리자 설정 Provider.IdP ──► IDPCertificateStore.Roots[0]
│
들어오는 SAMLResponse ──► assertion + 서명만 검증 대상
│
▼
서명이 등록 IdP 공개키와 일치할 때만 assertion 수용
공격자 인증서가 응답 안에 있어도 ──► 신뢰 저장소에 들어가지 않음
운영자는 취약 버전을 사용 중이라면 이 변경을 포함한 upstream 릴리스 또는 검증한 backport로 업데이트해야 한다. 특정 수정 릴리스 번호는 공개 권고문에서 확인되지 않으므로, 배포 전 소스에 buildSpCertificateStore(provider *Provider)가 있고 certEncodedData := provider.IdP를 사용하는지 확인해야 한다. 즉시 업데이트할 수 없으면 SAML Provider를 신뢰할 수 있는 IdP로만 제한하고, 고권한 계정에는 별도의 하위 MFA·로그 감시를 적용해야 한다. 이것은 완전한 대체 수정이 아니라 노출을 줄이는 임시 통제다.
이 보고서는 공개 PoC나 공격 코드를 실행하지 않았다. 분석은 CERT VU#780781 및 NVD의 원인 설명, 그리고 Casdoor의 패치 전후 공개 소스 diff를 정적으로 대조해 작성했다.
- CERT VU#780781은 2026-05-28 기준으로 조정 가능한 공급업체 패치가 없다고 기록했지만, Casdoor 저장소에는 2026-04-05의 관련 신뢰 원본 변경 커밋이 존재한다. 이 커밋이 CVE-2026-9090의 공식 수정으로 릴리스에 포함됐는지와 최초 수정 릴리스 번호는 공급업체 확인이 필요하다.
- GitHub Advisory의 Go 모듈 범위 표기와 CERT의 제품 버전 표기가 서로 다르므로, 운영 환경은 버전 번호만으로 판단하지 말고 위 패치 조건을 실제 소스/배포 산출물에서 확인해야 한다.
- CERT VU#780781 — CVE-2026-9090 원인, 영향 및 당시 공급업체 상태
- CVE-2026-9090 — NVD — CVSS와 영향 설명
- GHSA-fwgq-j9r9-qjgr — Go 모듈 범위 및 CWE
- 수정 커밋
d14674e— Casdoor — 응답 인증서 의존 제거 및 설정 인증서 고정 - 패치 전 소스 — 부모 커밋 — 취약한 인증서 추출 경로