4 minute read

들어가며

2-Tier PKI와 ADFS 팜을 세운 뒤 DMZ에 WAP(Web Application Proxy)를 붙이려는데, WAP에 반입한 ADFS SSL 인증서가 체인 검증(Verify())에서 계속 False가 나왔음. 처음엔 방화벽 문제로 봤는데 아니었고, 원인은 2-Tier CA 구축 때 오프라인 루트 CRL을 배포지점에 안 올린 데 있었음.

오프라인 루트 + DMZ 조합에서 나오는 문제라 기록으로 남김.

환경값은 예시로 치환함. 도메인 lab.local, 내부망 10.0.0.x, DMZ 10.10.10.x, CA는 lab-ROOTCA-CA(오프라인 루트) / lab-ISCA01-CA(발급 CA), federation 이름 sts.lab.local.


구성 배경

  • 2-Tier PKI: ROOTCA(workgroup, 오프라인, 10.0.0.231) + ISCA01(도메인 조인, 발급 CA, 10.0.0.232)
  • CDP/AIA: ISCA01의 IIS를 HTTP 배포지점으로 사용. pki.lab.local(CNAME) → ISCA01, /CertEnroll 가상 디렉터리
  • ADFS 팜: sts.lab.local, NLB VIP + ADFS01/02
  • WAP01: DMZ(10.10.10.10), workgroup(도메인 미가입), OPNsense로 격리. 방화벽은 DMZ → ADFS01:443만 허용

WAP는 백엔드 ADFS와 신뢰를 맺기 전에, 반입한 SSL 인증서의 체인을 검증해야 함. 그런데 여기서 막힘.

$c = Get-ChildItem Cert:\LocalMachine\My\<thumbprint>
$c.Verify()
# False

방화벽부터 의심 — 그러나 아니었음

certutil**** OFFLINE ****을 뱉었음. DMZ 격리를 해놨으니 WAP가 내부 PKI(pki.lab.local)에 못 닿는 것으로 판단함. OPNsense 규칙이 DMZ → ADFS01:443만 열려 있으니 CDP(:80)가 차단됐다는 논리였음.

방화벽에 WAP01 → ISCA01:80 규칙을 추가하고 재검증했지만 여전히 False. 그래서 curl로 실제 도달 여부를 직접 확인함:

curl.exe -v http://pki.lab.local/CertEnroll/lab-ISCA01-CA.crl
* IPv4: 10.0.0.232
* Established connection ... from 10.10.10.10 port 60237
< HTTP/1.1 200 OK
< Content-Type: application/pkix-crl
< Content-Length: 874

200 OK. DMZ에서 ISCA01의 CRL이 HTTP로 정상 내려옴. 방화벽·DNS·라우팅은 문제가 아니었음.

참고: certutil이 OFFLINE을 뱉은 건 네트워크가 막혀서가 아니라, 초기 실패 판정이 WinHTTP URL 캐시에 박혀 재사용됐기 때문. certutil -urlcache * delete로 비우면 그 오탐은 사라짐. 하지만 캐시를 비워도 Verify()는 여전히 False였음 — 진짜 원인은 따로 있었다는 뜻.


certutil -verify로 체인 뜯어보기

Verify()는 True/False만 주니 어디서 막히는지 안 보임. 체인 요소별 상태를 보려면 certutil -verify가 정답임.

Export-Certificate -Cert $c -FilePath C:\temp\sts.cer -Type CERT | Out-Null
certutil -verify -urlfetch C:\temp\sts.cer

출력 핵심만 추리면:

CertContext[0][0]  Subject: CN=sts.lab.local              ← leaf(ADFS 인증서)
  Verified "Base CRL (03)" ... http://pki.lab.local/CertEnroll/lab-ISCA01-CA.crl   ← 통과

CertContext[0][1]  Subject: CN=lab-ISCA01-CA              ← 중간(발급 CA)
  Element.dwErrorStatus = CERT_TRUST_REVOCATION_STATUS_UNKNOWN (0x40)
  Element.dwErrorStatus = CERT_TRUST_IS_OFFLINE_REVOCATION (0x1000000)
  CDP:  Error retrieving URL ... 0x80070032 ERROR_NOT_SUPPORTED
        file:////ROOTCA/CertEnroll/lab-ROOTCA-CA.crl       ← 여기서 터짐

CertContext[0][2]  Subject: CN=lab-ROOTCA-CA              ← 루트(트러스트 앵커)

두 가지가 확정됨.

첫째, leaf는 문제없었음. ADFS 인증서 자체의 폐기 여부는 ISCA01의 HTTP CRL로 이미 검증됨(Verified "Base CRL (03)"). curl로 확인한 그 CRL이 실제로 쓰인 것.

둘째, 막힌 건 중간 인증서(ISCA01)의 폐기검증임. 체인 검증은 leaf 하나만 보는 게 아니라 루트까지 각 단계의 폐기 여부를 확인함:

  • sts(ADFS)가 폐기됐나? → ISCA01 CRL로 확인 → OK
  • ISCA01은 폐기됐나? → ROOTCA CRL로 확인 → 실패

실패 이유는 ISCA01 인증서에 박힌 ROOTCA CDP가 http가 아니라 file:////ROOTCA/CertEnroll/... (UNC 공유 경로)라는 데 있음. WAP01은 workgroup·DMZ라 오프라인 ROOTCA의 파일 공유에 접근할 수 없음 → ERROR_NOT_SUPPORTED → 중간 인증서 revocation “unknown” → 전체 체인 CRYPT_E_REVOCATION_OFFLINE.


근본 원인 — ROOTCA CRL이 어디에도 배포 안 됨

file:// CDP가 박힌 것 자체가 신호였음. 2-Tier PKI 구축 때 ROOTCA의 CDP/AIA를 http로 재설정하지 않고 기본값(로컬 file 경로)으로 루트 인증서를 발급했다는 뜻. 게다가 ISCA01의 배포지점을 확인해보니:

# ISCA01에서
dir C:\Windows\System32\CertSrv\CertEnroll\*.crl
lab-ISCA01-CA+.crl    (delta)
lab-ISCA01-CA.crl

ISCA01 CRL만 있고 ROOTCA CRL 파일 자체가 없음. 즉 오프라인 루트의 CRL을 발급 CA의 HTTP 배포지점에 복사(publish)하는 단계를 빠뜨린 것.

이게 왜 지금까지 안 걸렸나? 도메인 조인된 내부 머신들은 AD에 게시된 CRL(LDAP)이나 로컬 캐시로 검증을 넘겼기 때문. workgroup·DMZ인 WAP01은 그 경로가 없어서 이 결함이 드러난 것.


해결 — 오프라인 루트 CRL 수동 확보 + 로컬 심기

ROOTCA는 발급을 안 하니 CRL이 자동 갱신되지 않음. 수동으로 뽑아야 함.

1) 오프라인 ROOTCA에서 CRL 생성

# ROOTCA(10.0.0.231) 콘솔에서
certutil -CRL
dir C:\Windows\System32\CertSrv\CertEnroll\*.crl   # lab-ROOTCA-CA.crl 생성 확인

2) 오프라인 머신에서 파일 옮기기 (네트워크 없이)

ROOTCA는 오프라인이라 네트워크 복사가 안 됨. CRL은 1~2KB로 작으니 Base64로 텍스트화해 옮김. Hyper-V 확장 세션(Enhanced Session)이면 클립보드 복붙으로 충분함.

# ROOTCA에서 — CRL을 Base64 문자열로
$crl = Get-Content C:\Windows\System32\CertSrv\CertEnroll\lab-ROOTCA-CA.crl -Encoding Byte
[Convert]::ToBase64String($crl) | Set-Clipboard
# WAP01에서 — 붙여넣어 파일 복원
$b64 = 'MIIDE...(전문)...=='   # 작은따옴표 필수 (+ / = 보호)
[IO.File]::WriteAllBytes('C:\temp\lab-ROOTCA-CA.crl', [Convert]::FromBase64String($b64))

# 복원 검증 — 진짜 CRL인지
certutil -dump C:\temp\lab-ROOTCA-CA.crl | Select-Object -First 15
# Issuer: CN=lab-ROOTCA-CA, ThisUpdate/NextUpdate 보이면 정상

3) 로컬 CA 저장소에 심고 재검증

certutil -addstore -f CA C:\temp\lab-ROOTCA-CA.crl
certutil -urlcache * delete
$c = Get-ChildItem Cert:\LocalMachine\My\<thumbprint>
$c.Verify()
# True

이제 sts → ISCA01은 HTTP CRL로, ISCA01 → ROOTCA는 로컬에 심은 CRL로 검증됨. DMZ 격리를 유지한 채 네트워크 없이 체인 전체가 통과함.


검증 — WAP 신뢰 등록과 외부 중계

체인이 잡힌 뒤 신뢰 등록과 외부 중계 검증.

# WAP 역할 설치
Install-WindowsFeature Web-Application-Proxy -IncludeManagementTools

# 프록시 신뢰 등록 (workgroup이면 도메인 접두어 필수)
$cred = Get-Credential lab\adm-admin
Install-WebApplicationProxy `
    -FederationServiceName "sts.lab.local" `
    -CertificateThumbprint "<thumbprint>" `
    -FederationServiceTrustCredential $cred
# Status: Success / DeploymentSucceeded

외부 중계 최종 확인 — WAP 경유로 ADFS 메타데이터가 나오는지:

curl.exe -v https://sts.lab.local/federationmetadata/2007-06/federationmetadata.xml
< HTTP/1.1 200 OK
< Content-Type: application/samlmetadata+xml
<EntityDescriptor ... entityID="http://sts.lab.local/adfs/services/trust">...

TLS 핸드셰이크가 에러 없이 끝나고 <EntityDescriptor>가 내려옴. 앞서 심은 ROOTCA CRL이 없었다면 이 단계에서 schannel이 신뢰 실패로 끊겼을 것.


정리 — 핵심 교훈

  1. 가설은 확인 전에 믿지 말 것. “방화벽이 막는다”는 그럴듯했지만 curl로 확인하니 아니었음. Verify()의 True/False만 보지 말고 certutil -verify로 체인 요소별 상태를 봐야 원인이 드러남.

  2. 체인 폐기검증은 leaf만이 아니라 각 단계를 다 확인함. leaf(ADFS)는 발급 CA의 CRL로, 발급 CA는 루트의 CRL로 검증됨. 중간 어느 한 단계라도 CRL을 못 구하면 전체가 REVOCATION_STATUS_UNKNOWN으로 보류됨.

  3. 오프라인 루트 + DMZ면 루트 CRL 배포가 필수. 오프라인 ROOTCA의 CRL을 발급 CA의 HTTP 배포지점에 publish하지 않으면, file:// CDP는 도메인 미가입·격리 환경에서 탈 수 없음. 이런 환경에서 문제가 먼저 나타남.

  4. 근본 해결은 ROOTCA CDP/AIA를 http로 재설계하는 것. 이번엔 로컬 심기로 우회했지만, 이는 CRL 만료(NextUpdate) 시 다시 깨지는 임시책임. 제대로 하려면 CAPolicy.inf/certutil -setreg로 ROOTCA의 CDP/AIA를 http 경로로 지정하고 루트 인증서를 재발급해 배포지점에 CRL을 올려둬야 함.

격리 자체가 검증을 막은 게 아니라, 원래 있던 배포 누락이 격리 환경에서 드러난 것뿐임. DMZ는 결함을 만든 게 아니라 노출시킨 쪽에 가까움.

Comments