【ドキュメントを追え! mitmproxy編】 第1話 いかにして中間者になるかHTTP編

#Tech
#proxy
#Document
#mitmproxy

thumbnail

勝手にシリーズ始めます

始まりました「 ドキュメントを追え! 」シリーズ

なにそれ???

「ドキュメントを自分なりに解説する記事」です。

📖 この記事の読み方

この記事はドキュメントの各セクションでの説明を補足している内容です。

本家ドキュメントと照らし合わせながら補足として参考にすることをおすすめいたします。

解説する項目

  • 通常のプロキシと透過プロキシの違い

  • HTTP通信ではプロキシはどのようにして宛先の情報を取得しているのか?

本日のドキュメント

mitmproxyの仕組み

https://docs.mitmproxy.org/stable/concepts/how-mitmproxy-works

🏃🏻 Let’s go !


プロキシの形態

プロキシには2種類あります。

プロキシの種類:透過プロキシ・通常のプロキシ

プロキシ(Proxy)は、クライアントとサーバーの間に立って通信を中継する仕組みです。プロキシにはいくつか種類がありますが、ここでは主に「透過プロキシ(Transparent Proxy)」と「通常のプロキシ(Explicit Proxy)」の違いについて解説します。


通常のプロキシ(Explicit Proxy)

通常のプロキシ(Explicit Proxy)

概要: ユーザーやアプリケーションが明示的にプロキシの存在を認識し、プロキシサーバーを通すように設定して使うプロキシです。

特徴:

  • クライアントに明示的な設定が必要(例: ブラウザのプロキシ設定)
  • 通信経路が明確に「クライアント → プロキシ → サーバ」となる
  • 一般的なプロキシの形(HTTPプロキシ、SOCKSプロキシなど)

用途:

  • Webフィルタリング
  • キャッシュサーバー
  • セキュリティゲートウェイ

例: ブラウザ設定でプロキシサーバーを指定する(例: 192.168.1.10:8080)


透過プロキシ(Transparent Proxy)

透過プロキシ(Transparent Proxy)

概要: クライアントはプロキシの存在を意識せず、通常どおり通信を行っているつもりでも、途中で通信がプロキシにリダイレクトされる仕組みです。

特徴:

  • クライアントに設定は不要
  • ルーターやファイアウォールなどで通信を強制的にプロキシに転送(NATやiptablesなど)
  • プロキシは、元の宛先に通信を中継・改変・記録できる
  • SSL/TLS通信を扱う場合は証明書の信頼が課題になる(MITM構成)

用途:

  • 学校や企業などのネットワーク監視
  • トラフィックのフィルタリング・ログ取得
  • キャプティブポータル(例: 公共Wi-Fiで認証画面を表示する)

例: iptablesでTCP 80番の通信をプロキシサーバーに強制転送

両者の比較

比較項目通常のプロキシ(Explicit)透過プロキシ(Transparent)
クライアント設定必要不要(ネットワーク側で制御)
クライアント認識プロキシの存在を認識認識しない
導入の難易度クライアントごとに設定が必要ネットワーク機器の設定が必要
HTTPS対応明示的にCONNECTで対応可能中間者証明書が必要な場合が多い
主な利用シーン社内の出口制御、開発デバッグ監視、ログ収集、公衆ネットワーク制御

中間者になるには何が必要か

中間者になるには何が必要でしょうか。プロキシツールからみて、受けとった情報から何を得ればトラフィックを中継できるのか整理します。

必要な情報は以下2つです。

項目説明具体例・備考
リモート接続先情報(宛先の特定)クライアントがどのホストにアクセスしようとしていたかを知るSNI, HTTP Hostヘッダ, IPアドレスなど
プロトコル情報(レイヤーの解釈)HTTPかHTTPSかなど、通信内容を正しく扱う必要があるCONNECTメソッド、TLSハンドシェイクなど

HTTPの場合

通常プロキシ

通常のプロキシの場合、クライアントから送られるリクエストライン(HTTPリクエストの1行目)は次の情報になります

GET http://example.com/index.html HTTP/1.1

このリクエストラインから リモート接続先情報 と プロトコル情報 を得るわけですが今回は簡単です。

プロトコルもリモートのURLも完全に記載されており、プロキシはサーバー(リモート接続先)にこのリクエスト情報と同じリクエストを発行して、レスポンスをクライアントに返却すればいいです。

つまりクライアントのHTTPリクエスト・レスポンスをクライアントとサーバー間でそのまま橋渡しするだけです。

ちなみに、プロキシがない状態で通常のブラウザがリクエストを投げると以下のリクエストラインになります。

GET /index.html HTTP/1.1

宛先はL4で定義されているので完全なURLを表記する必要はありません。ではなぜ先の例ではドメインふくめた完全なURLがリクエストラインに入るのでしょうか?

それは、クライアントがプロキシを認識しているためです。 RFC 7230により転送されることが明らかな場合、ターゲットは絶対URIである必要があると記載があります。

透過プロキシ

透過プロキシではクライアントはプロキシを認識していません。そのためクライアントからのリクエストラインはプロキシなしの状態です。

GET /index.html HTTP/1.1

このリクエストラインを含むHTTPリクエストがルーターから転送されると、プロキシは困ります。 リモート接続先、サーバーURLがないため、HTTPレイヤーで宛先が解決できないです。

Host ヘッダーの情報を当てにすることも可能ですが、クライアントが自由に変更できるため、セキュリティ的に完全には信頼できません。ただし、通常の通信では正しい情報が含まれていることが多く、補助的に使用されることもあります。

一方、ソケットが受け取ったデータパケットには、元々リクエストがどこに送られるべきだったかという宛先情報(IPアドレスとポート)が含まれています。この情報は、透過プロキシのようにリダイレクトされた接続であっても、ソケットが保持している情報の一部としてアクセスできます。

ドキュメントでは

mitmproxyでは、これは各プラットフォームのリダイレクトメカニズムと通信する方法を知っている組み込みモジュールセットの形で提供されます

とありますが、ソケットのオプションからipを特定するモジュールが組み込まれています。

ソケット通信の処理はカーネルが管理しますが、各言語に提供されるソケットライブラリはカーネルの提供するソケットAPIにアクセスするためのインターフェースを提供します。

Pyrhonの場合、 setsockopt()getsockopt() から通信の挙動をカスタマイズ、通信情報の取得ができます。

このオプションを指定することでソケット通信の情報に書き込みや通信情報の読み込みが可能になります。

Linuxの場合、通信のメタ情報を取得するオプションとして SO_ORIGINAL_DST というものが使えます。Linuxカーネルは転送されたパケットの元の宛先IPとポート情報をこれに保持しています。これは読み取り専用ですが、ここから元の宛先情報を取得することで、透過プロキシでもリモート接続先が解決できるというわけです。リバースプロキシにおいてもこのオプションにより元の宛先情報を得ることができ、ロギングに役立ちます。

mitmproxyでは以下のソースコードがlinux用のモジュールになります。

https://github.com/mitmproxy/mitmproxy/blob/e87f699f8a4434c841ac79d7f712ec66e7dcaf2f/mitmproxy/platform/linux.py

解説を加えたソースコードの抜粋は以下です。(IPv4の場合)

import socket
import struct

#カーネルで定義された値 (define SO_ORIGINAL_DST 80)
SO_ORIGINAL_DST = 80

def original_addr(csock: socket.socket) -> tuple[str, int]:
    # Code omitted
    ...

    if is_ipv4:
        #SO_ORIGINAL_DSTというオプションを使って、リダイレクトされた接続の元々の宛先情報を取得します。
        dst = csock.getsockopt(socket.SOL_IP, SO_ORIGINAL_DST, 16) 
          # socket.SOL_IP: これは、IPレベルのソケットオプションを指定するための定数です。つまり、IP層で設定されているオプションを扱います。

          # SO_ORIGINAL_DST: これは、カーネルが持つ、リダイレクトされた接続の元の宛先アドレス(IPアドレスとポート番号)を取得するための定数です。透過プロキシやNATで使用されます。

          # 16: これは、バッファサイズを指定するパラメータです。SO_ORIGINAL_DSTオプションは、元の宛先のIPアドレス(4バイト)とポート番号(2バイト)を返します。これに加えて、他の情報も含まれるため、合計16バイトのバッファを要求しています。

        # structモジュールは、バイナリデータのパックとアンパック(解析)を行うためのモジュールです。この関数を使って、16バイトのバイナリデータから必要な情報を取り出します。
        port, raw_ip = struct.unpack_from("!2xH4s", dst)
          # 2x: 最初の2バイトをスキップします。この部分は余計な情報で、実際には必要ありませんが、ソケットのオプションデータとしてこのように定義されているためスキップします。

          # H: 次に、ポート番号(2バイトの整数、16ビット)を取得します。Hは「unsigned short」を意味し、2バイトで表される整数をアンパックします。

          # 4s: 次に、IPアドレス(4バイト、IPv4アドレス)を取得します。4sは「4バイトの文字列」を意味し、これがIPv4アドレスに該当します。

        ip = socket.inet_ntop(socket.AF_INET, raw_ip)

これに関してのPoC記事を後ほど書きたいと思っています。

まとめ

以上今回はHTTPのときのみにフォーカスして解説しました。

HTTPSの通信ではまた話が複雑になってきます。ここもCAの項目に従って解説できればとおもいます。今回は以上!

最後までお読みいただきありがとうございました!

RiiiM

Author: RiiiM

Backend Developer

Share