Step 160. SSL/TLS와 HSTS — 중간자를 막는 신분증 제도

Step 160. SSL/TLS와 HSTS — 중간자를 막는 신분증 제도

Level 2 — 네트워크 공격과 MITM | 난이도 ★★★☆☆ | 예상 소요 시간 3시간

전제: Step 85(HTTP vs HTTPS 비교)와 Step 156~159(ARP/DNS 스푸핑)를 마쳤다.

  • 준비물: 리눅스 터미널(WSL이면 충분), openssl. 외부 접속 없이 로컬 실습만으로 진행합니다.
  • 주의: ⚠️ 이 챕터의 실습은 내 랩·합법 플랫폼 전용입니다. 허가 없는 시스템에 적용하면 범죄입니다. 오늘 만드는 자체 서명 인증서는 학습용이며, 실제 서비스에 쓰면 브라우저가 경고를 띄웁니다. 이 챕터의 인증서 생성과 핸드셰이크 출력은 2026-09-09에 WSL 리눅스(OpenSSL 3.x)에서 실측했습니다.

지난 챕터들에서 여러분은 중간자가 되는 법을 배웠습니다 — ARP를 속이고, 트래픽을 가로채고, DNS까지 바꿔치기했습니다. 그런데 그 모든 공격 앞에 서 있는 벽이 하나 있습니다. 바로 TLS입니다. 오늘은 MITM의 관점이 아니라 그 벽의 관점에서 봅니다. 인증서가 왜 "사칭 불가능한 신분증"인지, 핸드셰이크가 실제로 어떻게 생겼는지, 그리고 그 벽마저 우회하려던 sslstrip을 HSTS가 어떻게 무용하게 만들었는지를 정리합니다. 직접 가짜 인증서를 만들어 브라우저가 왜 경고하는지를 몸으로 확인해 봅니다.


1. 학습 목표

이 챕터를 끝내면 다음을 할 수 있습니다:

  • TLS 핸드셰이크의 단계(Client Hello → 인증서 → 키 합의 → 암호문 통신)를 설명한다
  • 인증서 체인(루트 CA → 중간 CA → 사이트)과 자체 서명 인증서의 차이를 설명한다
  • openssl로 로컬 TLS 서버를 띄우고 s_client 출력을 해석한다
  • MITM 공격자가 왜 인증서 사칭·탈취에 목매는지 공격 관점에서 설명한다
  • HSTS가 sslstrip류 다운그레이드 공격을 막는 원리를 설명한다

2. 배경 지식 — 오늘의 도구와 개념

오늘의 도구 한눈에 보기

구분 내용
언어·환경 리눅스 터미널 + openssl (외부 네트워크 불필요)
오늘의 명령 openssl req -x509(자체 서명 인증서), openssl x509 -noout -subject -issuer(인증서 읽기), openssl s_server(실습용 TLS 서버), openssl s_client -connect(핸드셰이크 관찰)
필요한 개념 대칭키/공개키, 인증서와 CA, 핸드셰이크, 암호 스위트, HSTS, Step 85의 평문/암호문 대조
오늘의 산출물 자체 서명 인증서 1장 + 핸드셰이크 해석 메모 + TLS/HSTS 방어 구조도

2-1. TLS가 막는 것 — 내용과 사칭

Step 85에서 확인했듯 TLS는 내용을 암호문으로 바꿉니다. 그런데 암호화만으로는 구멍이 하나 남습니다. 내가 암호화해서 말하는 상대가 진짜 그 서버인가? MITM 공격자가 "내가 example.com이야, 나랑 암호화해서 얘기하자"라고 끼어들면, 피해자는 암호화된 채로 공격자와 이야기하게 됩니다.

이 구멍을 메우는 것이 인증서(certificate)입니다. 인증서는 "이 도메인의 주인이 이 공개키의 주인임을 보증한다"고 CA(Certificate Authority, 인증 기관)가 서명한 문서입니다. 브라우저는 미리 내장된 CA 명단(루트 저장소)으로 서명을 검증하고, 통과해야 자물쇠를 띄웁니다. 공격자가 스스로 만든 인증서는 CA 서명이 없으니 경고가 뜹니다 — 오늘 그 장면을 직접 만듭니다.

2-2. 인증서 체인 — 보증의 연쇄

브라우저가 믿는 루트 CA는 전 세계에 수십 개뿐이고, 루트는 직접 사이트 인증서에 서명하지 않고 중간 CA에 권한을 위임합니다. 그래서 서버는 보통 "사이트 인증서 + 중간 인증서"를 함께 보내고, 브라우저는 사이트 → 중간 → 루트 순으로 체인(chain)을 따라가며 검증합니다. 어느 고리라도 끊기거나 서명이 안 맞으면 경고입니다.

오늘 만들 자체 서명 인증서(self-signed certificate)는 발급자(issuer)와 주체(subject)가 같은 인증서 — "내가 나를 보증한다"는 선언입니다. 체인의 끝에 믿을 만한 CA가 없으므로 브라우저는 신뢰하지 않습니다.

2-3. 핸드셰이크 — 암호화 통로를 여는 의식

TLS 핸드셰이크는 통신 전에 벌어지는 협상입니다.

  1. Client Hello: 클라이언트가 "나는 이런 TLS 버전과 이런 암호 스위트를 지원한다"고 알립니다. 이때 SNI(접속하려는 서버 이름)가 평문으로 실립니다.
  2. Server Hello + 인증서: 서버가 "그럼 이 암호 스위트로 하자"고 고르고 인증서를 내밉니다.
  3. 키 합의: 둘이 이번 세션에서만 쓸 일회용 대칭키를 안전하게 합의합니다.
  4. 이후 모든 내용은 그 키로 암호화된 Application Data가 됩니다.

2-4. sslstrip과 HSTS — 다운그레이드 공격과 그 종말

과거의 유명한 우회가 sslstrip입니다. 사용자는 보통 주소창에 https://를 치지 않으므로 첫 접속은 HTTP입니다. MITM은 그 첫 HTTP 응답 속의 HTTPS 링크와 리다이렉트를 전부 HTTP로 바꿔치기해, 사용자가 계속 평문으로 통신하게 했습니다 — 암호화가 시작되기 전에 암호화로 가는 길을 막는 공격입니다.

HSTS(HTTP Strict Transport Security)는 이 길을 차단합니다. 서버가 Strict-Transport-Security 헤더를 한 번내면, 브라우저는 지정된 기간 동안 그 사이트에 HTTP로는 아예 접속을 시도하지 않고 주소를 스스로 HTTPS로 고쳐 씁니다. 바꿔치기할 첫 HTTP 응답이 성립하지 않게 되는 것입니다. 나아가 주요 사이트는 브라우저에 preload 목록으로 하드코딩되어 최초 방문부터 HTTPS가 강제됩니다.


3. 따라 하기

3-1. 자체 서명 인증서 만들기 — "내가 나를 보증한다"

입력 (리눅스/WSL):

mkdir -p tls-lab && cd tls-lab
openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 1 -nodes -subj "/CN=lab.local"
openssl x509 -in cert.pem -noout -subject -issuer -dates

출력 (2026-09-09 실측):

subject=CN = lab.local
issuer=CN = lab.local
notBefore=Sep  9 07:52:37 2026 GMT
notAfter=Sep 10 07:52:37 2026 GMT

읽는 법: subject(누구의 인증서인가)와 issuer(누가 보증했는가)가 둘 다 lab.local입니다 — 내가 나를 보증한, 자체 서명의 결정적 특징입니다. 유효 기간도 하루(-days 1)로 줬습니다. key.pem은 이 인증서와 짝이 되는 개인키이니 남에게 주지 마세요.

3-2. 로컬 TLS 서버 띄우기

방금 만든 인증서로 실습용 HTTPS 서버를 엽니다.

입력:

openssl s_server -accept 4433 -cert cert.pem -key key.pem -www &

읽는 법: -accept 4433은 4433번 포트에서 기다리라는 뜻이고, -www는 접속하면 간단한 상태 페이지를 돌려주는 실습 모드입니다. 브라우저로 https://localhost:4433에 접속해 보세요 — "이 사이트는 신뢰할 수 없습니다" 류의 경고가 뜹니다. 방금 만든 인증서의 issuer가 신뢰 명단에 없기 때문입니다. 이 경고가 2-1의 "사칭 방지"가 작동하는 장면입니다.

3-3. 핸드셰이크 관찰 — s_client의 눈

이번에는 브라우저 대신 openssl의 클라이언트로 접속해, 협상의 결과를 글자로 봅니다.

입력 (새 터미널):

echo | openssl s_client -connect 127.0.0.1:4433 -servername lab.local

출력 (2026-09-09 실측, 일부):

depth=0 CN = lab.local
verify error:num=18:self-signed certificate
verify return:1
CONNECTED(00000003)
---
Certificate chain
 0 s:CN = lab.local
   i:CN = lab.local
   a:PKEY: rsaEncryption, 2048 (bit); sigalg: RSA-SHA256
   v:NotBefore: Sep  9 07:52:37 2026 GMT; NotAfter: Sep 10 07:52:37 2026 GMT
---
SSL handshake has read 1337 bytes and written 391 bytes
Verification error: self-signed certificate
---
New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384

읽는 법: 네 군데를 보세요. ① verify error:num=18:self-signed certificate — 검증 실패, 사유는 "자체 서명". 신뢰 명단에 보증인이 없다는 2-2의 설명 그대로입니다. ② Certificate chains:(subject)와 i:(issuer)가 같음을 다시 확인합니다. ③ read 1337 bytes and written 391 bytes — 협상 동안 서버가 1337바이트(대부분 인증서)를 보냈고 클라이언트는 391바이트를 썼습니다. ④ New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384 — 최종 합의된 버전과 암호 스위트입니다. 핸드셰이크는 실패가 아니라 완료됐고, 다만 "신원 미검증" 상태라는 점이 핵심입니다. MITM 공격자가 내미는 가짜 인증서도 정확히 이 상태가 됩니다.

: 암호화는 성립했는데 신원 확인은 실패했습니다. "암호화됨"과 "신원 확인됨"이 별개라는 것 — 브라우저의 경고는 후자의 실패를 알리는 것입니다.

3-4. 진짜 사이트의 핸드셰이크와 비교하기

Step 85에서 공개 사이트를 캡처했을 때의 목록을 다시 봅니다 (Step 85, 2026-09-09 실측 재인용):

    4 ... 192.168.39.82 → 172.66.147.243 TLSv1   583 Client Hello (SNI=example.com)
    6 ... 172.66.147.243 → 192.168.39.82 TLSv1.3 3750 Server Hello, Change Cipher Spec
    7 ... 172.66.147.243 → 192.168.39.82 TLSv1.3 372  Application Data
   10 ... 192.168.39.82 → 172.66.147.243 TLSv1.3 146  Change Cipher Spec, Application Data

읽는 법: Client Hello의 SNI(누구에게 접속하는지)는 평문으로 보이지만, 그 뒤는 전부 Application Data — 암호문입니다. 또 목록에 Certificate 줄이 없습니다. TLS 1.3에서는 인증서조차 암호화 구간 안으로 들어가기 때문입니다(Step 85 3-4에서 확인). 오늘 3-3에서 텍스트로 본 협상이, 패킷 캡처에서는 이렇게 보입니다.

3-5. 인증서 속 깊이 들여다보기

3-1에서 만든 인증서의 내부를 텍스트로 펼쳐 봅니다.

입력:

openssl x509 -in cert.pem -noout -text | head -20

출력 (2026-09-09 실측, 일부):

Certificate:
    Data:
        Version: 3 (0x2)
        Serial Number:
            3b:cf:04:db:7a:86:00:83:4a:d8:a6:fc:f7:9b:69:da:ca:e1:5f:7b
        Signature Algorithm: sha256WithRSAEncryption
        Issuer: CN = lab.local
        Validity
            Not Before: Sep  9 07:52:37 2026 GMT
            Not After : Sep 10 07:52:37 2026 GMT
        Subject: CN = lab.local
        Subject Public Key Info:
            Public Key Algorithm: rsaEncryption
                Public-Key: (2048 bit)

읽는 법: 인증서의 정체가 보입니다 — 일련번호(Serial Number), 서명 알고리즘, 보증인(Issuer), 유효 기간, 주인(Subject), 그리고 주인의 공개키. "인증서 = 신원 + 공개키 + 보증인의 서명"이라는 2-1의 정의가 글자 그대로 들어 있습니다. 일련번호는 인증서를 폐기(revoke)할 때 "몇 번 인증서를 무효로 한다"고 지목하는 번호로 쓰입니다.

3-6. HSTS 헤더 읽는 법

HSTS는 서버의 응답 헤더 한 줄입니다.

Strict-Transport-Security: max-age=31536000; includeSubDomains

읽는 법: max-age=31536000은 "지금부터 1년(초 단위) 동안 이 사이트는 HTTPS만"이라는 선언이고, includeSubDomains는 서브도메인까지 같은 규칙을 적용하라는 뜻입니다. 이 헤더를 받은 브라우저는 이후 http://로 시작하는 주소를 사용자가 쳐도, 요청을 보내기 전에 스스로 https://로 고쳐 씁니다. sslstrip이 노리던 "첫 평문 접속"이 사라지는 순간입니다.

확인 실습: 브라우저 개발자 도구(F12) → Network 탭에서 아무 대형 사이트나 열어 응답 헤더를 보세요. 이 헤더가 있는지, max-age가 얼마인지가 그 사이트의 다운그레이드 방어 수준입니다.


4. 미션과 연습문제

미션 — TLS 방어 구조도와 핸드셰이크 해석

  1. 3-1~3-3을 재현하고, s_client 출력에서 네 값(검증 결과, issuer, 읽은 바이트 수, 합의된 암호 스위트)을 표로 정리하세요.
  2. 브라우저로 https://localhost:4433에 접속해 경고 문구를 기록하고, 그 경고가 막는 공격(사칭 MITM)을 한 문장으로 연결하세요.
  3. 공격자 관점 정리: MITM이 TLS를 우회하는 경로 세 가지(가짜 인증서 + 사용자의 경고 무시 유도, HTTP 다운그레이드, 인증서 탈취)를 쓰고, 각각을 막는 방어를 짝지으세요.
  4. 종이나 화이트보드 앱에 "질의 → 응답 → 접속" 흐름 위에 DNS 스푸핑이 뚫는 층과 TLS/HSTS가 막는 층을 그려 step160_tls_hsts.png(또는 .md의 텍스트 도식)로 저장하세요.

연습문제

문제 1. 자체 서명 인증서의 subject와 issuer가 같다는 것은 무엇을 의미하며, 브라우저가 이를 신뢰하지 않는 이유는 무엇인가요?

문제 2. 3-3에서 핸드셰이크는 완료됐는데도 "Verification error"가 떴습니다. "암호화 성립"과 "신원 확인"이 별개라는 것이 MITM 방어에서 왜 중요한가요?

문제 3. sslstrip이 노린 지점은 "암호화된 통신"이 아니라 "암호화로 넘어가는 순간"이었습니다. HSTS는 이 공격을 어떻게 무용하게 만드나요?

문제 4. HSTS에도 약점이 있습니다 — "브라우저가 그 사이트를 처음 방문하는 순간"입니다. preload 목록은 이 약점을 어떻게 보완하나요?


5. 모범 답안과 완료 기준

미션 모범 답안

1번 표 예 (2026-09-09 실측값):

항목 의미
검증 결과 verify error:num=18:self-signed certificate 신뢰할 보증인(CA) 부재
issuer CN = lab.local subject와 동일 — 자체 서명
읽은 바이트 1337 대부분 서버 인증서
암호 스위트 TLSv1.3 / TLS_AES_256_GCM_SHA384 협상된 버전과 암호

2번: 경고 문구 예 — "이 사이트의 보안 인증서를 신뢰할 수 없습니다"(브라우저마다 다릅니다). 이 경고는 "DNS나 ARP를 속여 가짜 서버로 유도해도, 그 서버가 진짜 도메인의 인증서를 내밀 수 없다"는 사실을 이용해 사칭 MITM을 막습니다.

3번 짝짓기 예: ① 가짜 인증서 → 브라우저 경고 + 사용자 교육(경고 무시 금지), ② HTTP 다운그레이드 → HSTS(+preload), ③ 인증서 개인키 탈취 → 키 관리와 폐기(OCSP/CRL), 정기 재발급.

검증하는 법: ① 표의 네 값이 실제 출력에서 옮겨졌는가. ② 경고 문구를 "귀찮은 팝업"이 아니라 "사칭 탐지 신호"로 해석했는가. ③ 세 우회 경로마다 방어가 정확히 짝지어졌는가. ④ 구조도에서 DNS 스푸핑(이름→주소 층)과 TLS(신원+내용 층)가 다른 층으로 그려졌는가.

연습문제 해답

문제 1 해답. 발급자가 자기 자신, 즉 "내가 나를 보증한다"는 선언임을 의미합니다. 브라우저가 신뢰하지 않는 이유는 검증의 끝에 미리 내장된 루트 CA가 없기 때문입니다 — 누구나 자기 서명 인증서를 만들 수 있으므로, 그것을 믿으면 사칭범의 인증서도 통과하게 됩니다.

문제 2 해답. 암호화는 "중간에서 내용을 못 읽게" 하지만, 상대가 누구인지는 말해 주지 않습니다. 신원 확인이 없으면 피해자는 공격자와 암호화된 채 대화하게 됩니다. 그래서 TLS는 암호화와 인증서 검증을 한 세트로 묶고, 브라우저는 검증 실패를 경고로 노출해 사람이 멈출 기회를 줍니다.

문제 3 해답. HSTS 헤더를 받은 브라우저는 지정 기간 동안 그 사이트에 HTTP 요청을 아예 보내지 않고 스스로 HTTPS로 고쳐 씁니다. MITM이 바꿔치기할 "첫 평문 응답"이 존재하지 않게 되므로, 다운그레이드의 발판 자체가 사라집니다.

문제 4 해답. HSTS는 헤더를 "한 번 받아야" 작동하므로, 첫 방문이 평문이면 그 한 번이 공격 창구입니다. preload 목록은 주요 사이트의 HSTS 정책을 브라우저 자체에 미리 심어 두어, 최초 방문조차 HTTPS로 시작하게 만듦으로써 그 창구를 닫습니다.

완료 기준 체크리스트

  • [ ] TLS 핸드셰이크의 4단계를 순서대로 설명할 수 있다
  • [ ] 인증서 체인(사이트 → 중간 → 루트)의 구조를 설명할 수 있다
  • [ ] 자체 서명 인증서를 만들고 subject=issuer임을 확인할 수 있다
  • [ ] s_client 출력에서 검증 결과와 암호 스위트를 읽을 수 있다
  • [ ] "암호화 성립 ≠ 신원 확인"을 설명할 수 있다
  • [ ] sslstrip의 원리와 HSTS의 대응을 설명할 수 있다
  • [ ] SNI 등 암호화 밖에 남는 정보가 무엇인지 안다

6. 흔한 실수와 해결

벽 1. "s_server가 포트를 못 열어요"

증상: 4433 대신 443처럼 낮은 포트를 쓰면 Permission denied류 오류가 납니다.
원인: 1024번 이하 포트는 관리자 권한이 필요합니다.
해결: 실습은 4433 같은 높은 포트를 쓰거나, sudo로 실행하세요.

벽 2. "브라우저 경고를 무시하고 들어갔는데 이게 안전한 건가요?"

증상: "고급 → 계속 진행"으로 자체 서명 사이트에 들어갑니다.
원인: 오늘 실습처럼 내가 방금 만든 인증서라면 괜찮습니다 — 신원은 내가 보증할 수 있으니까요.
해결: 단, 이 습관을 실제 인터넷에 들고 나가면 안 됩니다. 모르는 사이트의 인증서 경고를 무시하고 진행하는 것이 실전 MITM의 마지막 관문입니다. "경고는 무시하라고 있는 게 아니다"를 체감으로 남기세요.

벽 3. "s_client가 한참 멈춰 있어요"

증상: 접속 후 아무 출력도 더 없이 대기합니다.
원인: s_client는 대화형입니다 — 접속된 채로 입력을 기다리는 중입니다.
해결: HTTP 요청을 직접 치거나(GET / HTTP/1.0 후 Enter 두 번), 앞의 echo |처럼 입력을 즉시 닫아 버리세요. 나올 때는 Q 또는 Ctrl+C입니다.

벽 4. "인증서가 평문으로 안 보여요"

증상: 캡처에서 Certificate 패킷을 못 찾습니다.
원인: TLS 1.3에서는 인증서가 암호화 구간 안으로 들어갑니다(3-4). 오류가 아니라 최신 프로토콜의 개선입니다.
해결: 인증서 내용을 보고 싶으면 캡처가 아니라 3-3의 s_client 출력(텍스트)을 쓰세요. TLS 1.2로 강제하는 -tls1_2 옵션도 있지만, 목적이 "현재 표준 이해"라면 1.3 그대로가 맞습니다.

벽 5. "HSTS 헤더를 줬는데도 http로 접속돼요"

증상: 실습 서버에 헤더를 붙였는데 브라우저가 http 접속을 허용합니다.
원인: HSTS는 "HTTPS로 한 번 접속해서 헤더를 받은 뒤"에만 작동합니다. 자체 서명 경고를 무시하고 들어간 세션에서는 헤더를 신뢰하지 않는 브라우저도 있습니다.
해결: 정상 인증서 환경에서 확인하세요. 이 동작 자체가 "첫 방문의 창"이라는 HSTS의 한계를 보여 주는 좋은 관찰입니다.


7. 정리

오늘의 개념

개념 한 줄 설명
TLS HTTP를 암호화 통로에 넣는 프로토콜 — 내용 기밀성 + 신원 확인
인증서 "이 도메인 주인 = 이 공개키 주인"을 CA가 서명한 신분증
CA / 체인 신뢰의 뿌리 / 사이트→중간→루트로 이어지는 보증의 연쇄
자체 서명 인증서 발급자=주체인 인증서 — 학습용, 브라우저는 경고
핸드셰이크 버전·암호 스위트 협상 + 인증서 교환 + 세션키 합의
SNI Client Hello 속 접속 대상 이름 — 암호화 밖에 남는 정보
sslstrip HTTPS로 넘어가는 첫 순간을 HTTP로 바꿔치기하는 다운그레이드 공격
HSTS "이 사이트는 HTTPS만" 선언 — 다운그레이드의 발판을 제거
preload HSTS 정책을 브라우저에 내장 — 첫 방문의 창까지 닫음

오늘의 명령어

명령 하는 일
openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 1 -nodes -subj "/CN=이름" 자체 서명 인증서와 개인키 생성
openssl x509 -in cert.pem -noout -subject -issuer -dates 인증서의 주체·발급자·기간 읽기
openssl s_server -accept 4433 -cert cert.pem -key key.pem -www 실습용 TLS 서버
echo | openssl s_client -connect 127.0.0.1:4433 -servername 이름 핸드셰이크 관찰과 검증 결과 확인

명령어보다 중요한 감각

지난 챕터의 공격자는 전화번호부(DNS)를 바꿔치기할 수 있었습니다. 그런데 오늘 여러분이 직접 만든 가짜 신분증은 num=18 한 줄에 걸렸습니다. 공격과 방어가 어디서 싸우는지 이제 정확히 보입니다 — 이름의 층에서는 공격이 이기고, 신원의 층에서는 방어가 이깁니다.

그리고 마지막 변수는 사람입니다. 브라우저가 아무리 경고를 띄워도 사용자가 "계속 진행"을 누르면 모든 층이 무너집니다. 기술이 막아도 사람이 뚫는다 — 오늘 만든 그 경고 화면을, 앞으로 여러분이 설계할 모든 시스템에서 "사용자가 무시할 수밖에 없게 만들지 않는 법"의 출발점으로 기억하세요.


전부 체크되면 Step 160 완료입니다. 사이드바의 체크박스를 눌러 진도를 저장하세요.