TLS 연결 디버깅: Forward Secrecy 재확인하기

앞선 글과 같이 쓰려고 했던 내용 정리. 글이 길어지고 쓸 시간도 모자라서 쪼개서 옮긴다.

SSL/TLS 연결로 메시지를 주고 받는 서버/클라이언트를 디버깅하는 작업이라 우선 wireshark 를 써서 덤프를 떴다. 아래 내용은 해당 디버깅 세션의 덤프는 아니고, 임의로 비슷한 TLS 설정을 쓰고,  거기서 HTTP GET을 보내고 응답을 받는 과정을 실행해서 해당 덤프를 얻었다. 이 메시지 덤프를 분석해서 “왜 이 메시지를 wireshark 에서 디코딩 할 수 없는지” 그리고 “TLS의 이런 기능 덕분에 우리의 사생활이 더 잘 키져지는지” 를 정리해보겠다.

TLS 메시지를 wireshark 에서 분석하려면 서버의 비밀키 (private key) 를 등록해야한다. Wireshark UI 상에서 추가하거나 (설정의 프로토콜 ssl 이하) ~/.config/wireshark/ssl_keys 파일에 디코딩할 서버 주소 (IP:port) 와 키에 해당하는 .pem 파일 위치를 지정하면 끝.

TLS 패킷 덤프 열어보기

이 설정으로 wireshark 에서 열어보면 아래처럼 분석이 불가능한 내용이 나온다.

비밀키를 알고 있는데 왜 분석을 할 수 없을까? 이는 TLS의 특정 키 교환 방식 때문이다. 실제로 어떤 메시지를 주고 받았는지 확인해보면 아래와 같다.

서버가 보낸 TLS 설정: Ephemeral ECDH 키교환 / RSA 로 키 교환 서명 / AES-256-GCM 으로 블럭 암호화 (AEAD) / SHA2 – 384 메시지 체크섬

서버와 클라이언트 사이에 사용할 암호화 키(=세션 키)를 서버가 바로 정해서 보내는게 아니라 타원곡선 (elliptic curve; EC) 을 사용한 디피-헬만 (DH) 키 교환 알고리즘을 이용하고, 이를 완전히 랜덤하고 이 세션에서만 쓰고 버릴 데이터를 이용하는 (ephemeral) 방식을 썼다. 즉, 서버의 비밀키는 세션 키와는 관련성이 없다.
서버의 비밀키는 서버가 ECDH 를 위한 메시지를 보낼 때, 해당 메시지를 서버가 보냈다고 전자 서명하는데 썼다. 이 메시지 내용은 다음과 같다.

서버가 보낸 ECDH 키 교환 과정에 해당하는 메시지

서버 인증서가 서명한 공개키가 RSA 알고리즘 기반이라, 서버가 보낸 메시지의 서명부분(Signature Algorithm 및 그 데이터)도 이 RSA 알고리즘을 써서 만든걸 확인할 수 있다.

디코딩할 수 없는 이유를 정리하면 다음과 같다.

  • 서버와 클라이언트 사이에 공유할 세션 키 (=둘만 알면되는 랜덤한 값) 를 생성하는데 서버 인증서의 비밀키 값을 쓰지 않는다.
  • 서버 인증서에 해당하는 비밀키는 이 공유 세션 키의 서버 부분을 보낼 때, 이 데이터가 “서버가 보냈음을 증명” 하는 용도로 전자서명할 때만 쓴다.
  • Server Key Exchange 에 해당하는 메시지는 서버 비밀키를 이용해서 이를 해석(=디코딩)할 수 없다. 비밀 키를 모르는 상태와 이 문제를 푸는 난이도가 동일하다. 그래서 서버에서 생성한 랜덤 값을 추측할 수 없다.
  • 클라이언트는 키 교환 메시지(Client Key Exchange) 를 보내고, 중간에 이걸 해석할 수 없다. 이 이후부터 서버와 클라이언트는 공유 비밀 정보를 만들고 이를 써서 세션 키를 초기화한다.

복호화 가능한 TLS 설정 시험하기

이런 이유로 오고 가는 메시지를 제대로 디코딩 할 수 없었던 것. 그럼 반대로(??) 서버의 비밀 키를 알고 있는 경우엔 디코딩 가능하게 하려면 어떻게 해야할까? 서버의 TLS 알고리즘에서 키를 생성할 때 임시 키를 안전하게 생성하는 방식을 쓰지 않도록 강제하면 된다. 예를 들어서 원래 사용하던 ssl_ciphers ECDHE-RSA-AES256-GCM-SHA384;대신에, ssl_ciphers AES128-GCM-SHA256; 같은 걸로 설정하란 소리. 이렇게 바꾸면 아래처럼 암호화 푼 내용을 볼 수 있다.

위에서 말한 것처럼 TLS 설정이 RSA 기반의 세션 키 생성으로 바꿨다. 그리고 이 약한 알고리즘을 쓰는 경우에는 오고가는 HTTP 메시지를 원래 상태 그대로 확인할 수 있다.
알고리즘을 바꾸면 어떻게 서버 키로 암호화를 풀 수 있는지는, 키 교환 과정을 살펴보면 이해할 수 있다.

RSA 로 암호화한 PreMaster Secret 을 클라이언트에서 서버로 전송한다. 이 정보를 가지고 세션 키를 생성하게 된다. 그런데 이 RSA 키는 서버의 비밀 키에 해당한다. 즉, 키 교환 시점 이전부터 메시지를 덤프해두고 있다면 나중에 다시 이걸 복호화해서 볼 수 있다.

인터넷 상에서 사생활에 민감한 이들에겐 다행인 소식. 지난 8월 (2018년 8월) 에 표준화 작업을 마무리한 TLS 1.3 버전부터는 이런 방식을 쓸 수 없다. 앞서 디코딩할 수 없었던 ECDH처럼 “나중에 비밀키를 탈취해도 해석할 수 없는” 방법만 사용하게 바뀌었다. (이렇게 미래에 비밀키를 탈취당해도 문제 없는 방식이라 forward secrecy 라고 부른다)

다만 이런 FS 방식을 지원하는 경우에도, a. 서버 비밀키를 탈취하고 b. 서버 클라이언트 연결 사이에 끼어들어가서 능동적으로 중간자 공격 (man-in-the-middle attack) 을 하는 경우에는 복호화가 가능하다는 점은 참고하시길.

디버깅 방법 요약

TLS 연결 디버깅: 인증서 오류를 찾아서

몇 주 전이 아니라 거의 석달 아니면 넉달 전 일인데, TCP 기반 서비스의 SSL/TLS 를 사용한 연결이 제대로 동작하지 않는 문제를 확인할 일이 생겼다. 문제 자체는 굉장히 단순했다. 나는 TLS 연결 문제를 확인할 때 주로 OpenSSL 의 opeenssl s_client명령을 이용하는데, 첫 문제는 이 명령어 한 번으로 찾을 수 있었다.

잘못된 TLS 설정을 사용하는 nginx 서버에 openssl s_client -connect 10.0.12.34:443 -servername hub.prgmr.net 명령을 실행하면 아래와 같은 로그를 출력한다. 테스트를 위해서 실제 서버가 아니라 10.0.12.34 에 떠 있는 서버가 실제로 해당 도메인 주소를 가지고 있다고 믿게(?)하고, 이 서버와 연결해서 테스트했다.

CONNECTED(00000003)
depth=0 CN = hub.prgmr.net
verify error:num=20:unable to get local issuer certificate
verify return:1
depth=0 CN = hub.prgmr.net
verify error:num=21:unable to verify the first certificate
verify return:1
---
Certificate chain
 0 s:/CN=hub.prgmr.net
   i:/C=US/O=Let's Encrypt/CN=Let's Encrypt Authority X3
---
Server certificate
-----BEGIN CERTIFICATE-----
MIIGUDCCBTigAwIBAgISA0kdStyJNTUMMLjMLXKmzZvOMA0GCSqGSIb3DQEBCwUA
... (중략)
IhVTEXbM5GE5ECs2VflW6w+zBY3wHOlMnn3lA+Dn83iWHg+J
-----END CERTIFICATE-----
subject=/CN=hub.prgmr.net
issuer=/C=US/O=Let's Encrypt/CN=Let's Encrypt Authority X3
---
No client certificate CA names sent
Server Temp Key: ECDH, P-256, 256 bits
---
SSL handshake has read 2562 bytes and written 348 bytes
---
New, TLSv1/SSLv3, Cipher is ECDHE-RSA-AES256-GCM-SHA384
Server public key is 4096 bit
Secure Renegotiation IS supported
Compression: NONE
Expansion: NONE
No ALPN negotiated
SSL-Session:
    Protocol  : TLSv1.2
    Cipher    : ECDHE-RSA-AES256-GCM-SHA384
    Session-ID: 02085AA959197358B866CA34CF177224FD47454B18A2C49D59940B03E251472E
    Session-ID-ctx:
    Master-Key: 58465AA5FB96B22EA782C60B1A2E591D4C1F430EE0FCAF0B942E0374937C9FAA9ED8543925DFC6AFFA69A43B8142A857
    TLS session ticket lifetime hint: 300 (seconds)
    TLS session ticket:
    0000 - c5 3c 2a bb f3 20 53 e0-96 2a 13 6d 5b c8 13 54   .<*.. S..*.m[..T
    0010 - 70 ad a4 ed e3 5a e1 c6-d9 1a 54 70 e5 c6 2c ce   p....Z....Tp..,.
    0020 - 1c c1 39 10 aa 6d 79 07-6a b0 d1 dc 8f f7 a8 b7   ..9..my.j.......
    0030 - 58 cc 3a 38 ab 31 75 01-f6 c0 21 14 f0 29 b4 44   X.:8.1u...!..).D
    0040 - cc 87 d2 43 f3 63 73 63-7f 12 81 fb d4 d1 03 8b   ...C.csc........
    0050 - 6f 8d f7 1e c0 e6 bb ce-1c 78 e3 bd b9 e7 87 42   o........x.....B
    0060 - c2 7a 0e 57 00 a6 8b c3-bc 9b 6a f8 68 ff 6a 93   .z.W......j.h.j.
    0070 - c4 25 6a 27 8b b6 69 b8-85 8e 9d f0 16 d3 de 90   .%j'..i.........
    0080 - d3 7e 26 4b 90 bd a8 74-ab 38 c5 d3 8d 32 1f 28   .~&K...t.8...2.(
    0090 - 76 fd 74 d7 ea 18 0b ae-67 33 7f c0 d1 59 89 a2   v.t.....g3...Y..
    00a0 - b3 23 4c f2 12 c0 4f 40-ac fa 13 80 21 de 64 81   .#L...O@....!.d.
    00b0 - a1 8d 6d 4e f1 ad cf b4-58 3b 7e 26 92 23 42 81   ..mN....X;~&.#B.

    Start Time: 1546124091
    Timeout   : 7200 (sec)
    Verify return code: 21 (unable to verify the first certificate)
---

여기서 문제는 “unable to verify the first certificate” 이다. SSL/TLS는 인증서를 인증하기 위한 인증 기관 (Certificate Authority; CA) 인증서부터, 실제로 서버가 보내는 인증서까지 일련의 서명이 필요하다.

  • 각 브라우저 혹은 OS 혹은 사용 중인 TLS 라이브러리가 인식하는 루트 인증서로 서명한 인증서 (intermediate certificate)
  • 위 인증서로 서명한 또 다른 인증서 (또다른 intermediate certificate) …를 여러 단계 혹은 0 단계.
  • …마지막 중간 단계 인증서로 서명한 서버의 인증서

예를 들어 이 블로그를 구동하는 서버의 인증서는 DST Root CA X3 가 루트 인증서고, 그 인증서로 서명한 Let’s Encrypt 의 인증서, 마지막으로 해당 인증서로 서명한 이 블로그 도메인의 인증서로 이어진다.

TLS 인증서의 체인 예시

이런 형태로 연결된 일련의 인증서 (certificate chain) 가 필요하다. 그래서 “서버 인증서” 와 “서버 인증서까지 이르는 중간 단계 인증서” 를 모두 보내야하는데, 위에선 그렇지 않아서 이런 오류를 출력한다. TLS를 처음 설정할 때 이런 실수를 많이 하는데 (정식으로 서명한 인증서가 있으니 잘 돌아야하는데 왜 에러인가 같은 질문을 스택오버플로우에서 많이 찾아볼 수 있다), 잘못된 nginx 설정은 아래와 같다. 인증서 디렉터리는 Ubuntu 16.04 에서 Let’s Encrypt 를 사용하는 경우처럼 설정했다.

server {
  listen 443 ssl;
  server_name hub.prgmr.net;
  root /var/www/html;

  ssl on;
  ssl_certificate /etc/nginx/hub.prgmr.net/cert.pem;
  ssl_certificate_key /etc/nginx/hub.prgmr.net/privkey.pem;

  ssl_protocols TLSv1.2;
  ssl_ciphers ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECD
HE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-SHA
384:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256;
  ssl_prefer_server_ciphers on;
}

여기서, ssl_certificate 를 서버 인증서 파일인 으로 지정했는데, 실제로는 “서버 인증서\n중간 단계인증서k\n중간단계인증서k-1 … \n중간단계인증서0” 처럼 중간단계 인증서를 모두 포함한 파일로 지정해야 한다. Let’s Encrypt 인증서를 쓴다면 이미 해당 파일을 fullchain.pem 이란 이름으로 제공한다. 이 파일을 cert.pem 대신 지정해주면 정상적으로 TLS 연결을 맺는다.

이 설정 문제는 문제를 덮어놓은 다른 문제였고, 실제 문제는 다른 글로 정리하겠다(…).