Skip to content

필사 모드: TLS 핸드셰이크와 인증서 오류 해독 — 브라우저는 되는데 curl은 실패하는 이유

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

들어가며 — 브라우저는 자물쇠가 보이는데 curl은 실패합니다

새로 발급한 인증서를 배포했습니다. 브라우저로 열어 보니 자물쇠가 정상이고 인증서 정보도 문제가 없습니다. 그런데 백엔드에서 그 엔드포인트를 호출하면 이렇게 실패합니다.

curl -sS https://api.example.com/v1/health
curl: (60) SSL certificate problem: unable to get local issuer certificate
More details here: https://curl.se/docs/sslcerts.html

이 시점에 가장 흔한 결론은 "curl이 최신 CA를 모른다"입니다. 그래서 ca-certificates를 업데이트하고, 그래도 안 되면 -k를 붙이거나 애플리케이션에서 검증을 끕니다. 이것은 원인을 덮는 조치이고, 대개 서버 설정이 잘못된 상태로 운영에 들어가게 만듭니다.

진짜 원인은 거의 항상 하나입니다. 서버가 중간 인증서를 함께 보내지 않고 있고, 브라우저는 그것을 스스로 메워서 성공한 것입니다. 이 글은 그 메커니즘을 정확히 설명하고, 오류 메시지만 보고 원인을 지목할 수 있게 정리합니다.

TLS 1.3 핸드셰이크를 메시지 순서로 읽기

디버깅을 하려면 어떤 메시지가 어떤 순서로 오가는지 알아야 합니다. TLS 1.3은 왕복 한 번에 끝납니다.

클라이언트                                              서버
  |                                                      |
  |-- ClientHello ------------------------------------->  |
  |     supported_versions: TLS 1.3                       |
  |     key_share: x25519 공개키                          |
  |     server_name: api.example.com   (SNI, 평문)        |
  |     application_layer_protocol_negotiation: h2, http/1.1
  |                                                      |
  |  <------------------------------------ ServerHello -- |
  |     key_share: x25519 공개키                          |
  |     ---- 여기부터 모든 메시지가 암호화된다 ----        |
  |  <------------------------------- EncryptedExtensions |
  |  <----------------------------- CertificateRequest(*) |
  |  <------------------------------------- Certificate   |
  |  <------------------------------- CertificateVerify   |
  |  <------------------------------------------ Finished |
  |                                                      |
  |-- Certificate(*) ---------------------------------->  |
  |-- CertificateVerify(*) ---------------------------->  |
  |-- Finished ---------------------------------------->  |
  |-- Application Data (첫 HTTP 요청) ----------------->  |
  |                                                      |
  (*)는 mTLS를 요구할 때만 등장

여기서 운영에 직접 영향을 주는 사실이 세 가지 나옵니다.

첫째, ServerHello 이후의 모든 메시지가 암호화됩니다. TLS 1.2에서는 패킷 캡처만으로 서버 인증서를 꺼내 볼 수 있었지만 TLS 1.3에서는 불가능합니다. 와이어샤크에서 인증서가 안 보인다고 서버가 인증서를 안 보낸 것이 아닙니다. 인증서를 확인하려면 반드시 클라이언트 쪽에서, 즉 openssl s_client 같은 도구로 확인해야 합니다.

둘째, ClientHello의 SNI는 여전히 평문입니다. 나머지가 다 암호화되어도 접속 대상 호스트 이름은 노출됩니다. 이것이 Encrypted Client Hello가 등장한 이유이기도 합니다. 디버깅 관점에서는 오히려 유용합니다. 패킷 캡처에서 SNI만은 언제나 읽을 수 있습니다.

셋째, 클라이언트가 Finished와 첫 요청을 연달아 보냅니다. 서버가 클라이언트 인증서를 검증하기 전에 클라이언트는 이미 핸드셰이크가 끝났다고 판단합니다. 그래서 mTLS 인증 실패는 핸드셰이크 오류가 아니라 첫 요청을 보낸 직후의 연결 끊김으로 나타납니다. 이 차이가 mTLS 디버깅을 어렵게 만드는 핵심입니다.

SNI가 없으면 서버는 아무 인증서나 내밉니다

하나의 IP와 포트에 수백 개 사이트가 붙어 있는 것이 요즘 기본 구성입니다. 서버는 TCP 연결만으로는 어느 인증서를 내밀지 알 수 없습니다. 그 정보를 ClientHello의 SNI 확장이 알려줍니다.

SNI 없이 접속하면 서버는 기본 가상 호스트의 인증서를 돌려줍니다.

# SNI를 빼고 IP로 직접 접속
openssl s_client -connect 203.0.113.40:443 -noservername </dev/null 2>/dev/null \
  | openssl x509 -noout -subject
subject=CN = default.vhost.example.net
# SNI를 명시하면 올바른 인증서가 나온다
openssl s_client -connect 203.0.113.40:443 -servername api.example.com </dev/null 2>/dev/null \
  | openssl x509 -noout -subject
subject=CN = api.example.com

openssl s_client에서 가장 자주 나오는 실수가 여기 있습니다. -connect만 쓰고 -servername을 빼면 SNI가 전송되지 않아, 실제 서비스와 다른 인증서를 보고 엉뚱한 결론을 내리게 됩니다. 최신 OpenSSL은 -connect의 호스트 이름을 SNI로 자동 설정하지만, IP로 접속하거나 구버전을 쓸 때는 그렇지 않습니다. -servername은 항상 명시하는 습관이 안전합니다.

SNI를 보내지 않는 클라이언트가 실제로 존재합니다. 아주 오래된 자바 클라이언트, 일부 임베디드 기기, 그리고 일부 모니터링 에이전트가 그렇습니다. 이런 클라이언트만 인증서 오류를 내는 상황이라면 원인은 인증서가 아니라 SNI 미전송입니다. 서버 쪽 기본 가상 호스트를 무엇으로 둘 것인지가 곧 대응책이 됩니다.

캡처에서 SNI를 확인하는 방법도 알아 두면 좋습니다.

sudo tcpdump -nn -i any -A 'tcp port 443 and tcp[((tcp[12:1] & 0xf0) >> 2)] = 0x16' | head -40

체인 검증과 중간 인증서 누락 — 브라우저와 curl이 갈리는 지점

검증은 종단 인증서에서 시작해 발급자를 따라 올라가면서, 로컬 신뢰 저장소에 있는 루트에 도달하면 성공합니다. 서버의 책임은 루트를 제외한 중간 인증서를 모두 함께 보내는 것입니다.

정상적인 체인은 이렇게 보입니다.

openssl s_client -connect api.example.com:443 -servername api.example.com </dev/null 2>/dev/null \
  | sed -n '/Certificate chain/,/---/p'
Certificate chain
 0 s:CN = api.example.com
   i:C = US, O = Let's Encrypt, CN = R11
 1 s:C = US, O = Let's Encrypt, CN = R11
   i:C = US, O = Internet Security Research Group, CN = ISRG Root X1
---

중간 인증서가 빠진 서버는 이렇게 나옵니다.

Certificate chain
 0 s:CN = api.example.com
   i:C = US, O = Let's Encrypt, CN = R11
---
...
Verify return code: 20 (unable to get local issuer certificate)

0번만 있고 1번이 없습니다. 클라이언트는 R11이라는 발급자를 신뢰 저장소에서 찾아야 하는데, 신뢰 저장소에는 루트인 ISRG Root X1만 있고 중간 인증서 R11은 없습니다. 그래서 사슬이 끊깁니다.

그런데 브라우저는 왜 성공할까요. 여기가 핵심입니다.

종단 인증서 안에는 발급자 인증서를 내려받을 수 있는 URL이 들어 있습니다.

openssl s_client -connect api.example.com:443 -servername api.example.com </dev/null 2>/dev/null \
  | openssl x509 -noout -text | grep -A3 'Authority Information Access'
            Authority Information Access:
                OCSP - URI:http://r11.o.lencr.org
                CA Issuers - URI:http://r11.i.lencr.org/

크롬, 사파리, 엣지는 검증 중 발급자를 못 찾으면 이 CA Issuers URL로 HTTP 요청을 보내 중간 인증서를 직접 내려받습니다. 이것을 AIA fetching 또는 AIA chasing이라고 합니다. 파이어폭스는 조금 다른 방식으로, 알려진 중간 인증서를 미리 내장하고 한 번 본 중간 인증서를 캐시해 둡니다.

반면 OpenSSL은 기본적으로 AIA를 따라가지 않습니다. curl, wget, 파이썬 requests, Go, Node 대부분이 여기에 해당합니다. 자바도 기본은 따라가지 않고 com.sun.security.enableAIAcaIssuers 속성을 켜야 합니다.

그래서 다음 명제가 성립합니다. 브라우저에서 되는데 curl에서 실패한다면 클라이언트가 아니라 서버가 잘못된 것입니다. 브라우저는 서버의 실수를 대신 메워 주고 있을 뿐입니다. 게다가 AIA fetching은 네트워크 요청이므로 첫 접속마다 수십에서 수백 밀리초를 더 씁니다. 사용자 체감 지연으로도 이어집니다.

확인은 두 명령이면 끝납니다.

# 서버가 보내는 체인만으로 검증되는지 확인 (신뢰 저장소는 시스템 기본)
openssl s_client -connect api.example.com:443 -servername api.example.com \
  -verify_return_error </dev/null 2>&1 | tail -3

# 체인을 파일로 떠서 개수 확인
openssl s_client -connect api.example.com:443 -servername api.example.com -showcerts </dev/null 2>/dev/null \
  | grep -c 'BEGIN CERTIFICATE'
Verify return code: 0 (ok)
2

고치는 방법은 서버 설정입니다. nginx의 ssl_certificate에는 종단 인증서와 중간 인증서를 순서대로 이어 붙인 파일(fullchain)을 지정해야 합니다. 아파치는 최신 버전에서 SSLCertificateFile에 fullchain을 넣으면 되고, 구버전은 SSLCertificateChainFile을 따로 지정합니다. 로드밸런서라면 인증서 등록 화면의 체인 항목을 비워 두지 않았는지 확인합니다.

오류 메시지 사전

메시지만 정확히 읽어도 원인의 대부분이 결정됩니다.

오류 메시지실제 원인확인 명령조치
unable to get local issuer certificate서버가 중간 인증서를 안 보냈거나 로컬에 루트가 없음s_client 체인 개수 확인, 신뢰 저장소 존재 확인서버에 fullchain 배포, 또는 ca-certificates 설치
certificate has expirednotAfter 경과, 또는 클라이언트 시계가 어긋남openssl x509 -noout -dates, date 확인갱신, 또는 NTP 동기화
certificate is not yet validnotBefore 이전, 사실상 클라이언트 시계 문제date, timedatectl시각 동기화
hostname mismatch, no alternative subject name접속한 이름이 인증서 SAN 목록에 없음openssl x509 -noout -ext subjectAltNameSAN에 이름 추가, 또는 올바른 SNI로 접속
self signed certificate in certificate chain사내 CA 또는 가로채기 프록시가 중간에 있음체인 최상단 발급자 확인사내 루트를 신뢰 저장소에 등록
self signed certificate종단 인증서 자체가 자체 서명issuer와 subject가 같은지 확인정식 인증서 발급
tlsv1 alert protocol version클라이언트와 서버의 최소 프로토콜 버전이 겹치지 않음s_client에 -tls1_2, -tls1_3을 각각 지정해 비교서버 설정 조정 또는 클라이언트 갱신
sslv3 alert handshake failure공통 암호군 없음, 또는 mTLS에서 클라이언트 인증서 미제출openssl ciphers, s_client에 -cert와 -key 지정암호군 정렬 또는 클라이언트 인증서 제시
certificate required (alert 116)TLS 1.3 mTLS에서 클라이언트가 빈 인증서를 보냄서버 로그, s_client에 -cert와 -key 지정클라이언트 인증서 설정

두 가지 오해를 정정합니다.

hostname mismatch에서 현대 클라이언트는 CN 필드를 아예 보지 않고 subjectAltName만 검사합니다. CN에 도메인을 넣어 두었으니 괜찮다는 판단은 2017년 이후로는 틀렸습니다. 크롬은 58 버전부터 CN 폴백을 제거했고 OpenSSL과 Go도 마찬가지입니다. 반드시 SAN을 확인해야 합니다.

openssl s_client -connect api.example.com:443 -servername api.example.com </dev/null 2>/dev/null \
  | openssl x509 -noout -ext subjectAltName
X509v3 Subject Alternative Name:
    DNS:api.example.com, DNS:api-internal.example.com

certificate has expired도 자주 오진됩니다. 서버 인증서가 아니라 클라이언트 시계가 틀린 경우가 상당히 많습니다. 시계 없는 임베디드 기기, 오래 정지했다 부팅한 VM, 호스트 시각과 어긋난 컨테이너가 대표적입니다. 만료일과 현재 시각을 함께 확인하는 습관이 시간을 아껴 줍니다.

openssl s_client -connect api.example.com:443 -servername api.example.com </dev/null 2>/dev/null \
  | openssl x509 -noout -dates
date -u
notBefore=Jun  2 08:14:31 2026 GMT
notAfter=Aug 31 08:14:30 2026 GMT
Sun Jul 26 05:11:20 UTC 2026

openssl s_client로 확인하는 순서

한 번에 다 보는 명령을 외워 두면 편합니다.

HOST=api.example.com
PORT=443

openssl s_client -connect "${HOST}:${PORT}" -servername "${HOST}" \
  -showcerts -status -alpn h2,http/1.1 </dev/null 2>&1 | head -60

주요 출력은 이렇게 읽습니다.

CONNECTED(00000003)
depth=2 C = US, O = Internet Security Research Group, CN = ISRG Root X1
verify return:1
depth=1 C = US, O = Let's Encrypt, CN = R11
verify return:1
depth=0 CN = api.example.com
verify return:1
---
Certificate chain
 0 s:CN = api.example.com
   i:C = US, O = Let's Encrypt, CN = R11
 1 s:C = US, O = Let's Encrypt, CN = R11
   i:C = US, O = Internet Security Research Group, CN = ISRG Root X1
---
ALPN protocol: h2
OCSP response: no response sent
---
New, TLSv1.3, Cipher is TLS_AES_128_GCM_SHA256
Verify return code: 0 (ok)

Verify return code: 0 (ok)가 최종 판정입니다. depth가 높은 쪽부터 내려오는 verify 줄은 어느 단계에서 끊겼는지를 알려줍니다.

프로토콜별로 되는지 갈라 보는 것도 유용합니다.

openssl s_client -connect api.example.com:443 -servername api.example.com -tls1_3 </dev/null 2>&1 | grep -E 'New,|Cipher'
openssl s_client -connect api.example.com:443 -servername api.example.com -tls1_2 </dev/null 2>&1 | grep -E 'New,|Cipher'
New, TLSv1.3, Cipher is TLS_AES_128_GCM_SHA256
New, TLSv1.2, Cipher is ECDHE-RSA-AES128-GCM-SHA256

메일이나 데이터베이스처럼 STARTTLS를 쓰는 프로토콜은 옵션을 붙여야 합니다.

openssl s_client -connect smtp.example.com:587 -starttls smtp -servername smtp.example.com </dev/null
openssl s_client -connect db.example.com:5432 -starttls postgres </dev/null

curl로 볼 때는 다음 조합이 가장 정보량이 많습니다.

curl -v --resolve api.example.com:443:203.0.113.40 https://api.example.com/v1/health 2>&1 | grep -E '^\*'
* Server certificate:
*  subject: CN=api.example.com
*  start date: Jun  2 08:14:31 2026 GMT
*  expire date: Aug 31 08:14:30 2026 GMT
*  subjectAltName: host "api.example.com" matched cert's "api.example.com"
*  issuer: C=US; O=Let's Encrypt; CN=R11
*  SSL certificate verify ok.

--resolve는 DNS를 우회해 특정 IP로 접속하면서 SNI와 Host 헤더는 원래 이름으로 유지합니다. 로드밸런서 뒤의 개별 노드를 하나씩 검증할 때 필수입니다.

신뢰 저장소, 컨테이너, 그리고 mTLS의 실패 지점

검증이 실패하는 또 다른 축은 클라이언트 쪽 신뢰 저장소입니다. 배포판마다 경로와 갱신 명령이 다릅니다.

# 데비안, 우분투
ls -l /etc/ssl/certs/ca-certificates.crt
cp corp-root.crt /usr/local/share/ca-certificates/corp-root.crt
update-ca-certificates

# RHEL, 로키, 페도라
ls -l /etc/pki/tls/certs/ca-bundle.crt
cp corp-root.crt /etc/pki/ca-trust/source/anchors/
update-ca-trust extract

# 알파인
apk add --no-cache ca-certificates
cp corp-root.crt /usr/local/share/ca-certificates/
update-ca-certificates

컨테이너에서 가장 자주 나오는 사고는 최소 이미지에 신뢰 저장소가 아예 없는 경우입니다. Go 바이너리를 scratchalpine 위에 올리면 이렇게 실패합니다.

x509: certificate signed by unknown authority

인증서가 잘못된 것이 아니라 비교할 루트가 이미지 안에 하나도 없는 것입니다. 알파인이라면 ca-certificates 패키지를 설치하고, 정적 바이너리라면 distroless의 base 이미지를 쓰거나 멀티스테이지 빌드에서 인증서 묶음을 복사합니다.

FROM alpine:3.20 AS certs
RUN apk add --no-cache ca-certificates

FROM scratch
COPY --from=certs /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY app /app
ENTRYPOINT ["/app"]

런타임마다 신뢰 저장소를 가리키는 환경 변수가 다르다는 점도 기억할 만합니다.

export SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt   # OpenSSL, Go, curl
export REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt  # 파이썬 requests
export NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/corp-root.crt  # Node
# 자바는 별도 키스토어를 쓴다
keytool -importcert -alias corp-root -file corp-root.crt \
  -keystore "$JAVA_HOME/lib/security/cacerts" -storepass changeit -noprompt

self signed certificate in certificate chain 오류가 사내망에서만 난다면 원인은 대개 가로채기 프록시입니다. 회사가 TLS 검사 장비를 쓰면 모든 인증서가 사내 CA로 재서명되어 나옵니다. 체인 최상단 발급자를 보면 바로 드러납니다.

mTLS는 실패 지점이 두 배로 늘어납니다. 확인 순서는 이렇습니다.

# 서버가 클라이언트 인증서를 요구하는지, 그리고 어떤 CA를 받아 주는지 확인
openssl s_client -connect mtls.example.com:8443 -servername mtls.example.com </dev/null 2>&1 \
  | sed -n '/Acceptable client certificate CA names/,/^---/p'
Acceptable client certificate CA names
C = KR, O = Example Internal CA, CN = Example Issuing CA
Client Certificate Types: RSA sign, ECDSA sign

이 목록이 결정적입니다. 내 클라이언트 인증서의 발급자가 여기에 없으면 그 인증서로는 절대 통과하지 못합니다. 발급자를 확인합니다.

openssl x509 -in client.crt -noout -issuer -subject -dates
openssl verify -CAfile ca.crt client.crt
issuer=C = KR, O = Example Internal CA, CN = Example Issuing CA
subject=CN = payments-service
notBefore=Jul  1 00:00:00 2026 GMT
notAfter=Jul  1 00:00:00 2027 GMT
client.crt: OK

이제 클라이언트 인증서를 제시하고 접속합니다.

openssl s_client -connect mtls.example.com:8443 -servername mtls.example.com \
  -cert client.crt -key client.key -CAfile ca.crt </dev/null 2>&1 | tail -5

curl -sS --cert client.crt --key client.key --cacert ca.crt \
  https://mtls.example.com:8443/v1/health

앞에서 말한 TLS 1.3의 특성이 여기서 드러납니다. 클라이언트 인증서가 거부되어도 핸드셰이크는 성공한 것처럼 보이고, 오류는 첫 요청을 보낸 뒤에 나타납니다.

curl: (56) OpenSSL SSL_read: error:0A000412:SSL routines::sslv3 alert bad certificate, errno 0

클라이언트 로그만 보면 요청 전송 중 끊긴 것처럼 보이므로, mTLS 실패는 반드시 서버 로그와 함께 봐야 합니다. 확장 키 용도가 맞는지도 자주 놓치는 항목입니다. 서버 인증서에는 serverAuth, 클라이언트 인증서에는 clientAuth가 있어야 합니다.

openssl x509 -in client.crt -noout -ext extendedKeyUsage
X509v3 Extended Key Usage:
    TLS Web Client Authentication

마치며 — 클라이언트를 고치기 전에 체인을 세어 보십시오

인증서 문제의 판단 순서는 짧게 요약됩니다.

먼저 openssl s_client-servername을 반드시 붙여 접속하고, 출력의 Certificate chain에 몇 개가 나오는지 셉니다. 종단 인증서 하나만 나오면 그 순간 원인은 확정입니다. 서버가 중간 인증서를 안 보내고 있고, 브라우저가 AIA로 그것을 메워 주고 있었던 것입니다. 클라이언트를 손대는 대신 서버에 fullchain을 배포하면 끝납니다.

체인이 온전한데도 실패한다면 그때가 신뢰 저장소를 볼 차례입니다. 컨테이너에 ca-certificates가 있는지, 사내 CA가 등록되어 있는지, 런타임별 환경 변수가 맞는지 확인합니다.

그리고 검증을 끄는 선택지는 마지막까지 남겨 두어야 합니다. -kverify=False는 문제를 해결하지 않고 보이지 않게 만들 뿐이며, 그 상태로 배포되면 가로채기 공격에 그대로 노출됩니다. 대부분의 인증서 오류는 서버 설정 한 줄로 정확히 해결됩니다.

현재 단락 (1/201)

새로 발급한 인증서를 배포했습니다. 브라우저로 열어 보니 자물쇠가 정상이고 인증서 정보도 문제가 없습니다. 그런데 백엔드에서 그 엔드포인트를 호출하면 이렇게 실패합니다.

작성 글자: 0원문 글자: 11,361작성 단락: 0/201