こんにちは、masm11 です。
MITM (man-in-the-middle) プロキシをご存知でしょうか? ブラウザとサーバの間に入って HTTPS の仲介をするのは普通の プロキシと同じですが、通信内容を覗くことができます。 もちろん自由に覗けてしまっては HTTPS の意味がありませんので、 特別な証明書をブラウザにインストールする必要はあります。
今回はそんな MITM プロキシを Ruby で作ってみたいと思います。
先に成果物
というか、既に作って、以下に置いてあります。
https://github.com/masm11/mitm-proxy2/blob/master/mitm-proxy.rb
今回はこれがどのように動いているのか、説明してみたいと思います。
CA 証明書の作成
https://github.com/masm11/mitm-proxy2/blob/master/mitm-proxy.rb#L15-L46
このメソッドは Ruby の OpenSSL を使って、CA のオレオレ証明書と 秘密鍵を生成しています。
# CA の情報を設定 name = OpenSSL::X509::Name.new name.add_entry 'C', 'JP' name.add_entry 'ST', 'Osaka' name.add_entry 'DC', 'Kita-ku' name.add_entry 'O', 'INGAGE Inc.' name.add_entry 'CN', 'Masm11 CA'
この辺は、いつも openssl コマンドで CSR を作る時に入力している項目ですね。
# CA の秘密鍵/公開鍵を生成 rsa = OpenSSL::PKey::RSA.generate(2048)
RSA 2048bit の秘密鍵/公開鍵を生成しています。
# CA の秘密鍵を保存 File.write('ca.pkey', rsa.export(OpenSSL::Cipher::Cipher.new('aes256'), CA_PASSPHRASE))
秘密鍵にパスフレーズを設定してファイルに保存しています。 起動ごとに作る必要はありませんので、作ったものはファイルに保存しておくことにしました。
# CA 証明書を作成 cert = OpenSSL::X509::Certificate.new cert.not_before = Time.now cert.not_after = Time.now + 3600 * 24 * 365
有効期限はとりあえず1年とします。
cert.public_key = rsa.public_key
cert.serial = 1
cert.issuer = name
cert.subject = name
公開鍵等を設定しています。
ext = OpenSSL::X509::Extension.new('basicConstraints', OpenSSL::ASN1.Sequence([OpenSSL::ASN1::Boolean(true)])) cert.add_extension(ext)
CA の証明書として使えるようにします。
cert.sign(rsa, sha1)
最後に署名します。
# CA 証明書を保存 File.write('ca.crt', cert.to_pem)
こちらも同様に保存しておきます。
CA 証明書の読み込み
https://github.com/masm11/mitm-proxy2/blob/master/mitm-proxy.rb#L48-L51
2回め以降の起動時は、CA 証明書はファイルから読み込むことにします。
read_ca_cert はファイルからオブジェクトを生成して返しているだけです。
サーバ証明書の生成
今度は、CA でなくサーバの証明書の生成です。
CA は本物のサーバの秘密鍵を持っていないので、自前で秘密鍵を用意し、 対応する証明書も用意します。その証明書は上で作った CA の秘密鍵で 署名されているわけです。
先程とよく似ているので、違う部分だけ説明します。
begin return OpenSSL::PKey::RSA.new(File.read("mitm-proxy/#{domain}.pkey")), OpenSSL::X509::Certificate.new(File.read("mitm-proxy/#{domain}.crt")) rescue Errno::ENOENT end
こちらも、一度作ったものはファイルに保存することにしました。
ここでは、ファイルから読んでオブジェクトを生成して返しています。 ただしファイルがなくて ENOENT になった場合は次の処理へ進みます。 なお、他の例外が発生した場合は呼び出し元へそのまま投げます。
name.add_entry 'CN', domain
サーバ証明書の CommonName はそのドメインです。
crt.issuer = ca_cert.issuer crt.subject = name
発行者は CA です。また対象はそのサーバです。
起動時の初期化
ここからは、処理の順を追って説明します。
https://github.com/masm11/mitm-proxy2/blob/master/mitm-proxy.rb#L152-L154
if ARGV[0] == '--init' init_ca_cert end
初回起動時は --init
オプションを付けるものとします。
その場合は CA 証明書を生成します。
https://github.com/masm11/mitm-proxy2/blob/master/mitm-proxy.rb#L156
ca_pkey, ca_cert = read_ca_cert
CA の秘密鍵と証明書を読み込みます。
Socket.tcp_server_loop('127.0.0.1', 8000) do |sock, addr| fork do serve(ca_pkey, ca_cert, sock) exit(0) end sock.close end
TCP のサービスを書く時、接続を受け付けたらプロセスを fork し、 新しいプロセスに処理を任せて、本体は引き続き次の接続を待つ、 という作り方をよくします。今回もそうしました。
127.0.0.1:8000
をプロキシ待ち受けアドレスとします。
そこで接続を待ち受け、接続があったらブロックが呼ばれます。
ブロックの中では、fork して serve
メソッドで処理します。
親プロセスではソケットが不要なので閉じておきます。
なお、ここで閉じ忘れると、通信相手 (ブラウザや curl) が 切断を認識できなくてハマります。
処理する
では serve
メソッドを見ていきます。
https://github.com/masm11/mitm-proxy2/blob/master/mitm-proxy.rb#L99-L150
line = sysread_line(sock).chomp if line !~ /\ACONNECT\s+(.*):(\d+)\s/ raise 'Bad connect: ' + line end host = $1 port = $2.to_i
ソケットから 1行読みます。読んだものが CONNECT であることを確認して、 接続先サーバ名とポート番号を取り出します。
なお、Ruby には IO#read
と IO#sysread
が用意されていますが、
これらは混ぜて使うとおかしな挙動になります。
OpenSSL が sysread を使っているようなので、私も sysread を使うことにしました。
IO#readline
は IO#read
を使っていて、ここでは使えませんので、
sysread 版を自分で作りました。それが sysread_line です。
loop do line = sysread_line(sock).chomp break if line == '' end
CONNECT
以降の HTTP ヘッダの処理です。ここでは読み飛ばしているだけです。
空行が現れたら HTTP ヘッダの終わりです。
cs = TCPSocket.open(host, port) cs = OpenSSL::SSL::SSLSocket.new(cs) cs.connect cs.post_connection_check(host)
接続先が得られたので、接続します。TCP で接続し、それを OpenSSL に渡して、
cs.connect
で TLS ハンドシェイクをしています。cs.post_connection_check
では
証明書の中の CommonName が正しいかをチェックしています。
sock.syswrite "HTTP/1.1 200 OK\r\n\r\n"
ここまで成功したら、ブラウザに成功レスポンスを返します。
sysread と同様、syswrite を使います。
pkey, cert = get_cert(ca_pkey, ca_cert, host)
サーバの自前秘密鍵と証明書を取得しています。このメソッドは先程上で用意したものです。
ctxt = OpenSSL::SSL::SSLContext.new('TLSv1_2_server') ctxt.cert = cert ctxt.key = pkey
ブラウザと TLS ハンドシェイクするための準備です。 TLS のバージョン、証明書、秘密鍵をセットしています。
サーバ側としてハンドシェイクするので、少々手間がかかります。
sock = OpenSSL::SSL::SSLSocket.new(sock, ctxt) sock.accept
ソケットを OpenSSL に渡して、ハンドシェイクしています。
以上で、プロキシ-ブラウザとプロキシ-サーバが TLS で接続できました。 ここから先は、ソケット間の橋渡しをしているだけです。
rs = IO.select([sock, cs])
ブラウザ側接続またはサーバ側接続のどちらかからデータを読めるようになるまで待ちます。
rs = rs.first.first
読めるようになったら、この場所にソケットが入っていますので、取り出します。
if rs == sock buf = sock.sysread(1024) if buf.nil? # EOF break end cs.syswrite(buf)
ブラウザ側ソケットから読めるようになった場合の処理です。 最大 1024バイト読んで、EOF でなければサーバ側ソケットへ送ります。
elsif rs == cs buf = cs.sysread(1024) if buf.nil? # EOF break end sock.syswrite(buf)
逆向きの場合の処理です。
いずれかが EOF になった場合は、ループを抜け、プロセスを終了します。 その時に接続が切れます。
試す!
ブラウザで試せばいいのでしょうが、CA 証明書をブラウザに設定する必要がある等、 手間ですので、今回は curl で試します。
https_proxy=http://localhost:8000 curl --cacert ./ca.crt https://www.masm11.me/
プロキシの場所を環境変数で渡しています。
--cacert ./ca.crt
で CA 証明書を指定しています。この ca.crt
は MITM プロキシが
生成した CA 証明書のファイル名です。
これで試したところ、
luna:mitm-proxy % https_proxy=http://localhost:8000 curl --cacert ./ca.crt https://www.masm11.me/ <!DOCTYPE html> <html> <head> <title>Welcome to nginx!</title> <style> body { (以下略)
通信できました!
まとめ
以上、MITM プロキシの説明をしてみました。
最後に説明したループの中で、buf には通信内容が平文で入っています。
今回はブラウザではテストしませんでした。実はブラウザでのテストは大変なのです。 serial が戻ってはいけないし、同じ serial で別の証明書ではいけないし...
インゲージではエンジニアを募集しています。 こんな話題に花を咲かせたいそこのあなた! ぜひうちで働きませんか? (業務ではこんなコードは書きませんが・・) 詳細は以下のページへ!
ではまた!