mitmproxyみたいなやつをRustで作ったので学んだこと書く
はじめに
Rustでmitmproxyみたいなやつを作れるライブラリを公開したのでそのときに学んだことを書きます。リポジトリはこちら。
mitmproxyとは
mitmproxyは、HTTPのプロキシサーバーで、HTTPSの通信の内容も見ることができるのが特徴です。
HTTPプロキシ
HTTPプロキシはHTTPのレベルで動作するプロキシです。なのでプロキシ側もHTTPを理解ってなければなりません。
HTTPの通信(プロキシなし)
printf "GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n" | nc example.com 80
HTTP/1.1 200 OK
Accept-Ranges: bytes
Age: 360610
Cache-Control: max-age=604800
Content-Type: text/html; charset=UTF-8
Date: Mon, 18 Nov 2024 06:44:56 GMT
Etag: "3147526947+gzip"
Expires: Mon, 25 Nov 2024 06:44:56 GMT
Last-Modified: Thu, 17 Oct 2019 07:18:26 GMT
Server: ECAcc (sac/256D)
Vary: Accept-Encoding
X-Cache: HIT
Content-Length: 1256
Connection: close
... # 以下Body (省略)
HTTPの通信(プロキシを使う)
printf "GET http://example.com/ HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n" | nc localhost 3003 # http://localhost:3003 HTTPにプロキシを立てている
HTTP/1.1 200 OK
... # 同じなので以下省略
このようにいつもならGET / HTTP/1.1
と送るところをGET http://example.com/ HTTP/1.1
と送ることでプロキシにどこにアクセスしたいかを伝えることができます。
プロキシが相手と通信して結果を返してくれるので、プロキシ側で内容を見たり編集したりすることができます。
HTTPSの通信(プロキシなし)
printf "GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n" | ncat --ssl example.com 443
... # 同じなので以下省略
HTTPSの通信(プロキシを使う)
これは特殊なのでcurlでやってみます。
curl -v https://example.com -x http://localhost:3003
* Trying 127.0.0.1:3003...
* Connected to (nil) (127.0.0.1) port 3003 (#0)
* allocate connect buffer!
* Establish HTTP proxy tunnel to example.com:443
> CONNECT example.com:443 HTTP/1.1
> Host: example.com:443
> User-Agent: curl/7.81.0
> Proxy-Connection: Keep-Alive
>
< HTTP/1.1 200 OK
< Date: Mon, 18 Nov 2024 10:17:21 GMT
<
* Proxy replied 200 to CONNECT request
* CONNECT phase completed!
* ALPN, offering h2
* ALPN, offering http/1.1
* CAfile: /etc/ssl/certs/ca-certificates.crt
* CApath: /etc/ssl/certs
* TLSv1.0 (OUT), TLS header, Certificate Status (22):
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* TLSv1.2 (IN), TLS header, Certificate Status (22):
* TLSv1.3 (IN), TLS handshake, Server hello (2):
* TLSv1.2 (OUT), TLS header, Finished (20):
* TLSv1.3 (OUT), TLS change cipher, Change cipher spec (1):
* TLSv1.2 (OUT), TLS header, Certificate Status (22):
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* TLSv1.2 (IN), TLS header, Finished (20):
* TLSv1.2 (IN), TLS header, Certificate Status (22):
* TLSv1.3 (IN), TLS handshake, Server hello (2):
* TLSv1.2 (IN), TLS header, Supplemental data (23):
* TLSv1.3 (IN), TLS handshake, Encrypted Extensions (8):
* TLSv1.2 (IN), TLS header, Supplemental data (23):
* TLSv1.3 (IN), TLS handshake, Certificate (11):
* TLSv1.2 (IN), TLS header, Supplemental data (23):
* TLSv1.3 (IN), TLS handshake, CERT verify (15):
* TLSv1.2 (IN), TLS header, Supplemental data (23):
* TLSv1.3 (IN), TLS handshake, Finished (20):
* TLSv1.2 (OUT), TLS header, Supplemental data (23):
* TLSv1.3 (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384
* ALPN, server accepted to use h2
* Server certificate:
* subject: C=US; ST=California; L=Los Angeles; O=Internet�Corporation�for�Assigned�Names�and�Numbers; CN=www.example.org
* start date: Jan 30 00:00:00 2024 GMT
* expire date: Mar 1 23:59:59 2025 GMT
* subjectAltName: host "example.com" matched cert's "example.com"
* issuer: C=US; O=DigiCert Inc; CN=DigiCert Global G2 TLS RSA SHA256 2020 CA1
* SSL certificate verify ok.
* Using HTTP2, server supports multiplexing
* Connection state changed (HTTP/2 confirmed)
* Copying HTTP/2 data in stream buffer to connection buffer after upgrade: len=0
* TLSv1.2 (OUT), TLS header, Supplemental data (23):
* TLSv1.2 (OUT), TLS header, Supplemental data (23):
* TLSv1.2 (OUT), TLS header, Supplemental data (23):
* Using Stream ID: 1 (easy handle 0x559036dbdeb0)
* TLSv1.2 (OUT), TLS header, Supplemental data (23):
> GET / HTTP/2
> Host: example.com
> user-agent: curl/7.81.0
> accept: */*
>
* TLSv1.2 (IN), TLS header, Supplemental data (23):
* TLSv1.3 (IN), TLS handshake, Newsession Ticket (4):
* TLSv1.2 (IN), TLS header, Supplemental data (23):
* TLSv1.3 (IN), TLS handshake, Newsession Ticket (4):
* old SSL session ID is stale, removing
* TLSv1.2 (IN), TLS header, Supplemental data (23):
* TLSv1.2 (OUT), TLS header, Supplemental data (23):
* TLSv1.2 (IN), TLS header, Supplemental data (23):
* TLSv1.2 (IN), TLS header, Supplemental data (23):
* TLSv1.2 (IN), TLS header, Supplemental data (23):
< HTTP/2 200
< age: 533083
< cache-control: max-age=604800
< content-type: text/html; charset=UTF-8
< date: Mon, 18 Nov 2024 10:17:23 GMT
< etag: "3147526947+gzip+ident"
< expires: Mon, 25 Nov 2024 10:17:23 GMT
< last-modified: Thu, 17 Oct 2019 07:18:26 GMT
< server: ECAcc (sac/251E)
< vary: Accept-Encoding
< x-cache: HIT
< content-length: 1256
<
* TLSv1.2 (IN), TLS header, Supplemental data (23):
* TLSv1.2 (IN), TLS header, Supplemental data (23):
... # 以下Body省略
* Connection #0 to host (nil) left intact
最初にプロキシにCONNECT example.com:443 HTTP/1.1
と送ることでプロキシに対してexample.com:443
との通信を要求します。
その後はexample.com:443とTCPのレベルでトンネリングされるのでそこから普通にHTTPSの通信を行います。(この例ではexmaple.comとHTTP/2で通信しています)
トンネリングされたあとの通信は暗号化されているのでプロキシ側で内容を見ることはできず、編集してもバレます。
mitmproxyの仕組み
上記のように通常のプロキシはHTTPSの通信を見ることができませんが、mitmproxyはあらかじめクライアントに対して自分の証明書をルート証明書として信用してもらうことで、クライアントが送るデータをすべて見ることができます。
各中継先のドメインの証明書はその場でmitmproxyが署名します。
このあとプロキシ側は本当にexample.com
に対するプロキシとして振る舞っても、別に適当にレスポンスを作って返してもいいです。
HTTP Upgrade
HTTP/1.1の通信は途中で別のプロトコルにUpgradeすることがあります。プロキシはこれも考慮する必要があります。
ブラウザはWebSocketにしかUpgradeすることができない気がします(ソース見つからず)。
Rustでやる
基本的にブラウザがプロキシを使う想定です。
ライブラリとして作り、プロキシのロジックなどはユーザーが実装することを想定しています。
使用したライブラリと所感
hyper
HTTPライブラリ。結構いい感じに低レベルなのが非常に良い。インターフェースが非常に洗練されている。多少とっつきづらいと思っても結局こいつのほうが理にかなっている。
- Content-Lengthで指定された長さのBody
- Transfer-Encoding: chunkedのBody
- Server-Sent Event
- その他なにかあるかもしれない
が全対応なので楽。
HTTP/2までサポートしているがHTTP/3はまだサポートしていない。
rustls
Rust製のTLSライブラリ。セキュリティ上問題のあるアルゴリズムがサポートされていないので、ブラウザからはアクセスできるがrustlsからはアクセスできないサイトがある。サーバー側で使う分には問題ない。
native-tls
OSにあるTLSライブラリを使うラッパー。
rcgen
証明書を作るライブラリ。これでその場でクライアントが通信したいドメインの証明書を作る。
インターフェース
色々試した結果、hyper::service::HttpService
を利用してプロキシを抽象化することにしました。
ちなみにですが、hyper
のService
はtower
のService
とは直接の互換性はありません https://github.com/hyperium/hyper/blob/0bd4adfed2bf40dda6867260ded05542d5b921c5/docs/ROADMAP.md?plain=1#L359-L368。
しかしhyper-util
にhyper_util::service::TowerToHyperService
があります。
利用者はhttp::request::Request
(通常はURIの部分がpath以降のみだがここではスキーム、ホストなど全部込み)を受け取ってhttp::response::Responseを返すHttpService
を実装することでプロキシのロジックを実装できます。
let client = DefaultClient::new().unwrap();
let server = proxy
.bind(
("127.0.0.1", 3003),
service_fn(move |req| {
let client = client.clone();
async move {
let uri = req.uri().clone();
// You can modify request here
// or You can just return response anywhere
let (res, _upgrade) = client.send_request(req).await?;
println!("{} -> {}", uri, res.status());
// You can modify response here
Ok::<_, http_mitm_proxy::default_client::Error>(res)
}
}),
)
.await
.unwrap();
RFC 9110にはプロキシがどのように振る舞ってはならないかなどの規定が書かれていますが、それらはライブラリの利用者に任せる想定です。
考慮点など
作ったものを実際にChromeのプロキシとして設定して試したときに気づいたことなど。
を使うと設定が便利。
HTTP/2
URLだけではブラウザはHTTP/1かHTTP/2を使うかどうかわからないので、TLSのALPNという機能を使ってサーバーとHTTP/2で通信するかどうか決めます。
ブラウザがhttp(sなし)でHTTP/2を使うことはありません。
Hostヘッダー禁止
例えばHTTP/1.1向けのリクエストをプロキシが受けてHTTP/2で中継先にリクエストを送るときにHTTP/1.1向けのヘッダーをHTTP2にそのまま渡すと良くないことがある。
Host
ヘッダーはHTTP/1.1のときには必須だが、HTTP/2のときにはMust Not
なので消さなければならない
実際に https://www.google.com/ にHTTP/2のときにHost
ヘッダーを送ると拒否される。
HTTPバージョンのマッチング
例えば https://echo.websocket.org/ はHTTP/1.1で接続するとWebsocketにUpgradeされるが、HTTP/2で接続すると普通にBodyが返ってくる。(HTTP/2にWebsocketはないのでしょうがない)
なのでmitmproxyでそのまま中継したい場合はをクライアントがHTTP/1.1で接続してきたときにはHTTP/1.1で中継し、HTTP/2で接続してきたときにはHTTP/2で中継する必要がある。
TCP_NODELAY
接続する際にTCPにTCP_NODELAY
を設定しないとどうしてもうまく通信できないサーバーがあった。よくわからない。
Discussion