"HTTPS는 어떻게 동작할까?"라는 질문을 파고들다 보니 생각보다 엮여 있는 개념이 많았습니다. 이번 글에서는 제가 공부하면서 헷갈렸던 순서를 그대로 따라가 보려고 합니다.
처음에는 "공개키로 암호화하면 되는 것 아닌가?"라고 생각했습니다. 그런데 곧 "그 공개키는 어떻게 믿을 수 있지?"라는 의문이 생겼고, 이어서 "중간에 누군가 끼어들면 어떻게 되지?"라는 질문으로 넘어가게 되었습니다. 이 흐름대로 하나씩 정리해 보겠습니다.
HTTP는 데이터를 평문 그대로 전송합니다. 클라이언트와 서버 사이에는 공유기, ISP, 라우터 같은 장비가 수없이 많은데, 그중 어느 지점에서든 패킷을 들여다볼 수 있다면 다음과 같은 문제가 생깁니다.
| 문제 | 설명 |
|---|---|
| 도청 | 비밀번호나 카드번호를 그대로 읽을 수 있습니다 |
| 변조 | 중간에서 내용을 바꿔치기할 수 있습니다 |
| 사칭 | 접속한 서버가 진짜인지 확인할 수 없습니다 |
이 문제를 해결하기 위해 HTTP를 TLS로 감싼 것이 HTTPS입니다. 참고로 SSL은 TLS의 이전 이름입니다. SSL은 보안 취약점 때문에 더 이상 사용되지 않고, 현재는 TLS 1.2와 TLS 1.3이 주로 쓰이고 있습니다.
대칭키 방식은 암호화할 때와 복호화할 때 같은 키를 사용합니다. 속도가 빠르다는 장점이 있지만, 이 키를 상대방에게 어떻게 안전하게 전달할 것인가라는 문제가 남습니다.
비대칭키 방식은 키가 한 쌍으로 이루어져 있습니다. 공개키는 누구에게나 공개해도 되고, 개인키는 주인만 보관합니다. 쓰임새는 크게 두 가지입니다.
다만 대칭키에 비해 연산이 느립니다.
그래서 TLS는 두 방식을 함께 사용합니다. 비대칭키로 안전한 연결을 준비하고, 실제 데이터는 빠른 대칭키(세션 키)로 주고받는 구조입니다.
가장 직관적인 방법은 다음과 같습니다.
Client Server
| | (1) generate key pair
| (2) generate session key |
| |
|<----------------------- (3) server public key --|
| |
|-- (4) Enc(public key, session key) ------------>|
| | (5) Dec(private key)
| | -> session key
| |
|<======= (6) encrypted with session key ========>|
누군가 중간에서 엿보더라도 암호화된 세션 키만 보일 뿐이고, 개인키가 없으니 풀 수 없습니다. 언뜻 보면 빈틈이 없어 보입니다.
참고로 실제 TLS에서는 세션 키를 그대로 보내지 않습니다. pre-master secret이라는 비밀값을 보낸 뒤, 양쪽이 이 값을 바탕으로 세션 키를 만들어 냅니다. 하지만 공개키로 암호화해서 보내고 개인키로 푼다는 원리 자체는 동일합니다.
문제는 3번 단계에 있습니다.
클라이언트에게는 받은 공개키가 진짜 서버의 것인지 확인할 방법이 없습니다. 바로 이 지점에서 MITM(Man-In-The-Middle), 즉 중간자 공격이 가능해집니다.
공격자가 공용 와이파이 같은 환경에서 클라이언트와 서버 사이에 끼어들었다고 가정해 보겠습니다.
Client Attacker Server
| | |
| |<------ server pub ------|
|<----- attacker pub -----| |
| | | (1) swap public key
| | |
|-- Enc(attacker pub, --->| |
| session key) | |
| | Dec(attacker priv) | (2)(3)
| | -> session key! |
| | |
| |--- Enc(server pub, ---->|
| | session key) | (4)
| | |
|<========== reads & modifies everything ==========>|
클라이언트와 서버는 모두 정상적으로 통신하고 있다고 믿지만, 실제로는 공격자가 대화 내용을 모두 읽을 수 있고 필요하면 바꿀 수도 있습니다.
여기서 주목할 점은 암호화 자체에는 아무 문제가 없었다는 것입니다. 빠져 있던 것은 "지금 누구와 암호화된 통신을 하고 있는가"에 대한 확인이었습니다.
그렇다면 신뢰할 수 있는 제3자가 "이 공개키는 naver.com의 것이 맞다"고 보증해 주면 됩니다. 이 역할을 하는 기관이 CA(Certificate Authority, 인증기관)입니다.
저는 처음에 "CA가 서버의 공개키를 암호화한다"고 잘못 이해하고 있었습니다. 실제로 서버의 공개키는 인증서 안에 그대로 노출되어 있습니다. CA가 하는 일은 암호화가 아니라 서명입니다. 내용을 숨기려는 것이 아니라, CA가 이 내용을 보증하며 중간에 변경되지 않았다는 사실을 증명하려는 것입니다.
+--------------------------------------------+
| Certificate (X.509) |
+--------------------------------------------+
| Version : v3 |
| Serial Number : 04:a1:9f:... |
| Issuer : DigiCert ... CA |
| Validity : 2026-01-01 ~ 2027-01-01 |
| Subject : www.naver.com |
| Public Key : server public key |
| Extensions : SAN: *.naver.com, ... |
+--------------------------------------------+
| Sig. Alg. : sha256WithRSAEncryption |
| Signature : Sign(CA private key, |
| hash(contents above)) |
+--------------------------------------------+
운영체제와 브라우저에는 신뢰할 수 있는 CA의 인증서(공개키)가 미리 내장되어 있습니다. 이를 루트 인증서 저장소라고 부릅니다. Microsoft, Apple, Google, Mozilla가 각자 기준을 정해 두고, 그 기준을 통과한 CA만 목록에 포함시킵니다.
검증은 다음 순서로 이루어집니다.
실제로는 루트 CA가 서버 인증서에 직접 서명하지 않고, 루트 CA에서 중간 CA를 거쳐 서버 인증서로 서명이 이어집니다. 이를 인증서 체인이라고 하며, 브라우저는 이 체인을 따라 올라가 루트 저장소에 있는 CA까지 도달하는지 확인합니다.
인증서는 공개된 정보이므로 공격자도 naver.com의 실제 인증서를 그대로 복사해 보여줄 수 있습니다. 하지만 공격자에게는 그 인증서에 담긴 공개키와 짝을 이루는 개인키가 없습니다.
CertificateVerify). 개인키가 없는 공격자는 이 서명을 만들어 낼 수 없습니다.결국 인증서는 "이 공개키가 naver.com의 것"임을 확인해 주고, 개인키를 실제로 사용할 수 있는지가 "지금 통신하는 상대가 그 주인"임을 확인해 줍니다. 두 가지가 모두 갖춰져야 신원 확인이 완료됩니다.
공격자가 시도할 수 있는 방법은 크게 두 가지인데, 어느 쪽도 성공하지 못합니다.
| 공격자의 시도 | 결과 |
|---|---|
| 자신의 공개키로 가짜 인증서를 만들어 제시 | 신뢰할 수 있는 CA의 서명이 없거나 도메인이 일치하지 않아 브라우저가 경고를 표시합니다 |
| 실제 인증서를 복사해서 제시 | 개인키가 없으므로 복호화도, 서명도 할 수 없습니다 |
RSA 키 교환에는 또 하나의 약점이 있습니다. 서버의 개인키 하나만 있으면 모든 세션 키를 풀 수 있다는 점입니다.
공격자가 오늘 오가는 암호화된 통신을 모두 저장해 두었다가, 몇 년 뒤 서버의 개인키를 탈취했다고 생각해 보겠습니다. 그러면 저장해 둔 세션 키를 복호화할 수 있고, 결과적으로 과거의 대화 내용까지 전부 해독할 수 있게 됩니다.
이 문제를 해결하기 위해 등장한 방식이 ECDHE입니다.
ECDHE의 아이디어는 세션 키를 암호화해서 전송하는 대신, 양쪽이 각자 계산해서 같은 값을 얻도록 하자는 것입니다.
작은 숫자로 직접 계산해 보면 쉽게 이해할 수 있습니다. 아래 예시는 원조 격인 DH 방식이며, ECDHE는 거듭제곱 대신 타원곡선 연산을 사용한다는 차이만 있을 뿐 원리는 같습니다.
① 미리 공개된 숫자
표준으로 정해져 있어 브라우저와 서버 코드에 이미 포함되어 있는 값입니다.
mod 23은 23으로 나눈 나머지를 의미합니다)② 각자 비밀 숫자 선택 (절대 전송하지 않음)
③ 공개값을 계산해서 교환
④ 받은 공개값에 자신의 비밀 숫자로 계산
양쪽 모두 2라는 같은 값을 얻었습니다. 이것이 공유 비밀값입니다.
같은 값이 나오는 이유는 간단합니다. 클라이언트가 계산한 19⁶은 (5¹⁵)⁶, 즉 5^(15×6)이고, 서버가 계산한 8¹⁵는 (5⁶)¹⁵, 즉 5^(6×15)입니다. 곱하는 순서만 다를 뿐 둘 다 5^(a×b)를 계산한 셈입니다.
반면 중간에서 엿본 사람은 5, 23, 8, 19만 알고 있습니다. 공유 비밀값을 구하려면 "5를 몇 번 거듭제곱해야 8이 되는가"를 풀어야 하는데, 실제로는 수백 자리에 달하는 큰 수를 사용하기 때문에 현실적으로 풀 수 없습니다.
ECDHE의 E는 Ephemeral, 즉 "일회용"을 뜻합니다. 비밀 숫자는 연결할 때마다 새로 생성되고, 연결이 끝나면 폐기됩니다.
또한 서버의 개인키는 더 이상 세션 키 계산에 쓰이지 않고 신원 증명(서명)에만 사용됩니다. 따라서 나중에 개인키가 유출되더라도 과거의 대화는 안전하게 보호됩니다. 이 성질을 전방 비밀성이라고 합니다.
| RSA 키 교환 | ECDHE | |
|---|---|---|
| 세션 키 | 클라이언트가 생성해 암호화 후 전송 | 전송하지 않고 양쪽이 각자 계산 |
| 서버 개인키의 역할 | 세션 키 복호화 | 신원 증명(서명)만 담당 |
| 개인키 유출 시 | 과거 대화까지 모두 노출 | 과거 대화는 안전 |
| TLS 1.3 | 제거됨 | 유일하게 남은 방식 |
그렇지 않습니다. ECDHE만으로는 중간자 공격을 막을 수 없습니다.
공격자가 클라이언트와는 자신의 공개값으로, 서버와는 또 다른 공개값으로 각각 키 교환을 진행하면 두 개의 연결을 따로 맺고 그 사이에서 중계할 수 있기 때문입니다. 4장에서 살펴본 공격과 구조가 똑같습니다.
그래서 TLS 1.3에서는 서버가 ECDHE 공개값이 포함된 핸드셰이크 전체에 개인키로 서명합니다. 공격자가 공개값을 바꿔치기하면 서명이 일치하지 않아 곧바로 드러나게 됩니다.
정리하자면 ECDHE는 도청을 막고, 인증서와 서명은 사칭을 막습니다. 안전한 통신을 위해서는 둘 다 필요합니다.
Client Server
| |
|-- ClientHello --------------------------------->|
| random, key_share (client ECDHE pub) |
| |
|<--------------------------------- ServerHello --|
| random, key_share (server ECDHE pub) |
| |
| [ both compute shared secret -> keys ] |
| |
|<------------------------------- {Certificate} --|
|<------------------------- {CertificateVerify} --|
|<---------------------------------- {Finished} --|
| |
|-- {Finished} ---------------------------------->|
| |
|<============== application data ===============>|
{...} = encrypted
| 단계 | 하는 일 |
|---|---|
| ClientHello / ServerHello | 사용할 방식을 협상하고, 랜덤값과 키 교환 재료를 주고받습니다 |
| 세션 키 계산 | 각자 공유 비밀값을 계산한 뒤 랜덤값과 조합해 세션 키를 만듭니다 |
| Certificate | 서버가 인증서를 보내고, 브라우저가 CA 체인을 따라 검증합니다 |
| CertificateVerify | 서버가 개인키로 서명해 인증서의 실제 주인임을 증명합니다 |
| Finished | 지금까지 주고받은 메시지가 변조되지 않았는지 서로 확인합니다 |
TLS 1.2에서는 핸드셰이크에 왕복이 두 번 필요했지만, TLS 1.3은 키 교환 재료를 첫 메시지에 바로 실어 보내면서 왕복 한 번(1-RTT)으로 줄였습니다. 세션 키가 더 일찍 만들어지기 때문에 인증서부터 암호화된 상태로 전송된다는 점도 달라진 부분입니다.
| 키 | 생성 시점 | 용도 |
|---|---|---|
| ECDHE 비밀 숫자 / 공개값 | 연결마다 새로 생성하고 폐기 | 공유 비밀값 계산 |
| 서버 개인키 / 공개키 | 서버가 미리 생성해 장기간 보관 | 신원 증명(서명) |
| 세션 키 | 핸드셰이크 중 양쪽이 계산 | 실제 데이터 암호화(대칭키) |
TLS가 중간자 공격을 막아 주기는 하지만, 다음과 같은 상황에서는 뚫릴 수 있습니다.
사용자가 인증서 경고를 무시하는 경우
"이 연결은 안전하지 않습니다"라는 경고 화면에서 "계속 진행"을 누르면 공격자의 인증서를 신뢰하게 됩니다.
공격자의 루트 인증서가 PC에 설치된 경우
악성 프로그램이 루트 저장소에 가짜 CA를 등록하면, 그 CA로 서명한 인증서는 모두 검증을 통과합니다. 사실 회사의 보안 프록시가 HTTPS 트래픽을 검사하는 것도 이와 같은 방식입니다.
CA가 해킹당하거나 인증서를 잘못 발급한 경우
이에 대비해 인증서 폐기 목록(CRL, OCSP)과 발급 기록을 공개하는 Certificate Transparency 같은 장치가 마련되어 있습니다.
처음 접속을 HTTP로 하는 경우 (SSL Stripping)
사용자가 http://로 접속하는 순간, 공격자가 HTTPS로의 전환을 가로막고 평문으로 중계할 수 있습니다. 서버는 HSTS 헤더를 통해 "앞으로는 반드시 HTTPS로만 접속하라"고 브라우저에 알려 이를 방지합니다.
지금까지의 내용을 짧게 정리하면 다음과 같습니다.
처음에는 단순히 "암호화해서 보낸다" 정도로만 알고 있었는데, 막상 들여다보니 누구와 통신하는지 확인하는 과정이 훨씬 큰 비중을 차지하고 있었습니다. 같은 부분에서 헷갈리셨던 분들께 이 글이 조금이나마 도움이 되었으면 합니다.