LOTS-OResearch Notes

TL;DR

  • 인증서를 받은 컴퓨터는 발급자를 따라 인증서 체인을 올라가 자기 신뢰 저장소에 있는 루트 CA에 닿으면 인증서를 믿는다.
  • OpenSSL error 19 self-signed certificate in certificate chain은 서버가 보낸 체인 맨 위의 self-signed 루트 CA가 내 신뢰 저장소에 없다는 뜻이다.
  • 사내망의 TLS 인스펙션 장비처럼 사설 루트 CA로 다시 서명해 루트 CA까지 함께 보내면 사설 루트 CA를 따로 설치하지 않은 기기에서 error 19가 난다.
  • 해결하려면 서버가 보낸 체인 맨 위의 루트 CA를 내 신뢰 저장소에 추가한다.
  • 추가하기 전에 지문을 비교해 진짜 회사 루트 CA가 맞는지 확인한다.

인증서 체인과 self-signed 에러

curl이나 Python으로 HTTPS 주소에 접속하다 보면 다음 에러를 만날 때가 있다.

curl: (60) SSL certificate problem: self-signed certificate in certificate chain

에러 문구만 보면 상대 서버의 인증서에 결함이 있는 것처럼 보인다. 하지만 같은 주소가 다른 컴퓨터에서는 문제없이 열리기도 한다. 두 컴퓨터는 똑같은 인증서를 받지만 각자 믿는 루트 CA 목록이 달라 검사 결과도 다르게 나온다.


인증서 체인과 신뢰 저장소

HTTPS 사이트는 요청을 보낸 컴퓨터에 "나는 example.com이다"라는 내용이 담긴 인증서를 보낸다. 인증서는 누구나 만들 수 있으므로 받는 쪽은 누가 발급했는지를 보고 믿을지 정한다. 인증서를 발급하는 기관을 CA(Certificate Authority, 인증 기관)라고 하며 Let's Encrypt, DigiCert, Amazon 같은 곳이 있다. CA가 인증서를 발급한다는 것은 인증서에 자기 서명을 넣는다는 뜻이다.

인증서마다 주인(subject)과 발급자(issuer)가 적혀 있고 발급자의 인증서에도 다시 발급자가 적혀 있다. 발급자를 따라 이어진 인증서 묶음이 인증서 체인이다.

depth인증서발급자
0example.com (서버 인증서)중간 CA
1중간 CA루트 CA
2루트 CA루트 CA 자신

체인 맨 끝의 CA를 루트 CA라고 한다. 루트 CA는 위에 발급해 줄 기관이 없어 자기 인증서를 스스로 발급하므로 루트 CA 인증서가 self-signed인 것은 정상이다.

실제 사이트에 접속하면 체인이 이렇게 보인다(www.naver.com, 2026년 10월 기준).

$ echo | openssl s_client -connect www.naver.com:443 -showcerts 2>/dev/null | grep -E "^ *([0-9]+ )?[si]:"
 0 s:C = KR, ST = Gyeonggi-do, L = Seongnam-si, O = NAVER Corporation, CN = *.naver.com
   i:C = US, O = DigiCert Inc, CN = DigiCert Global G2 TLS RSA SHA256 2020 CA1
 1 s:C = US, O = DigiCert Inc, CN = DigiCert Global G2 TLS RSA SHA256 2020 CA1
   i:C = US, O = DigiCert Inc, OU = www.digicert.com, CN = DigiCert Global Root G2

서버는 두 장을 보낸다. 0번 서버 인증서의 발급자가 1번 중간 CA의 주인이다. 1번의 발급자인 루트 CA DigiCert Global Root G2는 서버가 보내지 않는다.

서버는 보통 self-signed인 루트 CA 인증서를 빼고 보낸다. 받는 쪽 신뢰 저장소에 이미 들어 있기 때문이다.1 루트 CA까지 함께 보내는 서버도 있지만 보낸 루트 CA가 내 신뢰 저장소에 있으면 문제없이 통과한다.

신뢰 저장소(trust store)는 믿을 만한 루트 CA의 목록이고 인증서를 받아서 검사하는 클라이언트(먼저 요청을 보낸 쪽)에 있다.

신뢰 저장소는 OS를 설치할 때 함께 들어오며 인증서와 달리 기기 밖으로 나가지 않는다. Ubuntu는 /etc/ssl/certs/ca-certificates.crt 한 파일에 모아 둔다.2 Windows는 인증서 관리자(certmgr.msc)의 "신뢰할 수 있는 루트 인증 기관"에 둔다.

클라이언트는 발급자를 따라 체인을 올라가 신뢰 저장소에 있는 루트 CA에 닿으면 인증서를 믿는다. 검증할 때는 유효기간과 도메인 이름도 함께 본다.


error 19의 뜻

curl이나 Python처럼 OpenSSL로 인증서를 검증하는 도구는 실패 이유를 번호와 문구로 알려 준다. error 19는 서버가 보낸 인증서로 체인을 맨 위까지 이었는데 신뢰 저장소에서 믿을 루트를 찾지 못했다는 뜻이다.3

문구의 self-signed는 체인 맨 위의 루트 CA를 가리킨다. 루트 CA는 원래 self-signed이므로 문제는 해당 루트가 내 신뢰 저장소에 없다는 데 있다. 같은 체인도 루트 CA를 가진 클라이언트에서는 통과하고 없는 클라이언트에서는 error 19가 난다.

self-signed와 함께 자주 보는 에러는 세 가지다.3

번호문구뜻
18self-signed certificate서버 인증서 자체가 self-signed이고 신뢰 저장소에 없다
19self-signed certificate in certificate chain서버가 루트까지 보냈는데 보낸 루트가 신뢰 저장소에 없다
20unable to get local issuer certificate체인을 잇는 데 필요한 발급자 인증서를 찾지 못했다

루트가 신뢰 저장소에 없는 같은 상황이라도 서버가 루트를 체인에 넣어 보내면 19, 넣지 않으면 20이 난다. 20은 서버가 중간 CA를 빠뜨렸을 때도 난다. OpenSSL 1.1.1 이하는 하이픈 없이 self signed certificate in certificate chain으로 출력한다.4


사설 루트 CA

OS와 브라우저의 신뢰 저장소에 처음부터 들어 있는 루트 CA를 공인 루트 CA라고 한다. 브라우저와 OS를 만드는 회사의 심사와 정기 감사를 통과해야 기본 목록에 실리고 목록은 OS나 브라우저 업데이트로 바뀐다.5

회사나 보안 프로그램이 직접 만들어 운영하는 루트 CA는 사설 루트 CA라고 한다. 누구나 만들 수 있고 심사를 거치지 않아 기본 목록에 없으므로 믿을 기기에 따로 설치해야 한다.

평소 여는 사이트는 공인 CA의 인증서를 쓰므로 사설 루트 CA를 만날 일이 드물다. 그런데 누군가 중간에서 HTTPS 내용을 검사하면 사설 루트 CA가 등장한다. 검사하는 쪽이 원래 인증서 대신 자기 사설 루트 CA로 다시 서명한 인증서를 내 컴퓨터에 보내기 때문이다.67

다시 서명하는 쪽검사하는 통신
회사 보안 장비사내망에서 밖으로 나가는 HTTPS
클라우드 보안 서비스서비스를 거치도록 설정된 직원 기기의 HTTPS (접속 위치와 관계없음)
HTTPS 검사 기능을 켠 백신백신이 설치된 기기의 HTTPS

셋 모두 다시 서명한 인증서가 통과하도록 검사 대상 기기에 자기 루트 CA를 설치해 둔다. 설치해 두지 않은 기기에서는 같은 사이트도 검증에 실패한다.


사내망의 TLS 인스펙션

사설 루트 CA를 만나는 대표적인 곳이 사내망이다. 회사 보안 장비가 회사 안에서 밖으로 나가는 HTTPS 연결의 내용을 검사하는 방식을 TLS 인스펙션이라고 한다. 악성 파일 다운로드나 회사 자료 유출을 걸러 내려는 목적이다. HTTPS는 내용이 암호화돼 있어 그대로 중계해서는 볼 수 없으므로 장비가 연결을 둘로 나눈다.

  • 직원 컴퓨터 ↔ 장비: 장비가 직원이 접속하려던 사이트인 것처럼 연결을 받는다.
  • 장비 ↔ 외부 사이트: 장비가 클라이언트가 되어 외부 사이트에 따로 접속하고 진짜 인증서를 검사한다.

장비는 두 연결 사이에서 내용을 풀어 검사하고 다시 암호화해 넘긴다. 진짜 인증서와 짝을 이루는 개인키(private key)는 해당 사이트만 갖고 있으므로 장비는 회사 CA로 같은 도메인 이름의 인증서를 새로 발급해 직원 컴퓨터에 내민다. 구조는 중간자 공격(MITM)과 같다.

사내망의 TLS 인스펙션에서 장비가 회사 루트 CA 인증서도 함께 보내는 경우다. 발급 기관을 회사 루트 CA로 바꾼 인증서가 두 기기에 똑같이 가고 신뢰 저장소에 회사 루트 CA가 있는 회사 노트북만 통과한다.

클라이언트가 받는 체인은 서버 인증서(장비가 발급) ← 회사 루트 CA 2단이거나 회사 중간 CA를 하나 더 낀 3단이다. 회사 루트 CA는 회사가 직접 만든 사설 루트 CA라서 OS 기본 신뢰 저장소에는 없다. 장비가 회사 루트 CA까지 체인에 넣어 보내면 error 19, 넣지 않으면 error 20이 난다.

회사가 관리하는 노트북에는 회사 루트 CA가 미리 설치되어 있어 문제가 없다. 하지만 직접 설치한 서버나 개인 노트북처럼 회사가 관리하지 않는 기기에는 회사 루트 CA가 없어 같은 주소에서 검증에 실패한다. 같은 인증서를 받아도 신뢰 저장소에 회사 루트 CA가 있는 기기만 통과한다.


진단과 해결

아래 명령은 에러가 난 Ubuntu 서버에서 실행하고 회사 루트 CA를 예로 든다. 다른 사설 루트 CA도 순서는 같다. 2단계는 상대 서버가 루트 CA까지 보내서 error 19가 났을 때 쓴다. error 20이면 상대 서버가 루트 CA를 보내지 않은 것이므로 루트 CA를 배포한 쪽에서 파일을 받아 3단계부터 진행한다.

1. 막힌 루트 CA 찾기

서버가 보낸 체인과 검증 결과를 출력해 어느 인증서에서 검증이 멈췄는지 본다.

echo | openssl s_client -connect <host>:443 -showcerts 2>&1 | \
  grep -E "depth=|verify error|Verify return code|^ *([0-9]+ )?[si]:"
  • Verify return code: 0 (ok)이면 검증을 통과한 것이다.
  • verify error:num=19가 나오면 바로 위 depth= 줄에 적힌 인증서가 신뢰 저장소에 없는 루트 CA다. error 19는 이 루트 CA가 self-signed라는 뜻이고 아래 인증서 목록에서는 주인(s:)과 발급자(i:)가 같게 보인다.
  • 루트의 주인(s:)에 적힌 이름으로 어느 쪽의 사설 루트 CA인지 짐작할 수 있다. 이름은 누구나 똑같이 붙일 수 있으므로 진짜인지는 3단계에서 지문으로 확인한다.

이 예에서는 인증서 목록의 1번이 체인 맨 위 루트 CA다. 주인과 발급자가 같은 self-signed 루트 CA가 신뢰 저장소에 없어서 error 19가 났다.

2. 루트 CA 인증서 저장

-showcerts는 받은 인증서를 PEM 형식(-----BEGIN CERTIFICATE-----로 시작하는 텍스트)으로 모두 출력하며 체인 맨 위 인증서는 보통 마지막에 나온다. 마지막 인증서만 chain-root.crt 파일로 저장한다.

echo | openssl s_client -connect <host>:443 -showcerts 2>/dev/null | \
  awk '/BEGIN CERTIFICATE/{c=""} {c=c $0 "\n"} /END CERTIFICATE/{last=c} END{printf "%s", last}' \
  > chain-root.crt

저장한 파일이 1단계에서 찾은 루트 CA인지는 주인과 발급자를 출력해 확인한다. 두 줄 모두 1단계의 루트 CA 이름이면 맞게 저장된 것이다.

openssl x509 -in chain-root.crt -noout -subject -issuer

3. 지문으로 진짜 회사 루트 CA인지 확인

신뢰 저장소에 넣은 루트 CA로 서명한 인증서는 어느 사이트 것이든 경고 없이 통과한다. 회사 것이 아닌 루트 CA가 들어가 있으면 루트 CA를 만든 쪽이 은행 이름으로 인증서를 발급해 가짜 서버에 붙여도 내 컴퓨터는 진짜 은행으로 믿는다. 넣기 전에 저장한 인증서의 SHA-256 지문(fingerprint, 인증서마다 다른 고유 번호)을 확인한다.

openssl x509 -in chain-root.crt -noout -fingerprint -sha256

출력된 지문을 진짜 회사 루트 CA의 지문과 비교한다. 진짜 지문은 회사 노트북에 설치된 회사 루트 CA에서 확인하거나 IT 부서에 요청해 받는다. Windows 인증서 관리자에 보이는 지문(thumbprint)은 SHA-1이므로 이때는 -sha256 대신 -sha1로 출력해 비교한다.8 값이 다르면 등록하지 않는다.

2단계에서 저장한 루트 CA와 진짜 회사 루트 CA의 지문을 나란히 놓고 비교한다. 아래쪽은 따로 받은 진짜 회사 루트 CA 파일로 같은 명령을 실행한 예다. 처음부터 끝까지 같을 때만 신뢰 저장소에 넣는다.

4. 신뢰 저장소에 등록

Ubuntu에서는 PEM 형식에 .crt 확장자인 파일을 /usr/local/share/ca-certificates/ 아래에 두고 update-ca-certificates를 실행한다.2 지문을 확인한 파일을 회사 루트 CA 이름으로 복사한다.

sudo cp chain-root.crt /usr/local/share/ca-certificates/company-root-ca.crt
sudo update-ca-certificates

출력에 1 added가 나오면 신뢰 저장소에 등록된 것이다.

5. 다시 접속해 확인

1단계 명령을 다시 실행해 Verify return code: 0 (ok)가 나오면 OpenSSL의 체인 검증을 통과한 것이다. 처음 실패했던 curl이나 Python 명령도 다시 실행해 확인한다.

도구마다 신뢰 저장소가 다를 수 있다. curl과 apt는 시스템 저장소를 쓰지만 Java는 자체 키 저장소(keystore)를, Python requests는 기본으로 certifi 패키지의 목록을 쓴다.9 snap 앱은 시스템 저장소에 넣은 인증서를 반영하지 않을 수 있다.10 시스템에 넣은 뒤에도 특정 도구만 실패하면 도구별 저장소를 따로 확인한다.


Connections

출처

  1. IETF, RFC 8446 TLS 1.3, 4.4.2 Certificate ↩︎

  2. Ubuntu Manpage, update-ca-certificates(8) ↩︎ ↩︎2

  3. OpenSSL 3.0 매뉴얼, X509_STORE_CTX_get_error ↩︎ ↩︎2

  4. OpenSSL 1.1.1 소스, crypto/x509/x509_txt.c ↩︎

  5. Mozilla, Mozilla Root Store Policy, 3.1 Audits ↩︎

  6. Durumeric et al., The Security Impact of HTTPS Interception, NDSS 2017 ↩︎

  7. Zscaler, Deploying SSL/TLS Inspection ↩︎

  8. Microsoft Learn, X509Certificate2.Thumbprint Property ↩︎

  9. Requests 문서, Advanced Usage: CA Certificates ↩︎

  10. Ubuntu Server 문서, Install a root CA certificate in the trust store ↩︎