問題
8080
番ポートで待ち受けているhttpsサーバがある。このサーバは192.168.0.5/24
・10.0.0.5/24
の2つのIPを持ち、またゲートウェイ192.168.0.1/24 == 10.0.0.5/24
がポートフォワード10.0.0.10:4444 -> 192.168.0.5:8080
をかけている。
サーバが証明書を持つドメインとしてexample.com
・secret.example.com
があり、どちらもAレコードとして10.0.0.5
のみを持っているとする。
サーバは仮想ホストexample.com
・secret.example.com
を抱えており、Server Name Indication (SNI)を読んだ上でHost
ヘッダからリクエストを仮想ホストに分配する。仮想ホストはホストヘッダについて次の2項目を確認する。
- ホスト名がSNIと一致していること
- ポート番号がリクエストを受け付けたポート番号(
8080
)と一致していること
この時、ネットワークBからcurl
コマンドを用い、ゲートウェイ経由でhttps://secret.example.com:8080
にアクセスするにはどうすればよいか。
回答
curl --connect-to secret.example.com:8080:10.0.0.10:4444 https://secret.example.com:8080
解説
PPPoEを契約していても、より高速なIPoE(IPv4 over IPv6)経由で通信させたいという気持ちは湧くはずである。しかし多くのIPoEプロバイダでは操作不能かつ固定不能1な、割り当てられたポートしか使えない。とはいえIPoEに応じてサーバのポートを調整するという作業はしたくない。ファイアウォールの調整や内部サービスとの衝突など、色々面倒だからだ。よって、ゲートウェイでポートフォワードを噛ませる形になるだろう。これは問題における10.0.0.0/24
をWANに置き換えた時と同じネットワークになる。
ところで、httpは仮想ホストを実現するための仕組みとしてHost
ヘッダがあるが、httpsになるとSNIによってもホスト名が送られる。さらにHost
ヘッダにはポート番号も書かれている。これらの指定子について整合性チェックを取るようRFCにも書いてあるのだが、送信するべきSNI・Host
ヘッダと実際の送信先が異なるようなクエリはURLでは表現できない。IPとポート番号を直書きしたURLではサーバまで届きはするものの、その後の不整合を理由に適切な証明書が返ってこなかったり、400 Bad Request
とか421 Misdirected Request
を返されたりすることになる。
と、IPoE経由で自前のhttpsサーバを叩きに行くという状況がこの問題の背景な訳だが、シェルスクリプトから叩きたいこともよくあることだろう。最も標準的なコマンドラインツールであるcurl
にはさまざまなオプションがついているから、適宜活用して適切なリクエストを構成できる。
以下ではより弱い問題から始めてこの問題の最適解を見ていく。
準備問題1: httpサーバ
TCPには送信先ホスト名を伝達する機能は存在せず、上位レイヤで勝手に実装されている。httpではHost
ヘッダにホスト名が記述されるから、これを手で書き換えれば良い:
curl -H "Host: secret.example.com:8080" http://10.0.0.10:4444
準備問題2: 受付ポートとHost
ヘッダの不一致を無視するhttpsサーバ
httpsサーバではその前段であるTLSで証明書のやり取りをするが、curl
は送られてきたサーバ証明書のドメイン名がURLのドメインと一致しない場合、エラーを出して中断するのがデフォルトの挙動である。そこで-k
オプションで証明書の整合性の確認をオフにして
curl -k -H "Host: secret.example.com:8080" https://10.0.0.10:4444
で上手くいく……かは実装による。仮想ホストの振り分けがHost
ヘッダのみで行われているならこれで良いが、SNIによって振り分けていた場合、生のIPによるアクセスではSNIが送信されないので、デフォルトで降ってくる証明書のドメインと紐づいた仮想ホストに流されうる2。今回の場合、デフォルトのホストがexample.com
であった場合、https://example.com:8080
が見えてしまう。
そうでなくとも、TLSではexample.com
の証明書で認証し、httpとしてはsecret.example.com
に繋ぐというのは、かなり攻撃に近い接続3であるし、サーバ証明書をちゃんと確認しないのも危なっかしい。-k
オプションを使わない解決策はあるだろうか。
用件を考え直すと、要するにTLSやhttpにはsecret.example.com
に送っている気分になってもらいつつ、実際の送信先は10.0.0.10
にできれば良いわけで、つまりドメイン解決だけ騙せば良いのである。この辺りの用語で検索すると--resolve
オプションが出てくる:
curl --resolve secret.example.com:4444:10.0.0.10" http://secret.example.com:4444
これで、secret.example.com:4444
へのリクエストは10.0.0.10
(のポート4444
)に送られるようになり、TLS・httpはsecret.example.com:4444
に繋いでいる気分で動いてくれる。
本題: 受付ポートとHost
ヘッダの不一致を取り締まるhttpsサーバ
ところで、これでもHost
ヘッダはsecret.example.com:4444
と、ポート4444
に繋いでいる気分なのだが、それで問題ないだろうか?httpに関するRFCを覗くと、
RFC 9110 7.4. Rejecting Misdirected Requestsより
Once a request is received by a server and parsed sufficiently to determine its target URI, the server decides whether to process the request itself, forward the request to another server, redirect the client to a different resource, respond with an error, or drop the connection. This decision can be influenced by anything about the request or connection context, but is specifically directed at whether the server has been configured to process requests for that target URI and whether the connection context is appropriate for that request.
For example, a request might have been misdirected, deliberately or accidentally, such that the information within a received Host header field differs from the connection's host or port. If the connection is from a trusted gateway, such inconsistency might be expected; otherwise, it might indicate an attempt to bypass security filters, trick the server into delivering non-public content, or poison a cache. See Section 17 for security considerations regarding message routing.
Unless the connection is from a trusted gateway, an origin server MUST reject a request if any scheme-specific requirements for the target URI are not met. In particular, a request for an "https" resource MUST be rejected unless it has been received over a connection that has been secured via a certificate valid for that target URI's origin, as defined by Section 4.2.2.
The 421 (Misdirected Request) status code in a response indicates that the origin server has rejected the request because it appears to have been misdirected (Section 15.5.20).
と、スキームごとの整合性チェックに失敗したら421 Misdirected Request
を返すよう規定されている。特に証明書のドメイン名との不一致はMUSTでリジェクトするよう指示されている。
加えてRFC 9110 4.3.3. https Originsより
An origin server might be unwilling to process requests for certain target URIs even when they have the authority to do so. For example, when a host operates distinct services on different ports (e.g., 443 and 8000), checking the target URI at the origin server is necessary (even after the connection has been secured) because a network attacker might cause connections for one port to be received at some other port. Failing to check the target URI might allow such an attacker to replace a response to one target URI (e.g., "https://example.com/foo") with a seemingly authoritative response from the other port (e.g., "https://example.com:8000/foo").
と、ドメインが同一でポートが異なるhttpsバーチャルホストから侵入される可能性を記述している。ここから、Host
ヘッダについてポート番号の整合性をチェックする実装があってもおかしくないし、そうするべきだろう。4
従って、ポートに関する整合性が走るサーバではHost
ヘッダに含まれるポート番号も正しく記述する必要がある。
回答A: 泥臭く
というわけで、Host
ヘッダを書き換えて
curl --resolve secret.example.com:4444:10.0.0.10 -H "Host: secret.example.com:8080" http://secret.example.com:4444
とすれば良い。
しかしあまり気分の良い方法ではない。
問題点1: Host
ヘッダの書き換え
準備問題1でもそうだが、httpの仕様の根幹にあるHost
ヘッダを手で書き換えるというのは行儀が良くない。Host
ヘッダはできればcurl
に勝手に生成して欲しいものである。経由ポートでしかない4444
が、curl
のhttpモジュールに使われてしまっているのが原因だが、これを防ぐにはURLのホスト部がsecret.example.com:8080
にしないといけない。
問題点2: URLの書き換え
準備問題2でもそうだが、URLをhttps://secret.example.com:4444
としてしまっている。問題点1で指摘した通りhttpモジュールにポートを誤認させられるならその方が良いし、そもそもURLhttps://secret.example.com:8080
で指定されるエンドポイントに、10.0.0.10:4444
経由で繋がりたいという欲求なのだから、プロキシを指定するように書けるべきだ。つまり、--resolve
オプションがポートも変換できれば良いのだが、そんなオプションがあるだろうか。
回答B: --connect-to
を使う
以下curl(1)
より、--connect-to
オプションの説明。
For a request to the given "HOST1:PORT1" pair, connect to "HOST2:PORT2" instead. This option is suitable to direct requests at a specific server, e.g. at a specific cluster node in a cluster of servers. This option is only used to establish the network connection. It does NOT affect the hostname/port that is used for TLS/SSL (e.g. SNI, certificate verification) or for the application protocols. "HOST1" and "PORT1" may be the empty string, meaning "any host/port". "HOST2" and "PORT2" may also be the empty string, meaning "use the request's original host/port".
A hostname specified to this option is compared as a string, so it needs to match the name used in request URL. It can be either numerical such as "127.0.0.1" or the full host name such as "example.org".
--connect-to can be used several times in a command line
Example:
curl --connect-to example.com:443:example.net:8443 https://example.comSee also --resolve and -H, --header.
というわけで、顧客が本当に必要だったものはちゃんと存在している:
curl --connect-to secret.example.com:8080:10.0.0.10:4444" http://secret.example.com:8080
URLは書き換わらず、10.0.0.10:4444
をプロキシのように使うという点まで明白になっている。また、準備問題1・準備問題2でもこの方法は有効である。
さらに嬉しいことに、10.0.0.10
の部分はドメインでも良い。元々考えていた状況はPPPoEとIPoEの2本立てで、PPPoEのIPについてドメインに登録しているという状況だったから、IPoEのIPを登録することもできるはずだし、明らかにその方が便利だ。ここではipoe.example.com
にAレコード10.0.0.10
を登録してあるとする。--resolve
では変換先として生のIPアドレスしか指定できないので、別のツールでドメイン解決をする手間があった。--connect-to
ならドメインで指定すればよいので、ワンライナーになる5:
curl --connect-to secret.example.com:8080:ipoe.example.com:4444 http://secret.example.com:8080
まとめ
-
421 Misdirected Request
はポートフォワードと相性が悪い -
--resolve
よりも--connect-to
の方が自由度が高くて便利 -
manpage
は目を通しておくべき
-
稀に(工事後など)IPv4・IPv6 prefix・利用可能ポートの割り当てが変動する場合がある。 ↩
-
普通はSNIがついていなかったらフォールバックとしてHostでルーティングするとは思うが、存在しうる実装である。この実装なら少なくともSNI Spoofingは発生しないという利点がある。 ↩
-
実例: NGINXリバースプロキシでTLS Server Name Indication (SNI)と異なるドメイン名のバックエンドホストへルーティングできちゃう件について ↩
-
自作のnginxモジュールngx-strict-sniはそういう実装になっている。 ↩
-
これは単に記述が簡便に済むという点以外にも、ドメイン解決ツール(例えば
dig
)とcurl
のドメイン解決が異なる実装を採用している場合、本来疎通しない状況で疎通してしまうという問題を解決する。これが問題となるのはhttpsサーバをTCPレベルの疎通確認として使っている場合である。例えばssh
に利用可能な最も高速な経路を使わせるためにMatch exec
で各種ルートでの疎通確認をやっている時、ssh
と同一のドメイン解決手段で確認しないと、疎通確認は成功したのにssh
はドメイン解決で詰まる、みたいなことが起きる。しかしこれは有効な事例があまりにも狭いので脚注とした。 ↩