HEARTBEATS

TLSプロトコル拡張機能のServer Name Indication(SNI)を理解する

   

こんにちは。24年度に新卒入社した運用チームの又吉です。

普段サーバの監視業務をする中で、HTTPS通信中にクライアントがWebサーバに対してどのホスト名で接続したいかを伝える、TLSプロトコルの拡張機能である「Server Name Indication(以降SNIとする)」を知りました。そこで、今回は運用エンジニア1年生である私の学習も兼ねてSNIの仕組みについて、検証しながら記します。

この記事ではSNIについて実験するために、著者が手馴れているNagiosのHTTP監視プラグインであるcheck_httpコマンドを利用していますが、多くの方に試してもらえるようにopensslコマンドでの確認方法もところどころで併記します。

CDNを利用したWebページの場合、なぜSNIを使う必要があるのか、そしてそもそもSNIとは何か理解したかったため調査・検証しました。

SNIについてよりイメージしやすいよう、本稿はコマンドのデバッグやパケットキャプチャ等でSNIの具体的な姿を確認できる内容になっています。本稿がSNIをより深く理解する助けになれば幸いです。

Server Name Indication(SNI)とは

SNIは一つのWebサーバで複数のドメインをホストする際に利用するための仕組みです。 SNIではHTTPS通信のTLSハンドシェイク時にクライアントがサーバに対し接続したいホスト名を伝えることができます。そうすることでWebサーバがどのSSL/TLS証明書を使うのか切り替えるようにできます。SNIはTLSプロトコルの拡張機能の一つで、RFC6066で標準化されています。

複数のドメイン名をホストする具体例としてWebサーバのソフトウェアであるnginxやApacheのバーチャルホストの機能が挙げられます。SNIを利用することによりSSL/TLS層でドメイン名を判別できるため、ドメイン名ごとにSSL/TLS証明書の使い分けが可能になります。

SNIを利用することのできるコマンド

以下ではSNIを利用する方法を説明します。

check_httpコマンドでSNIを利用する

check_httpの使い方についてはNagios Pluginsのcheck_httpを使いこなそうを参考にしてみてください。

ここではSNIを利用するための--sniオプションに注目します。--sniオプションについて、check_httpコマンドの--helpオプションでは以下のように説明されています。

$ check_http --help
〜省略〜
 --sni
    Enable SSL/TLS hostname extension support (SNI)
〜省略〜

SNIはTLSの拡張機能なので次のように--sslオプションと併用します。

$ check_http -H example.com -f follow --ssl --sni
HTTP OK: HTTP/1.1 200 OK - 17291 bytes in 0.582 second response time |time=0.582172s;;;0.000000 size=17291B;;;0

Amazon CloudFrontやCloudflare CDNといったContents Delivery Network(以降CDNとする)を利用したWebページに対してcheck_httpコマンドを実行する場合、--sniオプションを付けずに実行すると以下のようにCannot make SSL connectionが応答します。

$ check_http -H example.com -f follow --ssl
CRITICAL - Cannot make SSL connection.
139844870334400:error:14077410:SSL routines:SSL23_GET_SERVER_HELLO:sslv3 alert handshake failure:s23_clnt.c:769:

opensslコマンドでSNIを利用する

openssl s_clientのヘルプには-servernameオプションが次のように説明されています。

$ openssl s_client -help
~省略~
 -servername val            Set TLS extension servername (SNI) in ClientHello (default)
~省略~

opensslでSNIを利用する場合は、次のようにコマンドを実行します。

$ openssl s_client -connect example.com:443 -servername example.com -quiet
depth=2 C = US, O = DigiCert Inc, OU = www.digicert.com, CN = DigiCert Global Root G3
verify return:1
depth=1 C = US, O = DigiCert Inc, CN = DigiCert Global G3 TLS ECC SHA384 2020 CA1
verify return:1
depth=0 C = US, ST = California, L = Los Angeles, O = Internet Corporation for Assigned Names and Numbers, CN = *.example.com
verify return:1
^C  (CTRL+Cで止める)

以下からは実際にWebサーバを構築し、RFCの文書を参考にしつつ、コマンドのデバッグ、パケットキャプチャを通して--sniオプションおよびSNIの理解を深めていきます。

検証環境の構築

今回はAmazon EC2のAmazon Linux2のインスタンスを2台起動して次の名前で構築します。

  • web-sandbox(Webサーバをインストールするインスタンス)
  • nagios-sandbox(check_httpコマンドをインストールするインスタンス)

以下に今回利用するソフトウェアのバージョン情報を示します。

ソフトウェア バージョン情報
nginx nginx/1.22.1
Nagios Plugins check_http 2.4.9
openssl 1.0.2k
gdb 8.0.1
wireshark-cli 2.6.2

次にそれぞれのサーバでのインストール作業や設定を示します。なお、EC2インスタンスやセキュリティグループ等の設定の説明は省略します。

web-sandboxインスタンス

web-sandboxインスタンスにはnginx、SSL/TLS証明書作成に必要なopenssl、そしてパケットキャプチャをするためのwireshark-cliをインストールします。

### nginxのインストールと起動
$ sudo amazon-linux-extras enable nginx1
$ sudo yum -y install nginx
$ sudo systemctl enable --now nginx

### opensslのインストール
$ sudo yum -y install openssl-devel

### wireshark-cliのインストール
$ sudo yum install -y wireshark-cli

nginxで複数のドメイン名をホストするよう、以下のように設定を追加します。 /usr/share/nginx/html/index.htmlや/usr/share/nginx/html2/index.htmlもそれぞれ別のコンテンツが表示されるように作成しておきます。設定の追加後はnginxの再起動をして設定を反映させましょう。

server {
    listen       80;
    listen       [::]:80;
    server_name  example.com;
    root         /usr/share/nginx/html;
    listen 443 ssl;
    ssl_certificate     /home/ec2-user/server.crt;
    ssl_certificate_key /home/ec2-user/server.key;
}
server {
    listen       80;
    listen       [::]:80;
    server_name  cert1.example.com;
    root         /usr/share/nginx/html2;
    listen 443 ssl;
    ssl_certificate     /home/ec2-user/server1.crt;
    ssl_certificate_key /home/ec2-user/server1.key;
}

今回はSSL/TLS接続を行いたいのでopensslコマンドでSSL/TLS証明書を作成します。ドメイン名が二つあるので二つ作成しておきます。CSRを作成する際に色々と情報の入力を求められますが、今回の検証においてはCommon Name(CN)のみ設定します。

$ openssl genrsa 2048 > server.key
$ openssl req -new -key server.key > server.csr
$ openssl x509 -in server.csr -days 365000 -req -signkey server.key > server.crt

$ openssl genrsa 2048 > server1.key
$ openssl req -new -key server1.key > server1.csr
$ openssl x509 -in server1.csr -days 365000 -req -signkey server1.key > server1.crt

nagios-sandboxインスタンス

nagios-sandboxインスタンスにはNagios Pluginsのcheck_http、SSL/TLS証明書確認のためのopensslと、 コマンドをデバッグするためのgdbをインストールします。なお、デバッグ情報無しでgdbでデバッグしようとすると「Missing separate debuginfos, use: debuginfo-install nagios-plugins-http-2.4.9-1.el7.x86_64」のようなメッセージが表示されるのでそれに従いデバッグ情報をインストールしておきます。

### check_httpのインストール
$ sudo amazon-linux-extras install epel -y
$ sudo yum -y install nagios-plugins-http
$ export PATH=$PATH:/usr/lib64/nagios/plugins

### opensslのインストール
$ sudo yum -y install openssl-devel

### gdbのインストール
$ sudo yum -y install gdb
$ sudo debuginfo-install nagios-plugins-http-2.4.9-1.el7.x86_64 -y

web-sandboxインスタンスに対してドメイン名で接続等できるように/etc/hostsに以下のように追記しておきます。

<web-sandboxのIPアドレス> example.com       # Nginxでの検証に使用
<web-sandboxのIPアドレス> cert1.example.com # Nginxでの検証に使用
<web-sandboxのIPアドレス> cert2.example.com # --verify-hostオプション検証に使用

--sniオプションを付けると何が起きるか理解する

まずはcheck_httpコマンドをデバッグして、--sniオプションを付けた場合に何が起きるか確認してみます。

nagios-sandboxにて以下のようにcheck_http関数をブレークポイントに設定し処理をたどります。setでcheck_httpコマンドのオプションを指定しており、-tでタイムアウト時間を5000秒とすることでデバッグ中にコマンドがタイムアウトすることを防ぎます。

$ gdb /usr/lib64/nagios/plugins/check_http
(gdb) set args -H example.com -f follow --sni --ssl -t 5000
(gdb) b check_http
(gdb) r

そのようにして処理を追うとssl3_ctrlという関数に辿り着きます。ssl3_ctrl関数はcase文で分岐しており、--sniオプションを付けた際の処理はcase SSL_CTRL_SET_TLSEXT_HOSTNAMEの部分にあります。例外処理等を除くと下記3327行目の処理にてSSL構造体のtlsext_hostnameにホスト名(今回の場合はexample.com)を設定していることがわかります。また3312行目から、これらの処理はlargがTLSEXT_NAMETYPE_host_nameの場合に実行されることがわかります。

3311        case SSL_CTRL_SET_TLSEXT_HOSTNAME:
3312            if (larg == TLSEXT_NAMETYPE_host_name) {
〜省略〜
3327                if ((s->tlsext_hostname = BUF_strdup((char *)parg)) == NULL) {
(gdb) n
3243            break;
(gdb) p s
$11 = (SSL *) 0x55555564efc0
(gdb) p s.tlsext_hostname
$10 = 0x555555650480 "example.com"

まとめると、「--sniオプションを付けるとTLSEXT_NAMETYPE_host_nameの場合の処理が実行され、SSL構造体のtlsext_hostnameにホスト名が設定される」ということになります。

次に、WiresharkでSSL構造体から作成された実際のパケットをキャプチャして確認してみます。

パケットキャプチャでSNIを確認する

wireshark-cliをインストールするとCLIでパケットキャプチャできるtsharkコマンドが利用できるようになります。web-sandboxにて以下のように実行してパケットキャプチャを起動します。<IPアドレス>にはnagios-sandboxのIPアドレスを設定します。また、-wオプションを指定しpcap-formatのキャプチャファイルを作成します。

$ sudo tshark -i eth0 -f "host <IPアドレス>" -w /tmp/sni.cap
Capturing on 'eth0'

キャプチャを開始したらnagios-sandbox側でcheck_httpコマンドを実行します。

$ check_http -H example.com -f follow --ssl --sni

実行が完了したらweb-sandboxでCtrl+C等でキャプチャを終了し、作成されたキャプチャファイルを確認してみます。キャプチャファイルを手元のPC等にダウンロードしてからGUIのWiresharkで開くと見やすいです。SNIはClient Helloパケットに設定されるので、Client Helloパケットの内容を確認します。すると以下画像のように、SNIは長さの情報を除くと「Server Name Type」と「Server Name」で構成されていることがわかります。

sni_client_hello.png

先ほどデバッグで確認した「--sniオプションを付けるとTLSEXT_NAMETYPE_host_nameの場合の処理が実行され、SSL構造体のtlsext_hostnameにホスト名が設定される」がパケットにも反映されていることが確認できました。この時点でまだわかっていないのは「Server Name Typeが何か」です。次にRFCでSNIについて確認してみます。

RFCを読んでServer Name Typeを理解する

まずSNIの構造を確認します。RFC6066の「3. Server Name Indication」で示されているServerName構造体を見ると、先ほどパケットキャプチャで確認した「Server Name Type」と「Server Name」に対応する「NameType」と「HostName」が含まれています。「NameType」はcase文で表現されているものの、「host_name」一種類のみであることがわかります。

     struct {
          NameType name_type;
          select (name_type) {
              case host_name: HostName;
          } name;
      } ServerName;

RFC6066の「3. Server Name Indication」にてNameTypeについて以下の記述があります。

Currently, the only server names supported are DNS hostnames; however, this does not imply any dependency of TLS on DNS, and other name types may be added in the future (by an RFC that updates this document). The data structure associated with the host_name NameType is a variable-length vector that begins with a 16-bit length. For backward compatibility, all future data structures associated with new NameTypes MUST begin with a 16-bit length field. TLS MAY treat provided server names as opaque data and pass the names and types to the application.

要約すると、「現在Server Nameとして利用できるのはDNSのホスト名のみだが、DNSに依存せず将来的に他の形式のServer Nameも利用できるようにNameTypeがある」といった内容です。そのため現在(2025年5月時点)NameTypeに設定されるタイプはDNSのホスト名であることを示す「host_name」のみです。

以上、check_httpコマンドのデバッグ、パケットキャプチャ、RFC読みを通してSNIの具体的な姿を確認できました。ここからはSNIによってWebサーバがどのように動作するかを確認していきます。

SNIによるWebサーバの動作を理解する

SNIが設定されている場合のnginxの動作

opensslコマンドを使って確認します。nagios-sandboxインスタンスで以下のようにコマンドを実行します。

$ openssl s_client -quiet -connect example.com:443 -servername example.com
depth=0 C = XX, L = Default City, O = Default Company Ltd, CN = example.com
〜省略〜
$ openssl s_client -quiet -connect cert1.example.com:443 -servername cert1.example.com
depth=0 C = XX, L = Default City, O = Default Company Ltd, CN = cert1.example.com
〜省略〜

-servernameオプションでSNIを指定しています。それぞれホスト名に対応したSSL/TLS証明書が使用されていることがわかります。

SNIが設定されていない場合のnginxの動作

では、SNIが設定されていない場合はどのような動作になるのでしょうか。opensslコマンドにて以下のように-servernameオプションを指定せずに実行して確認します。

$ openssl s_client -quiet -connect example.com:443
depth=0 C = XX, L = Default City, O = Default Company Ltd, CN = example.com
〜省略〜
$ openssl s_client -quiet -connect cert1.example.com:443
depth=0 C = XX, L = Default City, O = Default Company Ltd, CN = example.com
〜省略〜

cert1.example.comで接続しているにもかかわらず、example.comのSSL/TLS証明書が使用されていることがわかります。なお、check_httpコマンドで--sniオプションを付けずにコンテンツを確認してみると、コンテンツはホスト名に対応したバーチャルホストのものが応答していることがわかります。

$ check_http -H example.com -f follow --ssl --string "Contents of example.com"
HTTP OK: HTTP/1.1 200 OK - 255 bytes in 0.009 second response time |time=0.009181s;;;0.000000 size=255B;;;0
$ check_http -H cert1.example.com -f follow --ssl --string "Contents of cert1.example.com"
HTTP OK: HTTP/1.1 200 OK - 261 bytes in 0.010 second response time |time=0.009922s;;;0.000000 size=261B;;;0

nginxはSNIが設定されていない場合はデフォルトサーバのSSL/TLS証明書が使われるようです。筆者はApacheでも同様の検証を行いましたが、同様にDefault Virtual HostのSSL/TLS証明書が使用されました。

以上の検証から、WebサーバがSSL/TLS接続でホスト名に対応したSSL/TLS証明書を利用するためにSNIのHostNameを用いることがわかりました。また、そのHostNameを設定するためにcheck_httpの--sniオプションが必要であることがわかりました。

補足情報

以下は今回検証する中で知ったSNIやcheck_httpコマンドに関する補足的な情報です。

SNIが設定されていない場合に接続を拒否する

CDNを利用したWebページに対し--sniオプションなしでcheck_httpコマンドを実行した際はCannot make SSL connectionが応答します。しかし、nginxでは--sniオプションを付けない場合、デフォルトサーバのSSL/TLS証明書が使用されるもののSSL接続は成功します。

nginxでSNIが設定されていない場合にCannot make SSL connectionを応答するには以下の設定を追加する必要があります。

server {
    listen 443 ssl;
    ssl_reject_handshake on;
}

Apacheの場合は以下の設定を追加します。nginxと違いSNIがない場合にHTTP 403 Forbiddenが応答します。

SSLStrictSNIVHostCheck on

check_http --verify-hostオプションでSSL/TLS証明書を検証する

check_httpコマンドのオプションに--verify-hostがあります。--ssl、--sniオプションと併用し、-Hで指定したホスト名に対してSSL/TLS証明書が正しいものかチェックできます。ホスト名とSSL/TLS証明書のCommon Nameが一致しない場合、CRITICAL - Hostname mismatchが応答します。

$ check_http -H cert2.example.com -f follow --ssl --sni --verify-host
CRITICAL - Hostname mismatch.

SNIを暗号化するEncrypted Client Hello

SNIは平文でドメイン名が保持されるため、パケットキャプチャ等でSNIを確認することで通信している先を誰でも判別できてしまいます。完全に秘匿された通信を実現するためにはSNIを暗号化して隠す必要があります。それを実現するのがEncrypted Client Hello(以降ECHとする)です。ECHは現在(2025年5月時点)draft-ietf-tls-esni-24にてRFC策定が進められています。

ECHについて個人的に追いかけてみたいと思いましたので、次回はECHまわりの検証についてブログを書こうかと思っています。

参考文献

株式会社ハートビーツの技術情報やイベント情報などをお届けする公式ブログです。



ハートビーツをフォロー

  • Twitter:HEARTBEATS
  • Facebook:HEARTBEATS
  • HATENA:HEARTBEATS
  • RSS:HEARTBEATS

殿堂入り記事