MITM プロキシを作る

f:id:masm11:20210221003205p:plain

こんにちは、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#readIO#sysread が用意されていますが、 これらは混ぜて使うとおかしな挙動になります。 OpenSSL が sysread を使っているようなので、私も sysread を使うことにしました。

IO#readlineIO#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 で別の証明書ではいけないし...

インゲージではエンジニアを募集しています。 こんな話題に花を咲かせたいそこのあなた! ぜひうちで働きませんか? (業務ではこんなコードは書きませんが・・) 詳細は以下のページへ!

https://ingage.co.jp/recruit

ではまた!