TLS 핸드셰이크에서 HSTS와 Triple Handshake까지

2026.10.11
대표 이미지

(Claude Opus 5.5와 대화하며 이해한 과정을, Claude Opus 5.5가 글로 옮겨 작성함)


주소창 자물쇠 뒤에서는 TLS라는 약속이 돌아가요. 이 글에서는 브라우저와 서버가 처음 만나 둘만 아는 키를 나눠 갖는 과정(핸드셰이크)을 따라가 보고, 그 과정의 빈틈과 보완책(TLS 1.3, HSTS, Triple Handshake)까지 이어서 정리해 볼게요.


![등장인물.png](https://pub-6ece89a8bceb4744ab36f4c6c2fed32a.r2.dev/684e6a789552b75f411685fa/1791719802483-662046a6-5fda-4b78-9db2-1435d9d93781.png)등장인물.png


등장인물은 셋이에요. 파란 자물쇠는 클라이언트(브라우저), 주황 자물쇠는 서버, 보라색은 도청자예요. 도청자는 둘 사이를 오가는 메시지를 전부 볼 수 있다고 생각하고 따라가 볼게요.


1. 핸드셰이크: 둘만 아는 키 만들기


TCP 연결이 막 끝났을 때 클라이언트와 서버는 공유하는 비밀이 하나도 없어요. 핸드셰이크의 목표는 도청자가 다 보는 앞에서 **둘만 아는 대칭 세션키**를 만드는 거예요. 지금 가장 많이 쓰는 ECDHE 방식의 TLS 1.2를 기준으로 볼게요.


![핸드셰이크.png](https://pub-6ece89a8bceb4744ab36f4c6c2fed32a.r2.dev/684e6a789552b75f411685fa/1791719804239-0828b035-0d75-45fd-bd0b-296f0c7883b3.png)핸드셰이크.png


인사


클라이언트가 먼저 "나는 이런 암호 방식들을 쓸 수 있어"라며 **암호 스위트 목록**과 **client random**을 보내요(ClientHello). 서버는 그중 하나를 골라 **server random**과 함께 답해요(ServerHello).


암호 스위트는 키 교환, 서명, 암호화, 해시 방식을 한 묶음으로 정한 거예요. client random과 server random은 이번 연결을 위해 새로 뽑은 32바이트 숫자인데, 평문이라 도청자도 봐요. 비밀이 아니라 "이번 연결"이라는 표시예요. 3장에서 다시 만나요.


서버의 신분증과 공개값


서버는 이어서 **인증서**를 보내요. 인증서는 "이 공개키는 이 도메인의 것"이라고 CA(인증 기관)가 서명해 준 신분증이에요.


그리고 서버는 자기만 아는 **서버 비밀값**을 뽑고, 거기서 계산한 **서버 공개값**을 보내요. 이 공개값에는 서버 개인키로 서명을 붙이는데, 서명할 때 두 랜덤값도 같이 넣어요. 그래서 예전 연결의 서명을 녹화해 와도 이번 연결에서는 쓸 수 없어요.


서명은 개인키로만 만들 수 있고, 짝이 되는 공개키로 누구나 확인할 수 있는 도장이에요. 아직 둘이 공유한 키가 없는 때라, 공유 키로 만드는 도장(HMAC) 대신 서명을 써요.


클라이언트의 확인


클라이언트는 받은 인증서를 확인해요.


![인증서_체인.png](https://pub-6ece89a8bceb4744ab36f4c6c2fed32a.r2.dev/684e6a789552b75f411685fa/1791719804885-cc6441f9-8457-4dc8-9e18-ee906517af96.png)인증서_체인.png


  • 인증서에 서명한 CA를 따라 올라가면, 컴퓨터에 **처음부터 깔려 있는 루트 CA**에 닿는지

  • 인증서의 도메인이 지금 접속한 주소와 같은지, 유효기간 안인지

  • 인증서 속 공개키로 서버 공개값의 서명을 확인했을 때 맞는지


루트 CA는 자기 인증서에 스스로 서명해요. 그러니 루트를 믿는 근거는 서명이 아니라 "OS·브라우저에 미리 설치돼 있다"는 사실이에요. 네트워크로 받으면 중간에 바꿔치기당할 수 있으니 처음부터 깔아 두는 거죠.


이 확인 덕분에 도청자가 인증서 속 공개키를 바꾸면 CA 서명이 깨지고, 서버 공개값을 바꾸면 서버 서명이 깨져서 바로 들켜요.


확인이 끝나면 클라이언트도 **클라 비밀값**을 뽑고, **클라 공개값**만 서버에 보내요.


키 만들기


이제 양쪽이 각자 계산해요. 클라이언트는 "클라 비밀값 + 서버 공개값"으로, 서버는 "서버 비밀값 + 클라 공개값"으로 계산하는데, 신기하게도 같은 값이 나와요. 이게 **premaster**예요. 왜 같은지는 2장에서 볼게요.


![키_만들기.png](https://pub-6ece89a8bceb4744ab36f4c6c2fed32a.r2.dev/684e6a789552b75f411685fa/1791719805504-e32ba176-7b6d-4415-86a8-e1ebf24fa946.png)키_만들기.png


premaster에 두 랜덤값을 섞어 **master**를 만들고, master에 두 랜덤값을 한 번 더 섞어 **세션키**를 만들어요. 섞을 때는 PRF라는 함수를 쓰는데, 입력을 버무려 정해진 길이의 값을 뽑아 줘요. 세션키는 방향별로 하나씩(클라→서버, 서버→클라) 나오고, 둘 다 양쪽이 알고 있으니 대칭키예요.


비밀은 premaster가 지켜요. 랜덤값은 공개라서 비밀을 보태 주지는 않아요.


Finished로 마무리


마지막으로 양쪽이 **Finished**를 하나씩 보내요. Finished는 master를 키로 써서, 지금까지 주고받은 대화 기록(인증서 포함) 전체의 해시를 버무린 값이에요. 세션키로 암호화해서 보내면, 받은 쪽은 자기 기록으로 같은 계산을 해서 비교해요.


누가 중간에 평문 구간(예: 암호 스위트 목록)을 슬쩍 고쳤다면 양쪽 기록이 달라서 값이 안 맞아요. master를 모르는 도청자는 이 값을 위조할 수도 없고요. 값이 맞으면 상대도 같은 키를 만들었다는 뜻이니, 이제부터 데이터는 세션키로 암호화해서 주고받아요. 여기까지 왕복 두 번(2 RTT)이 걸려요.


2. 공개값만 주고받았는데 왜 같은 값이 나올까 (DH, ECDHE)


작은 숫자로 보면 금방 이해돼요. 공개 규칙은 "5를 비밀값만큼 거듭 곱하고, 23으로 나눈 나머지"예요.


![DH.png](https://pub-6ece89a8bceb4744ab36f4c6c2fed32a.r2.dev/684e6a789552b75f411685fa/1791719806191-a691a24e-257e-48c5-b5e1-325c76624a56.png)DH.png


클라이언트는 비밀값 6으로 8을, 서버는 비밀값 15로 19를 만들어 서로 교환해요. 클라이언트가 19를 6번 곱하든 서버가 8을 15번 곱하든, 결국 둘 다 "5를 6×15번 곱한 것"이라 똑같이 2가 나와요.


도청자는 8과 19를 다 봤지만 2를 만들 수 없어요. 8에서 비밀값 6을 거꾸로 찾아야 하는데, 나머지만 남기는 순간 원래 수가 얼마나 컸는지가 사라지거든요. 시계를 보고 3시인 건 알아도 바늘이 몇 바퀴 돌았는지는 모르는 것처럼요. 23 정도면 하나씩 대입해서 찾겠지만, 실제로는 수백 자리 숫자를 써서 현실적인 시간 안에는 못 찾아요. 이걸 **이산 로그 문제**라고 해요.


**ECDHE**는 이름을 쪼개 보면 쉬워요.


  • **DH**: 방금 본 원리

  • **EC**(타원곡선): 거듭 곱하기 대신 곡선 위의 점을 반복해서 옮겨요. 몇 번 옮겼는지 거꾸로 알기 어렵다는 건 같은데, 일반 DH에 통하는 지름길이 여기선 안 통해서 훨씬 짧은 키로 같은 안전도를 내요. 256비트가 일반 DH·RSA의 3072비트급이에요.

  • **E**(Ephemeral, 일회용): 비밀값을 연결마다 새로 뽑고, 쓰고 나면 버려요.


3. 공개된 랜덤값은 왜 굳이 섞을까


비밀은 premaster가 지킨다면서 랜덤값은 왜 섞을까요? 사실 정상적인 ECDHE에서는 비밀값을 매번 새로 뽑으니 premaster가 이미 연결마다 달라서, 랜덤값의 일이 겹쳐요.


랜덤값이 진짜 일하는 건 **premaster가 매번 새로 생긴다는 보장이 없을 때**예요. 서버가 귀찮아서 비밀값을 재사용하거나, 4장의 세션 재개처럼 premaster를 아예 안 만들 때요. 이럴 때 세션키까지 같아지면, 공격자는 키를 몰라도 녹화해 둔 예전 메시지를 그대로 다시 보내서 한 번 더 실행시킬 수 있어요. 이걸 **재생 공격**이라고 해요. 연결마다 새 랜덤값을 섞어 두면 세션키가 항상 새로 나오니 이런 재활용이 안 통해요.


덤으로, 같은 키를 여러 연결에서 쓰면 같은 키·같은 번호(nonce) 조합이 생겨서 AES-GCM 같은 암호화가 깨지는 문제도 막아 줘요.


4. 다시 만날 땐 빠르게 (세션 재개)


같은 서버에 다시 접속할 때마다 처음부터 핸드셰이크를 하면 느리겠죠. 그래서 첫 핸드셰이크 때 서버가 **세션 ID**를 발급하고, 그 ID와 master를 짝지어 저장해 둬요.


다시 올 때 클라이언트가 세션 ID를 내밀면 서버는 저장해 둔 master를 꺼내요. 인증서 확인과 키 교환은 건너뛰고 **랜덤값만 새로 주고받아서** 세션키를 만들어요. master는 그대로지만 랜덤값이 새것이라 세션키는 매번 달라져요. 3장의 랜덤값이 바로 여기서 제 몫을 해요.


5. RSA 키 교환이 밀려난 이유


TLS 1.2에는 ECDHE 말고 **RSA 키 교환**도 있었어요. 클라이언트가 premaster를 혼자 만들어서, 인증서 속 서버 공개키로 잠가 보내는 방식이에요.


![RSA_ECDHE.png](https://pub-6ece89a8bceb4744ab36f4c6c2fed32a.r2.dev/684e6a789552b75f411685fa/1791719806859-3289baed-e83c-40c4-8ef7-c8e71de44541.png)RSA_ECDHE.png


문제는 premaster와 공개 랜덤값만 있으면 master가 나온다는 거예요. 누군가 오늘 통신을 통째로 녹화해 뒀다가 몇 년 뒤 서버 개인키가 유출되면, 잠가 보낸 premaster를 풀 수 있어요. 그러면 과거 통신이 전부 풀려요.


ECDHE에서는 premaster를 보내지 않고 양쪽이 각자 계산해요. 비밀값은 쓰고 버리고, 서버 개인키는 서명에만 써요. 그래서 나중에 개인키가 털려도 과거 premaster를 다시 만들 수 없어요. 이 성질을 **forward secrecy**라고 해요.


6. TLS 1.3에서 달라진 것


![TLS13.png](https://pub-6ece89a8bceb4744ab36f4c6c2fed32a.r2.dev/684e6a789552b75f411685fa/1791719807562-1d2650c7-5a0e-4bc0-8ce2-5174c50a9e0c.png)TLS13.png


  • **왕복이 한 번으로**: 클라이언트가 인사할 때 서버가 고를 만한 곡선을 짐작해서 클라 공개값을 미리 실어 보내요. 그래서 2 RTT가 1 RTT로 줄었어요.

  • **ECDHE만**: RSA 키 교환을 빼서 모든 연결이 forward secrecy를 가져요.

  • **인증서도 암호화**: ServerHello 직후에 키를 만들 수 있으니 인증서부터는 암호화해서 보내요.

  • **대화 기록을 섞는 키**: 키를 만들 때 랜덤값만이 아니라 대화 기록 전체의 해시(인증서 포함)를 섞어요. master에 첫 세션의 고유한 대화가 녹아 들어가니, 세션을 재개해서 대화가 똑같아 보여도 첫 세션이 다른 연결이면 Finished가 달라져요. 9장의 공격을 막는 장치예요. 1.2에도 같은 수정이 확장으로 들어갔어요.

  • **재협상 삭제**: 연결 도중에 핸드셰이크를 다시 하는 기능을 없앴어요.


7. TLS가 지켜 주지 않는 것


TLS가 지키는 건 **기밀성**(못 엿봄)과 **무결성**(몰래 못 바꿈)이에요. **가용성**(통신이 되는 것)은 못 지켜요. 길목의 공격자가 패킷을 버리거나 연결을 끊는 건 막을 수 없어요. 대신 확인에 실패하면 그냥 끊어 버려요. 속는 것보다는 끊기는 게 낫다는 거죠.


8. HSTS (HTTP Strict Transport Security)


주소창에 `naver.com`만 치면 첫 요청은 사실 `http://`로 나가요. 이 순간을 노리는 게 **SSL stripping**이에요. 공격자가 첫 요청을 가로채서 서버와는 HTTPS로, 사용자와는 HTTP로 통신해요. 사용자는 평문으로 통신하는 줄도 모르고, 공격자는 내용을 다 봐요.


![HSTS.png](https://pub-6ece89a8bceb4744ab36f4c6c2fed32a.r2.dev/684e6a789552b75f411685fa/1791719808211-3d197637-1a01-4247-9929-7a10078c7226.png)HSTS.png


이걸 막는 게 **HSTS**예요. 서버가 HTTPS 응답에 `Strict-Transport-Security` 헤더를 붙이면, 브라우저는 정해진 기간 동안 그 도메인에 HTTP 요청을 아예 보내지 않고 알아서 https로 바꿔요.


공격자가 헤더를 빼 버리면 어떨까요? HTTPS 응답은 무결성 때문에 손댈 수 없고, HTTP로 받은 HSTS 헤더는 원래 무시돼요. 남는 빈틈은 헤더를 한 번도 못 받은 **첫 방문**뿐인데, 이건 브라우저에 미리 박아 둔 **preload 목록**으로 막아요.


9. Triple Handshake (2014)


마지막은 TLS 1.2에서 실제로 나온 공격이에요. 먼저 알아 둘 게 세 가지 있어요.


  • **클라이언트 인증서**: 은행처럼 엄격한 곳은 사용자도 인증서와 개인키를 갖고, 서버가 요구하면 대화 기록에 서명해서 본인임을 증명해요.

  • **재협상**: 연결된 상태에서 핸드셰이크를 다시 하는 거예요. 은행은 로그인이 필요한 작업이 들어오면 그때 재협상해서 인증서를 받았어요.

  • **2009년의 방어**: 다른 연결의 핸드셰이크를 재협상인 척 끼워 넣는 공격이 있어서, 재협상할 때 "이 연결의 직전 Finished 값"을 서로 확인하게 바뀌었어요.


![트리플.png](https://pub-6ece89a8bceb4744ab36f4c6c2fed32a.r2.dev/684e6a789552b75f411685fa/1791719808909-474df457-ff91-4441-825f-5ecc25ea41d8.png)트리플.png


사용자가 악성 사이트 evil.com에 접속하면, evil.com은 은행에도 접속해요. 목표는 evil.com이 넣은 요청을 은행이 사용자 이름으로 처리하게 만드는 거예요.


  1. **master 맞추기**: evil.com이 랜덤값과 premaster를 양쪽 연결에 그대로 복사해요(RSA 방식). 두 연결의 master가 같아져요. 다만 오간 인증서가 달라서(evil.com 것과 은행 것) Finished는 아직 달라요.

  2. **Finished 맞추기**: 양쪽 다 세션을 재개해요. 재개에는 인증서가 오가지 않으니 대화 기록까지 같아지고, Finished도 같아져요.

  3. **서명 가로채기**: evil.com이 은행에 자기 요청(예: 송금)을 넣어 두면 은행이 인증을 요구해요. evil.com은 이걸 사용자에게 그대로 중계하고, 사용자는 자기가 받은 메시지에 서명해요. 메시지에 은행 인증서가 들어 있어서 evil.com이 손댈 수 없으니 그대로 넘기는 거예요. 은행은 직전 Finished를 확인하지만 2단계에서 같아졌으니 통과! evil.com의 요청이 사용자 이름으로 처리돼요.


원인은 master가 premaster와 공개 랜덤값만으로 정해져서 복사할 수 있었다는 거예요. 두 연결을 구분해 줄 인증서는 master에 들어가지 않았죠. 그래서 master에 대화 기록 전체의 해시를 섞도록 고쳤어요(Extended Master Secret). 1.3이 처음부터 이렇게 키를 만들고 재협상까지 없앤 이유예요.


생략한 것


  • ChangeCipherSpec 메시지, 세션키 묶음 안의 MAC 키·IV

  • 1.3의 데이터 암호화 방식(AEAD만 허용), 키 계산(HKDF), 재개 방식(PSK)

  • 인증서 검증이 뚫리는 경우: CA 해킹(Certificate Transparency로 대응), 사용자 PC에 공격자의 루트 CA가 설치된 경우