迫真の氷結晶

初投稿です。

  • HOME
  • 自己紹介
  • 作品集
  • ポエム系

HTTP上の画像をすり替える悪いルーターの制作

2019-11-26
  • #技術系
  • #ネットワーク

9月の話ですが、Raspberry Piで、HTTP上を流れる画像をすり替える悪いルーターを作りました。

軽い中間者攻撃実験とも言えると思います。

小俣光行『ルーター自作でわかるパケットの流れ』(ルーター自作本)をベースに、それを改造して、ルーティングテーブル、NAPTを実装してから、画像のすり替えを実装しました。

丸10日かかりました…

ソースコード: https://github.com/iciclize/payload/tree/yjsnpi

デモ映像

やってること

2つインターフェースのあるRaspberry Piの、WAN側としておうちのルーター、LAN側としてWindowsのホストをつないで、Windowsのブラウザと外部のHTTPサーバーとの通信を書き換える実験をしています。

  1. パケットを監視して、LANのホスト(以下クライアントやブラウザと言ったりします)と, 外部ホスト80/TCP(HTTPサーバー)とのTCPコネクションの開始を検出して追跡.
    • それぞれのシーケンス番号, ACK番号を記録しておく.
  2. サーバーからの最初のレスポンスに Content-Type: image が含まれていたら, そのパケットを破棄し, サーバーにRSTパケットを送ってサーバーとの接続を強制切断.
    • 今回はサーバーから送られてくる画像データは一切必要ないので. もう送ってこないでということで.
  3. 用意しておいたニセのHTTP画像パケットをクライアントに送りつける.
    • レスポンスヘッダには Connection: close をつけておいて, 画像の受信が完了したクライアントが自分からTCPコネクションを切断するように仕向ける.

なおルーター実行時、サーバーからの応答TCPパケットを、ルーターより先にカーネルが処理してしまって、サーバーに対しカーネルが勝手にRSTパケットを返してしまい、うまくクライアント・サーバー間でTCPがつながらなかったので、

iptables -A OUTPUT -p tcp --tcp-flags RST RST -j DROP

としてLinuxからRSTが一切送れないようにして実験しました。

なので、後から気づきましたが、実験時送ったRSTは消されていて、実際にはRSTを送れていません。

経緯・動機

もともと昨年、画像をすり替えることを目標に自作ルーターをしていたのですが、画像をすり替えられるまでに至らず、画像のすり替えは保留としていました。

TCP/IPプロトコルスタック自作インターンに参加することになり、ちょうどいいタイミングだと思って、インターン中に画像のすり替えにチャレンジしました。(全然インターン中に終わらず延長線になりましたが。)

ルーティングテーブルの実装

実はルーター自作本のソースコードにはルーティングテーブルに対応する記述はないので自分で簡単に作る必要があります。

  • 宛先アドレス
  • サブネットマスク
  • ゲートウェイ
  • 出口インターフェース

の4フィールドを持ったルーティングテーブルをテキストファイルで保存しておき、ごくシンプルに、対応するエントリを線形探索して経路を決定するようにしました。

NAPTの実装

ルーティングテーブルだけあってNAPT(IPマスカレード)の機能がなければどうなるかというと、LANからやってくるパケットの送信元アドレスがWAN側のアドレスに変換されずそのままWANに流れてしまうため、そのパケットが戻ってくる先のアドレスがプライベートアドレスとなってしまい、通信が成立しません。

つまり、上位にNAPTをしてくれるルーターが別に必要になるということで、これはちょっとダサい。

なのでまずNAPTを自前で実装しました。といってもRFCなど読まずに作ったので非常に簡易的ですが。

上位プロトコルはTCP, UDPのみに対応しています。ICMPのNAPT(?)には対応していないのでWAN-LAN間でのpingは通りません。

LAN側からWAN側への外向きパケットに対するNAPT

まず、インターフェース番号0をWAN側とし、それ以外をLAN側と決めておくことにします。

LAN側からWAN側に向かうパケットがやってきたら

  • 送信元アドレス
  • 送信元ポート番号
  • 宛先アドレス
  • 宛先ポート番号
  • NAPTに使う空きポート番号

をNAPTテーブルに記録します。

そのパケットの送信元アドレスと送信元ポート番号をそれぞれ

  • 送信元アドレス -> WAN側インターフェースに設定されたアドレス
  • 送信元ポート番号 -> 先ほど決定した空きポート番号

に書き換えます。

IPアドレス, ポート番号を書き換えているのでTCPあるいはUDPのチェックサムを再計算して書き換えます。

これでLAN側からWAN側へのNAPTは完了なのでこのパケットをWAN側インターフェースから送出します。

WAN側からLAN側への内向きパケットに対するNAPT

WAN側から応答が返ってくるときは、NAPTテーブルを参照して、先ほどこちらから送ったパケットの通信相手からの応答であることが確認できたら、先ほどと逆の変換を施してからLAN側に中継します。

NAPTエントリの破棄

10秒間通信がなければその通信に対応するNAPTエントリを削除するようにしました。

真面目な実装ならちゃんとTCP通信の状態を追ってクローズを確認すべきですね。

UDPではうまくいったものの、TCPではうまくコネクションが張れなかった

NAPTの実装を行っているとき、UDPではうまく外部との通信を中継できるのに、TCPではなぜかブラウザとサーバーとのコネクションがうまく張れませんでした。

Windowsの方でWiresharkを使い原因を調べると、WindowsがACKを返すと、Windowsに対してRSTが送られてしまっていました。

更に原因を調べて、まつもとりーさんのこの記事で発覚したのですが、

高速にリモートホストのポートがListenしているかを調べる - https://hb.matsumoto-r.jp/entry/2017/02/13/165646

カーネルのTCPスタックに邪魔される RAWソケットを介して、ユーザランドで自前で作ったパケットを送信し、その後リモートホストから返りのパケットを受信する場合、ユーザランドのTCPスタックだけでなくカーネルのTCPスタックも通過することになります。すると、カーネルのTCPスタックはこんな返りのパケット知らない!といって、不要なパケットとみなしrstフラグのたったパケットを返していしまいます。

とのことでルーターで処理したいパケットを、カーネルが先に処理してRSTパケットを送出してしまっていたのでした。

なのでiptablesを使って、

iptables -A OUTPUT -p tcp --tcp-flags RST RST -j DROP

とすることで、Raspberry Piから送られるRSTパケットをすべて遮断するようにしたところ、うまくTCPコネクションを張ることができNAPTが機能したので、先に進みました。

(今考えると、すべてのRSTパケットを遮断するのではなく内向きのRSTパケットのみ遮断すべきですね。当時はそのようにできることを知りませんでした。)

TCP・HTTP改ざん

いよいよ画像をすり替えるところですが、サーバーとクライアントの間に立ってそれぞれに対するACK番号の整合性を保つのが大変なので、いくつか致命的なズルをします。

  • クライアントからのパケットもサーバーからのパケットも、Window Scaleオプションを0に、ウィンドウサイズを1460に書き換え
  • サーバーからの最初のレスポンスに Content-Type: image が含まれていたら, そのパケットを破棄し, サーバーにRSTパケットを送って強制切断.
  • ブラウザへ送るニセの画像レスポンスのヘッダには Connection: close をつけておいて, 画像の受信が完了したクライアントが自分からTCPコネクションを切断するように仕向ける.

最初はもう少し凝ったことをしようと思っていましたが、作っているうちに、とにかく画像をすり替えられればなんでもいいやとなったので、「ブラウザにニセの画像が表示される」ために必要なことだけをしました。

外部ホスト80番(HTTP)ポートとの通信の監視

外部ホスト80番ポートにTCP-SYNパケットが送られるのを見て、クライアントがHTTPリクエストを送ろうとしているものと判断し、TCP接続管理テーブルを使ってTCPコネクションの監視を始めます。

Window Scaleを0に, Window Sizeを1460に

お互いにドバドバHTTP通信をしまくって、セグメント到着の順序が逆転したり、再送が発生したりしてACK番号が狂うのはNGなので、なるべく1パケットずつ確実に通信が行われるようにしたいです。

そのため、Window Scaleオプションを0, Window Sizeを1460にすることで、お互いのウィンドウサイズを1460バイト(1MSS)だと認識させるようにし、一度にたくさんのパケットが送信されないようにしました。

(実は一応SACKオプションも無効に設定されるようにしたのですが、別に意味はないと思います。)

HTTPサーバーからのレスポンスを見て画像か判定

監視しているTCPコネクションの、HTTPサーバーから送られてくる最初のセグメントに Content-Type: image が含まれていれば、画像と判断します。

もしレスポンスが画像でなければパケットには手を加えず普通に中継します。

バッファリングすべきでは?

画像かどうかの判定について、仮に最初のセグメントが数十バイトとかだったとすると Content-Type: image が含まれていなかったり、文字列の途中でセグメントが分割されていたりする可能性があるわけなので、本来ならば画像であると判断できるまでは受信データのバッファリングを行うべきですが、普通のHTTPサーバーの普通のレスポンスなら Content-Type ヘッダが1セグメント内に収まっていないことはまずないだろうと打算し、最初のセグメントしか見ていません。

はじめのうちは、バッファリングを行って、その最中は都度サーバーにACKを送り返そうなどと考えていたのですが、現実的にまず走らない処理を書いたところであまり意味がないし、時間的にも余裕もなかったので、バッファリングは見送りました。

画像だったらサーバーとの接続を断ち、クライアントにニセの画像を送る

HTTPレスポンスが画像レスポンスだった場合、サーバーにはRSTパケットを送信し強制的にサーバーとの接続を断ちます。(本当はRSTパケットはサーバーに届いていないけどタイムアウトで勝手にクローズしたのでしょう)

一方でクライアントには、予め用意しておいたニセの画像レスポンスパケットを送信します。この際、ニセのレスポンスのヘッダには Connection: close をつけておいて, 画像の受信が完了したクライアントが自分からTCPコネクションを切断するように仕向けます。

なぜサーバーとの接続を断つのか

今回の目的は画像を「書き換える」ことではなく「すり替える」ことが目的なので、サーバーからのレスポンス画像がどのようなものであろうと関係ありません。

一度画像だとわかればサーバーはもう用済みなので面倒の内容に強制的に接続を断つようにしました。

実はここが一つの妥協ポイントで、HTTP1.1ではデフォルトでKeep-Aliveが有効なので、画像のやりとりが終わると、今度はクライアントが、同じTCPコネクションを使って続きから次のリクエストを送信し始めます。

サーバーが送信した画像レスポンスのサイズとクライアントが受け取ったニセの画像レスポンスのサイズは違うので、ここでクライアント側のACK番号とサーバーのシーケンス番号に食い違いが生じます。

本当はこの食い違いをうまく欺きたかったのですが、時間とやる気の余裕がなく、なんとかこの面倒を回避できないかと考えた結果、一度ACK番号とシーケンス番号が食い違ったTCPコネクションを、サーバーとクライアントのお互いがもう使わないようにすれば良いと考え、サーバーにはRST、クライアントには Connection: close を送ることで、お互いにコネクションを破棄するようにしました。

Connection: close を受理したクライアントはサーバーに対しFINパケットを送ってきますが、面倒くさいのでこのFINパケットに対してルーターは何の対応もしません。FINパケットを受理されなかったクライアントはFIN受理待ち状態となりますが、既に画像の送信は済んでいて、何もしなくても勝手にクライアント側でタイムアウトしてコネクションがクローズするので、FINパケットが来ようともルーターはお構いなしという魂胆です(適当)。

感想

クライアント・サーバー間で食い違ったACK・シーケンス番号をうまくごまかすところに一番の技術的な難しさがあるはずですが、その困難を回避して横着することで目的を達成してしまいました。

iptablesでは、RSTパケットを完全に遮断するのではなく、内向きのRSTだけ遮断し外向きは許可することができることを後から知りましたが、まあ結果的に意図通りに動いたのでよしとしたいです。

いつか気が向いたらズルせず再送やウィンドウ管理などもして、もっと完全な形でTCPをハイジャックしたいとは思いますが、今の所苦労してそこまでやる気は無いというのが正直なところです…