17
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

株式会社LabBase テックカレンダーAdvent Calendar 2022

Day 22

NGINXリバースプロキシでTLS Server Name Indication (SNI)と異なるドメイン名のバックエンドホストへルーティングできちゃう件について

Last updated at Posted at 2022-12-21

この記事は、株式会社LabBaseの「株式会社LabBase テックカレンダー Advent Calendar 2022」の22日目の記事です。LabBase所属としては初の記事投稿となります。なお、昨日の記事はゲバラさんのこちらの記事です。

私はいち研究者として、LabBaseのリサーチエンジニアの皆さんと共同の研究開発をしています。その研究開発の中で偶然見つけてしまった「当たり前だと思い込んでいたけど、実装は実はそうではなかった」というNGINXの事案を報告します。場合によっては、セキュリティインシデントになりかねず、見つけた時に冷や汗をかいた覚えがあります。

なお、タイトルではNGINXのみに言及していますが、Caddyでも同じ事案が発生します。NGINXでの事象を解説したあと、Caddyについても言及します。

はじめに

まずは導入として、TLSのServer Name Indication (SNI)と、それを用いたNGINXリバースプロキシによるHTTPSのマルチドメインホスティングについて説明していきます。

TLS Server Name Indication (SNI)

clienthello.png

上図にTLSのセッション構築の概略図を記載します。1 TLSでは、セッション構築のイニシーションメッセージであるClientHelloに、接続したいドメインの名前 (server_name) を平文で記載し、サーバに通知します。これはServer Name Indication (SNI) と呼ばれており、TLS拡張の1つです。TLSにおいて、複数ドメインを1つのIPアドレス・ホストで捌くために、事実上必須の拡張となっています。このSNIに応じて、サーバは対応する証明書を含めて、クライアントに返答します。その後、証明書の検証などを完了し、TLSのセッションが構築されることとなります。

NGINXによる、HTTPSリバースプロキシによるマルチドメインホスティング

HTTPS Reverse Proxy for Multi Doamin Hosting

さて、複数ドメインのHTTPSサービスをホストするための、TLSを終端するリバースプロキシにおいては、SNIによる接続管理が行われることとなります。すなわち、

  1. クライアントから受け取ったClientHello内のSNIに応じて、返答する証明書を切り替えてTLSセッションを構築
  2. server_nameごとに事前設定されたバックエンドサービスへ、HTTPメッセージを転送

することになります。

このようなリバースプロキシとしては、NGINXは最も利用されているソフトウェアの1つです。具体的に、以下のような設定で上図の仕事の設定ができます。

nginx.conf
server {
  server_name www.example.org;
  listen 443 ssl http2;
  listen [::]:443 ssl http2;
  # www.example.com の証明書・秘密鍵
  ssl_certificate /path/to/com.cert;
  ssl_certificate_key /path/to/net.key;

  # backend.example.comへルーティング
  location / {
    proxy_pass http://backend.example.com:80;
  }
}

server {
  server_name www.example.net;
  listen 443 ssl http2;
  listen [::]:443 ssl http2;
  # www.example.net の証明書・秘密鍵
  ssl_certificate /path/to/net.cert;
  ssl_certificate_key /path/to/net.key;

  # backend.example.netへルーティング
  location / {
    proxy_pass http://backend.example.net:8000;
  }
}

serverディレクティブは、ApacheでいうVirtualHostです。NGINXは、クライアントからClientHelloを受け取ったのち、SNIで通知されるドメイン名と一致するserver_nameを持つserverディレクティブを決定します。そして、そのserverディレクティブの設定を、TLSの構築やバックエンドサービスへのルーティングへ共通して反映する…

……する、と思っていた時期が私にもありました…

この記事で言いたいこと

NGINXリバースプロキシでは、デフォルトでSNIとHOSTリクエストヘッダの一貫性が考慮されない

リバースプロキシとしてのNGINXは、TLSの構築と、バックエンドサービスへのルーティングにおいて、反映されるserverディレクティブに、デフォルトで一貫性がありません。つまり、あるドメインについてTLSセッションを構築した場合、そのドメインについて設定した本来のルーティング先とは、全く異なるバックエンドサービスへルーティングされるケースがあります。

具体的には、以下のドメイン名と、server_nameエントリが一致するserverディレクティブが、別個に反映されることになります。

  • TLSの構築: TLSのClientHello内のSNI
  • バックエンドへのルーティング: TLS構築後に流れてくるHTTPのHOSTリクエストヘッダあるいは:authority擬似ヘッダ (以下ではまとめてHOSTリクエストヘッダとして簡単化します)

つまり、TLS SNIとHOSTリクエストヘッダに一貫性を保たないクライアントがいた場合、想定外の事象を引き起こす可能性があることを意味します。

ただし、設定ファイルにif文を入れるような信じられない対応をすれば、このようなことは起きません。これについても最後の方に記載します。

想定されるセキュリティ上の懸念

SNIとHOSTリクエストヘッダの一貫性が取れなくともルーティング可能、という状況は、例えば以下のようなセキュリティ上の懸念があります。

まず、2つのドメインがTLS (HTTPS) を終端するリバースプロキシによって

  • Url https://tadashii.example.com -> バックエンドホストtadashiiへルーティング
  • Url https://akui.example.org -> バックエンドホストakuiへルーティング

となることを意図してルーティング設定がなされているとします。

しかし、本記事で紹介するNGINXの動作を考慮していないと、ドメインtadashii.example.comに対してTLS接続した後に、バックエンドホストakuiへにアクセスすることが可能という、意図しない状況を生みます。これは、ドメインtadashii.example.comの証明書の信頼性に依存してakuiへアクセス可能ということを意味します。

クライアントソフトウェア (e.g., ブラウザ) の実装次第で、TLS通信の上では正規ホストにアクセスしていると見せかけ、実際は正規ホストとは異なるバックエンドホストにアクセスさせることも可能ということです。これは、同一リバースプロキシで捌かれる、複数のドメインおよびバックエンドホストのオーナがそれぞれ異なるような場合、特に考慮しなければならないような事項です。Cookieとかの秘密情報が意図しないバックエンドホストへ漏洩するような、潜在的なセキュリティリスクになりうるからです。

実証してみる

環境設定

dockerで実験環境をサクッと立ち上げます。NGINXにリバースプロキシをさせて、2つのドメインをそれぞれ別のバックエンドサービス (コンテナ) へルーティングしていきます。今回は、バックエンドサーバとして普通のWebサーバとしてのNGINX、それとコンテナIDを返すjwilder/whoamiを利用してみます。

docker-compose.yml
version: '3.9'
services:
  nginx:
    image: nginx:latest
    container_name: proxy-nginx
    ports:
      - 80:80
      - 443:443
    restart: unless-stopped
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf
      - ./certs:/etc/nginx/certs:ro

  # https://<Domain_A>/のルーティング先サービス
  backend-nginx:
    image: nginx:latest
    container_name: backend-nginx
    restart: unless-stopped

  # https://<Domain_B>/のルーティング先サービス
  backend-whoami:
    image: jwilder/whoami:latest
    container_name: backend-whoami
    restart: unless-stopped

NGINXリバースプロキシの設定(nginx.conf)は、以下のような超最低限のものを利用します。今回、証明書はLet's Encryptで取得していますが、適当にオレオレ証明書を利用しても構いません。2 2つのバックエンドサービスに対して、全く別のドメイン、および証明書を設定していることに注意してください。

nginx.conf
# このバックエンドサーバへルーティングするのは<Domain A>へのアクセスのみ
server {
  server_name <Domain A>;
  listen 443 ssl http2;
  listen [::]:443 ssl http2;
  # Domain A の証明書・秘密鍵
  ssl_certificate /etc/nginx/certs/<Domain A>.crt;
  ssl_certificate_key /etc/nginx/certs/<Domain A>.key;

  # ルーティング先はbackend-nginxの80ポート。
  location / {
    proxy_pass http://backend-nginx:80;
  }
}

server {
  # このバックエンドサーバへルーティングするのは<Domain B>へのアクセスのみ
  server_name <Domain B>;
  listen 443 ssl http2;
  listen [::]:443 ssl http2;
  # Domain B の証明書・秘密鍵
  ssl_certificate /etc/nginx/certs/<Domain B>.crt;
  ssl_certificate_key /etc/nginx/certs/<Domain B>.key;

  # ルーティング先はbackend-whoamiの8000ポート。
  location / {
    proxy_pass http://backend-whoami:8000;
  }
}

NGINXの設定を書くのや証明書の生成・取得がめんどくさい場合、NGINXについてはnginx-proxyを利用、証明書取得についてはacme-companionを利用しても、結果は一緒なので問題ありません。

cURLしてみる

まずは普通にcURLしてみます。

% curl https://<Domain A>/
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>

〜中略〜

</html>
% curl https://<Domain B>/
I'm 22777b5d3e12

これが本来想定される動作ですね。では、HOSTリクエストヘッダを変更してcURLでアクセスしてみます。

% curl -vv -H "HOST: <Domain A>" https://<Domain B>/

〜中略〜
〜Domain Bのport 443へ接続〜

* Connected to <Domain B> (<IP Addr>) port 443 (#0)

〜中略〜
〜Domain Bの証明書 (CN/SANに注目) が提示され、その検証も成功〜

* Server certificate:
*  subject: CN=<Domain B>
*  start date: Nov  9 17:08:21 2022 GMT
*  expire date: Feb  7 17:08:20 2023 GMT
*  subjectAltName: host "<Domain B>" matched cert's "<Domain B>"
*  issuer: C=US; O=Let's Encrypt; CN=R3
*  SSL certificate verify ok.
* Using HTTP2, server supports multiplexing
* Copying HTTP/2 data in stream buffer to connection buffer after upgrade: len=0
* h2h3 [:method: GET]
* h2h3 [:path: /]
* h2h3 [:scheme: https]
* h2h3 [:authority: <Domain A>]
* h2h3 [user-agent: curl/7.86.0]
* h2h3 [accept: */*]
* Using Stream ID: 1 (easy handle 0x14e00be00)
> GET / HTTP/2

〜ここでHOSTリクエストヘッダはDomain Aを示す〜

> Host: <Domain A>
> user-agent: curl/7.86.0
> accept: */*

〜中略〜
〜Domain Aに接続され、コンテンツがGETされる〜

<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>

〜中略〜

</html>
* Connection #0 to host <Domain B> left intact

……おい……TLS SNIは無視され、HOSTヘッダで指定したDomain Aのバックエンドサーバに接続されてしまいました

当然逆もこのようになります。

% curl -H "HOST: <Domain B>" https://<Domain A>/
I'm 22777b5d3e12

というわけで、サーバに設定された証明書とは一切整合性をとることなく、実際に接続されるのは、HOSTリクエストヘッダで指定されたバックエンドサービスとなっていることが確認できました。これを偶然見つけた時は驚きました。

対策: NGINXでSNIとHTTP HOSTリクエストヘッダとの整合性を保つためには

NGINX設定ファイル (nginx.conf) の各serverディレクティブに以下を追加することで、この問題の対策が可能です。

if ($ssl_server_name != $host) {
  return 421;
}

これにより、TLS SNIと、HTTP HOSTリクエストヘッダの一貫性が取れない場合に、421 misdirected requestを返し、アクセス拒否をすることが可能です。cURLのレスポンスも以下のようになります。

% curl -H "HOST: <Domain B>" https://<Domain A>/
<html>
<head><title>421 Misdirected Request</title></head>
<body>
<center><h1>421 Misdirected Request</h1></center>
<hr><center>nginx/1.23.3</center>
</body>
</html>

なお421 Misdirected Requestは、リクエストURLのScheme (HTTPS) とAuthorityとの組み合わせの整合性が取れない場合、返答可能な400番台HTTPレスポンスです。TLS SNIとAuthority (=HOST) の組み合わせがおかしいので、これを返すのが自然ですね。

421 Misdirected Request
This can be sent by a server that is not configured to produce responses for the combination of scheme and authority that are included in the request URI.

実際に、RFC6066ではアプリケーションレイヤのプロトコルとSNIの示すドメイン (server_name) が異ならないか否かをチェックすること、と明示されています。

Since it is possible for a client to present a different server_name in the application protocol, application server implementations that rely upon these names being the same MUST check to make sure the client did not present a different name in the application protocol.

Q&A

ApacheやCaddyはどうなのか?

Apache

デフォルトで、SNIとHOSTリクエストヘッダの一貫性が担保されていないと421が返ってきます

% curl -vv -H "HOST: <Domain A>" https://<Domain B>/
<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">
<html><head>
<title>421 Misdirected Request</title>
</head><body>
<h1>Misdirected Request</h1>
<p>The client needs a new connection for this
request as the requested host name does not match
the Server Name Indication (SNI) in use for this
connection.</p>
</body></html>

Caddy

……Caddyよ、お前もか……。CaddyでもNGINXと同じ事項が確認できました。以下、サンプルのCaddyfileです。

{
  # 取得済み証明書・秘密鍵を利用する
  auto_https disable_certs
}

<Domain A> {
  tls /certs/<Domain A>.crt /certs/<Domain A>.key
  reverse_proxy backend-nginx:80
}

<Domain B> {
  tls /certs/<Domain B>.crt /certs/<Domain B>.key
  reverse_proxy backend-whoami:8000
}

docker-compose.ymlはほぼNGINXの場合と一緒なため、割愛します。以下、cURLの結果です。

% curl -H "HOST: <Domain B>" https://<Domain A>/
I'm 22777b5d3e12

CaddyではCaddyfileのGlobal Optionへ、以下のようにstrict_sni_hostを追加することで、この対策が可能です。これにより、もしTLS SNIとHOSTリクエストヘッダとの一貫性がない場合、421を返すようになります。

{
  servers {
    strict_sni_host
  }
}

一括設定可能なのでNGINXよりマシですがデフォルトでstrict_sni_hostをONにして欲しいですね…

クライアント証明書を利用している場合、同じ懸念はあるのか?

2つのドメインについてHTTPSリバースプロキシを構築している場合、一方はクライアント証明書による認証があり、他方はない場合を想定します。クライアント認証もserverディレクティブ内に記載される設定です。このとき、

  • 認証を行うドメインのバックエンドサービスに対して、
  • 認証のないドメインについて構築したTLSから認証なしでアクセスできるかどうか、

という懸念がありますね。これは、NGINX・Caddyともに不可能です。ただし、Caddyは明示的にstrict_sni_host insecure_offとしてやれば疎通してしまうようです。

まとめ

TLS SNIと、バックエンドホストのドメイン名との一貫性が保たれ、意図しないバックエンドサービスへルーティングさせないことは、リバースプロキシを運用する時には暗黙のうちに仮定している事項だと思います。しかし、NGINXやCaddyではそういうわけではないという落とし穴がありました、という話でした。特にNGINXでは、その対策のために設定ファイルの各serverディレクティブ全部にif文を書く必要があるのはどうなのか、は強く思うことです。

なお、この事象はNGINXのセキュリティチームにも報告されているらしいのですが、クライアント実装の問題だとしてNGINXの問題ではないという返答が返ってきているとのことです。

I have raised this to the nginx security team who say that it is not a security flaw with nginx, and that it is up to the client to validate certificates, and then not to send malformed requests for hosts that they should be accessing over that connection.

この事案を調べるにつれ、NGINXチームの対応状況もどうもアレだったということもあり、SNIとHTTP HOSTリクエストヘッダとの一貫性をちゃんと保つRust製HTTPSリバースプロキシを自作しました。

GitHub junkurihara/rust-rpxy: A simple and ultrafast http reverse proxy serving multiple domain names and terminating TLS over http/1.1, 2 and 3, written in Rust

多機能ではないものの、HTTPSマルチドメインにももちろん対応し、NGINX並に安定・高速に動作しています。CaddyとApacheは遅くてたまらん。

つぎの記事は

次の記事は@takahiro-yamadaさんです!期待していてください!

  1. このserver_nameに応じてTCPを利用するHTTP/2までの図ですが、HTTP/3でも基本は一緒です。

  2. 実証に限定して、cURLで--insecureオプションをつければ、確認可能です。

17
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
17
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?