mitmproxyでHTTPSプロキシする時、どんな通信が発生しているのか追ってみる

前回の記事で、ブラウザからアクセスするURLはそのままで接続先サーバだけ変える方法と、その際のHTTP通信の様子を確認しました。今回はその続きで、HTTPSの場合について、ブラウザとmitmproxy間、mitmproxyとサーバ間の通信内容を追っていきたいと思います。

処理の概要

HTTPSプロキシする場合のmitmproxyの挙動について書かれたドキュメントがあるので、そちらを参考にして概要を掴みます。以下はそのドキュメントから引用した図です。

f:id:ks888:20170504194435p:plain

この図に基づいて、順番に説明していきます。

1-2: ブラウザとプロキシ間でHTTP CONNECT

最初にHTTP CONNECTでブラウザとプロキシ間をつなぎます。通常のプロキシの場合はここでプロキシ先サーバとのTCP接続(HTTPトンネル)を確立するのですが、mitmproxyはしません。

mitmproxyはHTTPSリクエストの確認・変更に対応するため、ブラウザとプロキシ間、プロキシとサーバ間それぞれでTLS接続をします。そのため、ここでHTTPトンネルを作る必要はありません。

3,6: ブラウザとプロキシ間でTLS接続確立

次にブラウザとプロキシ間でTLSハンドシェイクをします。あとでWiresharkで確認しますが、3でClient Hello、6で残りのやりとりをしています。4-5ではプロキシとサーバ間でTLSハンドシェイクをしています。

なぜこのような順序なのかというと、mitmproxyが独自のサーバ証明書を作成する際、証明書に紐付けるドメイン名を明らかにするためです。この情報は、本物のサーバ証明書Common NameSubjectAltNameという箇所に書かれています。今回初めて知ったのですが、サーバ証明書には複数のドメイン名を紐付けられるらしいです。実際www.google.co.jpにアクセスしたときのサーバ証明書を見てみると、Common Name*.google.comで、SubjectAltNameは以下のようなドメイン名の列になっています。*.google.co.jpyoutube.comもここにあります。

f:id:ks888:20170504194946p:plain:w500

これらの情報を取得してからサーバ証明書を作るため、このような実行順序になっているようです。

4-5: プロキシとサーバ間でTLS接続確立

3,6で書いたような目的のために、ここでプロキシとサーバ間でTLS接続を確立します。図にはTLSハンドシェイクをどこまで進めるのか書かれていませんが、実際の挙動としては最後まで進めているようです。

7-8: HTTPSリクエス

ここまででブラウザとプロキシ間、プロキシとサーバ間それぞれでTLS接続が確立されたので、あとはいつもどおりリクエストを送るだけです。ただし、前回の記事に書いたような方法で接続先サーバを変える場合、プロキシとサーバ間ではもう一度TLS接続が必要になります。接続先変更後のサーバとはTLS接続していないためです。

実際の通信をみてみる

次にWiresharkで実際の通信をみてみます。

今回はブラウザからhttps://example.com/にアクセスすると、実際はhttps://www.google.co.jpにアクセスするようにしてみました。mitmproxyは以下のように実行しました。プロキシのポートは8080番です。その他の設定については前回の記事を参考にしてください。

mitmproxy --no-http2 -s "./mitmproxy_replace_host.py example.com www.google.co.jp"

ブラウザからhttps://example.com/にアクセスすると、TLSとHTTPのレベルでは以下のようなパケットが飛んでいました。

f:id:ks888:20170504200011p:plain

大体ドキュメント通りに並んでいます。8で再びTLS接続をしていますが、これはスクリプトがリクエストを書き換えているためです。4-5ではhttps://example.com/TLS接続したのに対し、8ではhttps://www.google.co.jpTLS接続しています。

まとめ

HTTPSの場合について、ブラウザとmitmproxy間、mitmproxyとサーバ間の通信内容を確認しました。mitmproxy組込みのCA証明書を使うために、通常のHTTPSプロキシとは異なる手順を踏んでいることがわかりました。

参考にしたページ: http://docs.mitmproxy.org/en/stable/howmitmproxy.html

mitmproxyを使って、アクセスするURLはそのままで接続先サーバだけ変える

最近App Engineでの開発中に、ブラウザからはexample-a.comというURLでアクセスしたいけど、実際にはexample-b.comのサーバに接続させたい、ということがありました。こういうとき、mitmproxyというツールを使うと楽だったので、やり方をメモしておきます。ついでに、mitmproxyをプロキシとして使う際の通信の様子をちょっと追ってみます。

mitmproxyについて

mitmproxyはHTTPプロキシの一種で、これをブラウザのプロキシに設定しておけば、HTTPトラフィックの確認や変更が手軽にできます。特に、mitmproxy組込みのCA証明書をインストールしておけばHTTPS通信も簡単に変更できちゃうところが良いです。

今回は、ブラウザからはexample-a.comドメインにアクセスしているように見えるけど、実際はexample-b.comにアクセスしている、という状況を作ります。ブラウザ、mitmproxy、サーバは、以下のような働きをします。

  • ブラウザ: mitmproxyをプロキシとしてHTTPリクエストを出す
  • mitmproxy: HTTPリクエストを書き換え、ブラウザが指定したサーバとは異なるサーバへHTTPリクエストを出す
  • サーバ: 特に変更なし

準備

  1. ブラウザのProxy設定

    mitmproxyはデフォルトで8080番ポートをLISTENしますので、ブラウザのProxy設定を127.0.0.1:8080にします。

  2. mitmproxyの準備

    mitmproxyを http://docs.mitmproxy.org/en/stable/install.html にある手順に沿ってインストールします。 MacOSの場合はbrewで一発です。

    brew install mitmproxy

    次に、proxy組込みのCA証明書を http://docs.mitmproxy.org/en/stable/certinstall.html にある手順に沿ってインストールします。

  3. HTTPリクエスト/レスポンスを書き換えるスクリプトを作成

    以下のスクリプトをダウンロードしておきます。

    Replacerクラスのrequestメソッドで、送られてきたHTTPリクエストのターゲットとHost Headerを書き換えています。また、リダイレクトが返ってきてもある程度はうまく動くようにHTTPレスポンスのLocationも書き換えています。

リクエストを書き換えてみる

実際にHTTPリクエストを書き換えてみます。まずは以下のようにしてmitmproxyを実行します。[src domain name]にブラウザからアクセスするドメイン名、[dst domain name]に実際に接続させたいサーバのドメイン名を指定します。

mitmproxy --no-http2 -s "./mitmproxy_replace_host.py [src domain name] [dst domain name]"

--no-http2オプションを指定しているのは、HTTP2のリクエストだと、HTTPリクエストのターゲットを書き換える箇所でエラーが出るためです。

この状態でブラウザからサイトにアクセスすると、[dst domain name]で指定したサーバの方にリクエストが飛んでいることが確認できるかと思います。

mitmproxyはどんな通信しているのか?

ここまでで目的は果たせましたが、せっかくなのでもう少し掘ってみます。具体的には以下の2点です。

  • HTTPの場合、mitmproxyはブラウザ、サーバとどんな通信をするのか?
  • HTTPSの場合はどうなのか?

まずはHTTPの場合です。ここの通信の様子はmitmproxy自身では確認できないと思うので、Wiresharkで見てみます。

今回はブラウザからhttp://ks888.hatenablog.com/にアクセスすると、実際はhttp://localhost/にアクセスするようにしてみました。localhostの80番でモックサーバーを起動して、mitmproxyを以下のように実行しました。

mitmproxy --no-http2 -s "./mitmproxy_replace_host.py ks888.hatenablog.com localhost"

ブラウザからhttp://ks888.hatenablog.com/にアクセスすると、HTTPレベルでは以下のようなパケットが飛んでいました。

f:id:ks888:20170504192518p:plain

赤枠の箇所にHTTPリクエスト/レスポンスの一行目が表示されています。ブラウザからプロキシへのHTTPリクエストは以下のようになっています。

GET http://ks888.hatenablog.com/ HTTP/1.1

注目したいのはhttp://ks888.hatenablog.com/で、いつもはURLのパス部分だけ指定されている箇所にプロトコルドメイン名まで指定されています。これはブラウザがプロキシ宛にHTTPリクエストを出す際、本当に接続したいサーバー(=オリジンサーバー)を指定する方法です。HTTP/1.1のRFCにもこのあたりに記載されています。

今回のスクリプトではここの値を他の値にすることで、リクエストをブラウザで指定したものとは異なるサーバに飛ばしています。

プロキシからサーバへのHTTPリクエストは以下のようになっています。特に気になるところはないです。

GET / HTTP/1.1 

HTTPレスポンスについても特に気になるところはありませんでした。

簡単ですがHTTPの場合を見てみました。次にHTTPSの場合を見ていきますが、長くなってきたので次回の記事に回したいと思います。TLSハンドシェイクをブラウザ<->プロキシ間とプロキシ<->サーバー間でやるだけでは?と思ってましたが、実際にはもう一工夫必要そうでした。

追記:続きの記事を書きました。

まとめ

mitmproxyを使って、ブラウザからはexample-a.comドメインにアクセスしているように見えるけど、実際はexample-b.comにアクセスしている状況を作りました。また、HTTPの場合にmitmproxyはどんな通信をするのか見てみました。

参考にしたページ: http://docs.mitmproxy.org/en/stable/howmitmproxy.html

GoogleのContainer Registry (gcr.io) からDocker Imageのメタデータだけを取得する

GCPではContainer RegistryにDocker Imageを格納できる。格納したImageの取得にはdocker pullコマンドが使えるけど、時々Imageのメタデータだけを参照したいことがある。そういうとき、いちいちdocker pullするのは時間がかかるし、ストレージも食う。

そういう場合に、Imageのメタデータだけをうまく取れないか、ということで色々やってみたらできたので、そのメモ。Docker RegistryのAPI V2を使っているだけなので、たぶん他のプライベートレジストリの場合にもほぼ同じ方法でできる。

tl;dr

以下のコマンドを叩く。[project id], [repository name], [tag name or digest]は環境に合わせて置き換える。gcloud auth loginはしてある前提。

digest=$(curl -sL -H "Accept: application/vnd.docker.distribution.manifest.v2+json" -H "Authorization: Bearer $(gcloud auth print-access-token)" https://gcr.io/v2/[project id]/[repository name]/manifests/[tag name or digest] | jq -r .config.digest)
curl -sL -H "Authorization: Bearer $(gcloud auth print-access-token)" "https://gcr.io/v2/[project id]/[repository name]/blobs/${digest}" | jq .

以下でもう少し詳しく説明する。

1. まずはImage Manifestを取得する

Image Manifestは、Docker Imageを他の人と共有する際に必要な情報もろもろをまとめたもの。各Layerのハッシュ値や署名などが格納されている。仕様はここで確認できる。例えば以下のようなManifestが作られる。

{
    "schemaVersion": 2,
    "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
    "config": {
        "mediaType": "application/vnd.docker.container.image.v1+json",
        "size": 7023,
        "digest": "sha256:b5b2b2c507a0944348e0303114d8d93aaaa081732b86451d9bce1f432a537bc7"
    },
    "layers": [
        {
            "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
            "size": 32654,
            "digest": "sha256:e692418e4cbaf90ca69d05a66403747baa33ee08806650b51fab815ad7fc331f"
        },
        {
            "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
            "size": 16724,
            "digest": "sha256:3c3a4604a545cdc127456d94e421cd355bca5b528f4a9c1905b15da2eb4a4c6b"
        },
        {
            "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
            "size": 73109,
            "digest": "sha256:ec4b8955958665577945c89419d1af06b5f7636b4ac3da7f12184802ad867736"
        }
    ]
}

config.digestはconfigが格納されたblobのハッシュ値。このハッシュ値の指すblobを取得すると、Docker Container作成時につかうconfig情報が取れる。次のステップではこのblobを取得する。今回は使わないけど、layersにはDocker Imageのレイヤーごとの情報が入っている。

GCPのContainer RegistryからこのManifestを取得する場合、curlだと以下のコマンドでできる。

curl -sL -H "Accept: application/vnd.docker.distribution.manifest.v2+json" -H "Authorization: Bearer $(gcloud auth print-access-token)" https://gcr.io/v2/[project id]/[repository name]/manifests/[tag name or digest]

Registry APIを使っており、その仕様はここで確認できる。大事なのはHeaderでManifestのバージョンを指定するところ。これを忘れると古いバージョンのManifestを取得してしまう。

2. Configのblobを取得する

次にDocker Imageのconfig情報を取得する。今度もRegistry APIを使う。仕様はこのあたり。これを1で取得したblobのハッシュ値と組み合わせて、以下のように使う。

curl -sL -H "Authorization: Bearer $(gcloud auth print-access-token)" https://gcr.io/v2/[project id]/[repository name]/blobs/[configのblobのハッシュ値。sha256:文字列を含む]

例えば以下のような情報が取得できる。

{
  "architecture": "amd64",
  "config": {
    "Hostname": "7e9ec6cde4d1",
    "Domainname": "",
    "User": "",
    "AttachStdin": false,
    "AttachStdout": false,
    "AttachStderr": false,
    "Tty": false,
    "OpenStdin": false,
    "StdinOnce": false,
    "Env": [
      "PATH=/go/bin:/usr/local/go/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
      "GOLANG_VERSION=1.8.1",
      "GOLANG_DOWNLOAD_URL=https://golang.org/dl/go1.8.1.linux-amd64.tar.gz",
      "GOLANG_DOWNLOAD_SHA256=a579ab19d5237e263254f1eac5352efcf1d70b9dacadb6d6bb12b0911ede8994",
      "GOPATH=/go"
    ],
    "Cmd": [
      "/bin/bash"
    ],
    "ArgsEscaped": true,
    "Image": "sha256:c0ccf5f2c0365842c2a2faeb28e8058761e83832ebccf4c10558f106e88ab89e",
    "Volumes": null,
    "WorkingDir": "/go",
    "Entrypoint": null,
    "OnBuild": [],
    "Labels": {
      "key": "value"
    }
  },
  ...
}

環境変数やラベルなどが格納されている。僕の場合は、gitのcommit IDをラベルに格納しておいて、このIDが一致していたら云々、みたいな使い方をした。