proxy
HTTPS
mitmproxy
Brotli

mitmproxyでhttps対応Data Compression Proxyを作る

動機

  • 4月の連休で帰省したが、データ通信量が契約を超過し低速モード突入したため重い
  • 特に画像が出てくるまでが遅い
  • 古くからある圧縮プロキシであるziproxyではhttpsサイトの圧縮に対応できないため、昨今メジャーなhttpsサイトでは圧縮されない
  • そこで、なんとかしてhttpsサイトに対応したデータ削減プロキシを作ってみよう!

mitmproxyとは

  • https://mitmproxy.org/
  • MITM = Man-In-The-Middle : 中間者
  • 本来、通常のSSL通信はサーバとクライアント間で暗号化と復号がされるため、中継する装置ではその内容を見ることはできない
  • squid等のProxyも暗号化されたままデータを中継する
  • データ圧縮に対応したziproxyもhttpsについては中継するのみ
  • しかし、mitmproxyは、サーバとクライアントの間のプロキシ側で一旦復号・暗号化をする
  • そのため、データの中を見てデバッグしたり、スクリプトによって処理を変更することができる

仕組み

https://docs.mitmproxy.org/stable/concepts-howmitmproxyworks/

HTTPプロキシとしての動作


how-mitmproxy-works-explicit.png
1. クライアントはプロキシに接続し、要求を出します。
2. Mitmproxyは上流のサーバーに接続し、単に要求を転送します。

HTTPSプロキシとしての動作


how-mitmproxy-works-explicit-https.png
1. クライアントはmitmproxyに接続し、HTTP CONNECT要求を発行します。
2. Mitmproxyは200 Connection Established、CONNECTパイプを設定したかのように応答します。
3. クライアントは、リモートサーバーと通信していると考え、TLS接続を開始します。SNIを使用して、接続先のホスト名を示します。
4. Mitmproxyはサーバーに接続し、クライアントが指定したSNIホスト名を使用してTLS接続を確立します。
5. サーバは、傍受証明書を生成するために必要なCNとSANの値を含む、一致する証明書で応答します。
6. Mitmproxyはインターセプト証明書を生成し、手順3で一時停止したクライアントTLSハンドシェイクを続行します。
7. クライアントは、確立されたTLS接続を介して要求を送信します。
8. Mitmproxyは、手順4で開始したTLS接続を介してサーバーに要求を渡します。

作り方

  1. Python環境構築用のシェルをダウンロード wget https://repo.continuum.io/miniconda/Miniconda3-latest-Linux-x86_64.sh
  2. シェル実行 sh ./Miniconda3-latest-Linux-x86_64.sh
  3. バージョン3.6以上であることを確認 python -V
  4. Pythonパッケージ管理システムでmitmproxyとpillow-simdとbrotliをインストール pip3 install mitmproxy pillow-simd brotli
  5. mitmproxy用のデータ圧縮スクリプトを作成(内容は後述) vi flows.py
  6. mitmproxyのCLIバージョンを起動 nohup mitmdump --listen-port 3126 --ssl-insecure -s flows.py --set stream_large_bodies=10m --ignore-hosts '(mzstatic.com|apple.com|icloud.com|mobilesuica.com|crashlytics.com|google-analytics.com)' --anticomp --set block_global=false --set flow_detail=2 &
  7. ポート3129でLISTENしていることを確認 lsof -i | grep mitm
  8. ログ確認 tail -f nohup.out
  9. スマホメールにプロキシサーバを追記したプロファイルを送付し導入するか、wifiにプロキシを設定する
  10. ブラウザから証明書サイトにアクセスして証明書をインストール http://mitm.it
  11. 証明書を信頼する (iPhone: 設定 -> 一般 -> 情報 -> 証明書信頼設定 -> ルート証明書を全面的に信頼する)
  12. 通信の最適化確認サイトでテスト、元画像の約20パーセントの容量になる http://horobi.com/Saiteika/
  13. 終了するとき pkill -9 mitm
flows.py
#!/usr/bin/env python3

from PIL import Image
import io, time, gzip
import brotli

def response(flow):
  if "content-type" in flow.response.headers and "content-length" in flow.response.headers:
    ru = str(flow.request.url)
    ae = str(flow.request.headers["accept-encoding"])
    ct = str(flow.response.headers["content-type"])
    cl = int(flow.response.headers["content-length"])
    s = io.BytesIO(flow.response.content)
    s2 = io.BytesIO()
    if (cl) > 100:

     # 画像を jpeg quality 10/100 に変換する
     if (ct) [0:6] == ("image/") and (cl) > 1000 and (ct) [0:9] != ("image/svg"):
         start = time.time()
         if (ct) [0:9] == ("image/png"):
           img = Image.open(s)
           if img.mode == 'RGBA' or "transparency" in img.info:
             img.save(s2, "png", optimize=True, bits=8)
           else:
             img = Image.open(s).convert("RGB")
             img.save(s2, "jpeg", quality=10, optimize=True, progressive=True)
             flow.response.headers["content-type"] = "image/jpeg"
         else:
           img = Image.open(s).convert("RGB")
           img.save(s2, "jpeg", quality=10, optimize=True, progressive=True)
           flow.response.headers["content-type"] = "image/jpeg"
         flow.response.content = s2.getvalue()
         ct2 = str(flow.response.headers["content-type"])
         cl2  = int(flow.response.headers["content-length"])
         i = int(cl2 /cl * 100)
         elapsed_time = time.time() - start
         print("*** compressed %s percent, size = %s/%s bytes, %s to %s, %s is processed, %s sec ***" % (i, cl2, cl, ct, ct2, ru, elapsed_time))
         return

     # スキームが http のテキストを gzip 圧縮する
     elif flow.request.scheme == "http" and not "content-encoding" in flow.response.headers:
         flow.response.headers["content-encoding"] = "none"     
         if (ct) [0:5] == ("text/") or (ct) [0:12] == ("application/") or (ct) [0:9] == ("image/svg"):
             start = time.time()
             gz = gzip.GzipFile(fileobj=s2, mode='w')
             gz.write(flow.response.content)
             gz.close()
             flow.response.content = s2.getvalue()
             ce = str(flow.response.headers["content-encoding"])
             flow.response.headers["content-encoding"] = "gzip"
             ct2 = str(flow.response.headers["content-type"])
             cl2 = int(flow.response.headers["content-length"])
             ce2 = str(flow.response.headers["content-encoding"])
             i = int(cl2 / cl * 100)
             elapsed_time = time.time() - start
             print("*** compressed %s percent, size = %s/%s bytes, %s to %s, %s to %s, %s is processed, %s sec ***" % (i, cl2, cl, ct, ct2, ce, ce2, ru, elapsed_time))
#             return

     # スキームが https のテキストを brotli 圧縮する
     elif flow.request.scheme == "https" and not "content-encoding" in flow.response.headers:
         flow.response.headers["content-encoding"] = "none"
         if (ct) [0:5] == ("text/") and (ct) [0:10] != ("text/plain") and (ct) [0:9] != ("text/html") or (ct) [0:12] == ("application/") and (ct) [0:16] != ("application/json") or (ct) [0:9] == ("image/svg"):
             start = time.time()
             s2 = flow.response.content
             s3 = brotli.compress(s2, quality=10)
             flow.response.content = s3
             ce = str(flow.response.headers["content-encoding"])
             flow.response.headers["content-encoding"] = "br"
             ct2 = str(flow.response.headers["content-type"])
             cl2 = int(flow.response.headers["content-length"])
             ce2 = str(flow.response.headers["content-encoding"])
             i = int(cl2 / cl * 100)
             elapsed_time = time.time() - start
             print("*** compressed %s percent, size = %s/%s bytes, %s to %s, %s to %s, %s is processed, %s sec ***" % (i, cl2, cl, ct, ct2, ce, ce2, ru, elapsed_time))
#             return

     else:
         ce = str(flow.response.headers["content-encoding"])
         print("*** %s, %s, %s, %s is not processed ***" % (ce, ct, cl, ru))

注意点

  • セキュリティのリスクは高まるので、自分専用で実験用に使う
  • 透過gif,pngは背景が黒になる、アニメーションはしなくなるのは割り切る → 透過 png は別処理を追加することで対応しました
  • Certificate pinning対応のサイトにはつながらない → --ignore-hostsオプションで復号せずに中継させる https://docs.mitmproxy.org/stable/howto-ignoredomains/
  • アプリのパッチダウンロードでエラーが出る → --anticompオプションを外す、flows.pyからテキスト圧縮処理を消す(必要に応じて配信側でgzip圧縮していることも多いし、FGOが起動できなくなったりと不具合も多いので、テキスト圧縮は外した方が良いです)

おわりに

 作成したProxyはiPhoneからmineoの低速モードで使っていますが、google mapの検索が2回ほど失敗する程度で、ほぼ不自由なく利用できています。
 今回Pythonのコードを生まれて初めて書きました。GW中の暇を利用して、iPhoneからTermiusというアプリでサーバにssh接続、safariで検索という環境でトライアンドエラーしました。
 コード書きにあたっては、QiitaのPython先輩方、オライリー・ジャパン発刊「Pythonチュートリアル 第3版」を参考にさせていただきました。

リファレンス

https://mitmproxy.org/
https://docs.mitmproxy.org/stable/
http://pillow.readthedocs.io/en/5.1.x/handbook/image-file-formats.html
http://pillow.readthedocs.io/en/latest/reference/Image.html
http://yalis.fr/cms/index.php/post/2014/01/23/Compress-the-mobile-web-even-further-both-HTTP-and-HTTPS
https://conda.io/miniconda.html

スクリーンショット

左:未圧縮 右:圧縮
D9ABC87F-792D-4AA4-B0E4-34CF7991C977.png ED4BF124-5CAB-4086-9D58-852BDC2538F2.png
131E5D93-7045-4014-888E-4523FA679E83.png C6144B07-3060-44CD-BCA1-6C5E9AC9605D.png
2B0BA4B7-D847-44AB-A328-E6126CE8A3A3.png 47F9198E-863A-4E21-98A8-4C7A193C3342.png

ログ
B6DD1BDA-5227-4E3D-BD72-2030DAC0AA52.png