SNI Inspection Proxyを書いた

とある事情があり、TLSが有効な複数のウェブサイトをリバースプロキシ経由でアクセスする必要が生じた。

正確に記すならば、これまでのアクセス元IPアドレスと異なるIPアドレスでアクセスを行う必要が生じたというのが正しい。

リバースプロキシ経由以外の方法がないわけではないが、いくつかの前提条件からリバースプロキシを経由する方法を検討した。

リバースプロキシ経由のアクセスがなされるような構成というのは、一般的にはリバースプロキシ・アップストリームともに同じ組織で管理されており
またTLS終端もリバースプロキシで行うといったことが多いのではないかと思う。

今回のケースはそれらとは異なる。

アップストリームは完全に外部に存在しリバースプロキシそれ自体には対象FQDNのTLSサーバ証明書と対になる秘密鍵も与えられない。

本来と異なる経由でアクセスされるウェブサイト(FQDN)が1つである場合や複数であっても接続先が同じIPアドレスであるのであればシンプルに考えることができる。

リバースプロキシのtcp:443に来たアクセスを無条件に1つのIPアドレス宛に転送してやれば済むのである。

一方で、ウェブサイト(FQDN)が複数ありなおかつそれらのIPアドレスがそれぞれ異なる場合、もしそれらのアクセスを
単一のプロキシで受けるのであれば何らかの方法でアクセス先のFQDNを特定することが必要となる。

なお、一般的にリバースプロキシはアップストリームのIPアドレスが隠蔽され、リバースプロキシ(あるいはその前段に置かれる機器)のIPアドレスがパブリックにされる構成である。
今回は経路を曲げたい(アクセス元IPアドレスを変えたい)という要求からスタートしているため少し状況が異なる。

  • パブリックなウェブサイトのIPアドレスはあくまでも本来のウェブサーバ(リバースプロキシから見てアップストリーム)となる
  • リバースプロキシを使うアクセス元では ローカルの名前解決 たとえば hostsファイルや キャッシュネームサーバで名前解決の書き換えを行い、対象のFQDNの名前に対してリバースプロキシのIPアドレスが返るようにする

以上、長くなってしまったがここまでが前提となる環境である。

今回はこのような条件で利用出来るリバースプロキシのPoCを書いてみた。

PoC of proxy for SNI (TLS)

本来、TLS(https)とは通信内容の暗号化・改竄検出などを行い、なりすまし・中間者攻撃・盗聴などの攻撃を防ぐためのものである。

暗号化されないHTTPのリバースプロキシは平文でやり取りされるヘッダ情報から”Host”ヘッダを取り出しで処理を決める材料とする。
しかしながら暗号化された通信においては復号しない限りこの情報を得ることが出来ない。

そこでどのサイト(FQDN)との通信なのかを特定するため、今回着目したのがSNI(Server Name Indication)である。

SNI(Server Name Indication)とはなにか?

rfc6066 “3. Server Name Indication” ではこう書かれている。

TLS does not provide a mechanism for a client to tell a server the name of the server it is contacting.
It may be desirable for clients to provide this information to facilitate secure connections to servers that host multiple ‘virtual’ servers at a single underlying network address.

“TLSは、サーバーにそれが接触しているサーバーの名前を伝えるために、クライアントのためのメカニズムを提供しません。”
“クライアントは、単一のネットワークアドレスに複数の「仮想」サーバをホストするサーバへのセキュアな接続を容易にするために、この情報を提供することが望ましい場合があります。”

一言で言うならば、1つのIPアドレスで複数の仮想サーバをホストするような環境でもTLSを容易に使えるよう情報を提供する拡張といったものである。

実装の解説など

1-4行目、25、26,148,149行目、この辺りはrubyでネットワーク接続を受け付けるサーバを書く場合のお約束みたいなものである。
TCPServerクラスのリファレンスからそのままコードを拝借した。

main.rb
1
2
3
4
5
6
7
8
9
10
11
12
require "socket"

gs = TCPServer.open("",443)
addr = gs.addr

while true
Thread.start(gs.accept) do |s|

# .....

end
end

28-40行if false end で括っている。
消せば良いのに。失敗コードの話をしたく、あえて残したままcommitした。この話は後ほど。

続いて42-45行

main.rb
1
2
3
4
buf = ''
s.readpartial(65540,buf)
payload_length = buf.bytes[3]*256+buf.bytes[4]
tls_payload = buf.bytes[5...].map{|b| b.chr}.join

ようやくここで、SNIとはなにかで触れた情報を取り出すことが出来る。
RFC8446にはTLS Record Protocol 構造がある。

Record Protocolは以下のような構造をとる。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
enum {
invalid(0),
change_cipher_spec(20),
alert(21),
handshake(22),
application_data(23),
(255)
} ContentType;

struct {
ContentType type;
ProtocolVersion legacy_record_version;
uint16 length;
opaque fragment[TLSPlaintext.length];
} TLSPlaintext;

ここで ContentType は 1byte 、uint16 は 2bytes である。またProtocolVersionも 2bytes である。
s.readpartial(65540,buf) の 65540 の根拠は 1 + 2 + 2 + 65535 である。

fragmentまで全部欠損することなく読むとしても 65540 bytes あれば足りる。

buf.bytes[5...] についてはきちんと実装するなら buf.bytes[5...5+payload_length] かもしれない。影響がないので手を抜いた。

こうして tls_payload には fragment が収まることとなる。

47-60行目はゴミなので割愛。

続いて tls_payload をさらにパースしていく処理が続く。RFCで言うと、4.1.2. Client Helloのあたりだ。

ちなみに私の今回のコードでは Handshake Type が何なのかで分岐したりチェックしたりしていない。
接続の最初に来るのは Client Hello であり、Client Helloのメッセージの中からservernameを見つけ出せばそれ以上パケットの中は見ないのが今回のコードなのでそれで十分であると思っている。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Structure of this message:

uint16 ProtocolVersion;
opaque Random[32];

uint8 CipherSuite[2]; /* Cryptographic suite selector */

struct {
ProtocolVersion legacy_version = 0x0303; /* TLS v1.2 */
Random random;
opaque legacy_session_id<0..32>;
CipherSuite cipher_suites<2..2^16-2>;
opaque legacy_compression_methods<1..2^8-1>;
Extension extensions<8..2^16-1>;
} ClientHello;

この構造をパースさせるため以下のような定義を書いた。

1
2
3
4
5
6
7
8
9
10
11
header_struct = [
{label:'Handshake Type',length:1,lenbytes:0,},
{label:'Length',length:3,lenbytes:0,},
{label:'Version',length:2,lenbytes:0,},
{label:'Timestamp',length:4,lenbytes:0,},
{label:'Random',length:28,lenbytes:0,},
{label:'Session ID',length:0,lenbytes:1,},
{label:'Cipher Suites',length:0,lenbytes:2,},
{label:'Compression Methods',length:0,lenbytes:1,},
{label:'Extensions',length:0,lenbytes:2,},
]

lengthが0より大きい場合には固定長と判断しいきなりその長さだけ値を読み込む、対してlengthが0の場合はlenbytes分だけ長さの情報があると解釈し
まず長さを決定した上でその長さ分の値を読む。

そのような処理を書いたのが64-83行となる。

この中で本当に欲しいのは Extensions の情報であるから、84-104行
さらにExtensionsを個々のextensionに分解してゆく。

その中でも欲しいのは extension_type = 0 (server_name) であるから、これがあれば95-103行目
さらにパースを行う。

こうして無事に、SNIのservernameを得られたところで「107-142行](https://github.com/rhykw/sni-inspection-poc/blob/0afceeea4ea4b2779e70d0fb67d486e422cb6ac8/main.rb#L107-L142)のプロキシ処理となる。

ここでやっていることは単純で、クライアント側のソケット・サーバ側のソケットそれぞれにnonblockなreadを発行し、read出来たものを対向のソケットにwriteすることを繰り返しているだけである。

nonblockなreadやwriteを使うことで双方向通信のために自前でスレッドを起こす処理が不要になっている。

ここまで拙い内容ではあるがPoCの解説である。