こんにちは、hachi8833です。今回はPhusion BlogのActionCableシリーズからの翻訳記事をお送りいたします。
文中のWebSocketとActionCableについては以下をご覧ください。
- Wikipedia-ja: WebSocket
- Railsガイド: Action Cable の概要
概要
原著者の許諾を得て翻訳・公開いたします。
- 元記事: Finding a DoS vulnerability in Rails 5 WebSockets Apps
- 原著者: Tinco Andringa
- 元サイト: Phusion Blog
- 会社: Phusion -- PassengerやUnion Stationの開発・販売で知られています
Rails 5のWebSocket対応アプリでDoS脆弱性を見つけるまで(翻訳)
本記事では、Puma上で動作するRails 5.0.0のActionCableについて述べます。PumaはRailsアプリのデフォルトのサーバーですが、低速度のクライアントによるDoS(サービス拒否: denial of service)にさらされる可能性があります。筆者たちはOS Xのネットワークトラフィック絞り込みツールを用いて攻撃をシミュレートし、そこに潜む脆弱性を明らかにしました。
以前のActionCable記事では、筆者たちがActionCableでストレステストを実施していくつもの問題を発見し、解決するまでを解説しました。私たちが発見した問題点は、修正の後Railsにマージされました。今回は、ストレステスト実施で発見された別の問題点についての記事となります。
発見した問題点の1つは数か月前にRailsにマージされ、最近リリースされたRails 5.0.1に組み込まれています。なお、Passengerはこの問題の影響をこれまで受けていません。
「遅いクライアント」がDoSを引き起こすメカニズム
ここからしばらく、「遅いクライアント」の基本原理と、それによって生じる影響について解説します。遅いクライアント問題について既に十分ご存知でしたら、この「遅いクライアント」セクションをスキップして次の「ActionCableが遅いクライアントから保護されるかをテストする」まで進んでいただいてかまいません。次のセクションでは、PumaとPassengerでそれぞれ動くRailsアプリへの攻撃をシミュレートする方法と、その結果PumaだけがDoS攻撃に対して脆弱であることが判明するまでを解説します。
遅いクライアントの背後に潜む理論
Webアプリケーションにとっての「遅いクライアント」は、インターネット接続の帯域幅が狭いユーザーを指します。スマホから接続しているユーザーや遠隔地からアクセスするユーザーの場合、インターネットの接続性が低下することがあります。そして遅いクライアントの中には、意図的に帯域幅を制限することでアプリの妨害を目論む、悪意のある攻撃者が潜んでいる可能性もあります。
アプリをブロックする遅いクライアントには2つのタイプがありますが、サービスを信頼できるものにするにはその双方に対処する必要があります。遅いクライアントの対処だけで終わらせるのではなく、遅い速いにかかわらずアプリの全ユーザーを正しく扱わねばなりません。遅いクライアントはデータの送信に時間がかかり、データの受信にもやはり時間がかかります。
つまり、ネイティブに書かれたWebアプリでは、リクエストを処理するスレッドやプロセスがデータ受信を完了するまでに数秒、ひどいときには数分待たされることすらありえるわけです。その間、ビジネスロジックの実行やデータベースへのクエリも滞ってしまいます。その後も、アプリが作成したレスポンスをクライアント受信完了するまでにまたしても秒単位・分単位で待たされることになるでしょう。
遅いクライアントによる実際の影響
Ruby製アプリの多くは、同期的なリクエスト/レスポンスI/Oモデルをワーカープロセス(またはワーカースレッド)で採用しています。この方式は一部において「遅いクライアント問題」の影響を受けやすくなっています。
最悪のシナリオはおそらくこのようになるでしょう: 100個のワーカープロセスを使うよう設定されているアプリケーション・サーバーがあり、1秒あたり数千ものリクエストを処理する設計になっているとします。ここでたったひとりの攻撃者が、極めて狭い帯域から数百ものリクエストをサーバーに送信するとどうなるでしょう。100個のワーカースレッドの一つ一つが攻撃者からのリクエストを受信すると、遅延は数分にもおよび、他の(正当な)リクエストがキューにたまってしまいます。
たったひとりの攻撃者が送信したリクエストのために、最終的に全プロセスがビジー状態のまま分単位で滞ってしまいます。数千個におよぶリクエストがキューで待ちぼうけを食らったり、あふれて消えてしまったりします。攻撃者がさらに執拗にリクエストを繰り返せば、最小限のリソースでアプリをいつまでも遅延させられるでしょう。
スレッドはプロセスよりもずっと軽いので、多少なりともこの問題を緩和できます。スレッドを増やして対応することもできますが、根本的な問題は変わりません。攻撃者が遅いゴミリクエストの数をさらに増やす結果になるでしょう。
イベントベースのI/Oバッファシステムによる問題の緩和
実際のRubyアプリサーバーでは、遅いクライアント問題からの保護は既に十分なされています。これには、I/O並列性(concurrency)の高いイベントベースの(evented)I/Oバッファシステムが使われています。
イベントベースのI/Oバッファシステムは、Rubyアプリのさまざまなサーバーでどのように使われているのでしょうか。
- Unicorn: nginxと合わせてデプロイされるのが普通です。nginxはイベントベースのI/Oを採用し、リバースプロキシとして動作します。遅い送信、遅い受信のどちらからも保護します。
-
Puma: イベントベースのI/Oマルチプレクサを内蔵しており、これ自体が送信の遅いクライアントを防ぎます。受信の遅いクライアントに対して保護できないため、本番デプロイではPumaをnginxの背後に配置することをおすすめします。
-
Passenger: イベントベースのI/Oバッファシステムを内蔵しており、nginxの有無にかかわらず、遅いクライアントによる送信と受信の両方から保護できます。
従来の「遅いクライアント」対策がWebSocketと組み合わさった場合に発生する特殊な問題
バッファリングリバースプロキシとしてのnginxには一箇所弱点があります。クライアントからのリクエスト受信が完了するまで、そのリクエストをアプリに送信開始できないのです。同様に、アプリからのレスポンス受信が完了するまでは、そのレスポンスをクライアントに送信開始できません。通常これが問題になることはありませんが、WebSocketと組み合わさった場合に問題が生じます。WebSocketフレームは即座にクライアントに送受信されなければならないので、nginxはWebSocketに逆らって動作するわけにいきません。
すなわち、WebSocket(とWebSocketをベースにしているActionCable)が動作するためにはnginxのI/Oバッファシステムをオフにするしかなくなり、必然的にnginxによる「遅いクライアント」からの保護もオフになってしまいます。ActionCableは受信WebSocketデータ向けに独自にイベントベースのI/Oマルチプレクサを提供することで、この問題を何とかして和らげようとします。つまりActionCableによる保護は、遅いWebSocketクライアントからの送信を対象としていますが、遅いWebSocketクライアントからの受信は対象に含まれていないのです。
次のセクションではこの問題の実例を示すと同時に、Passengerがこの問題の影響をまったく受けないことも示します。PassengerはI/Oをバッファリングでき、かつデータを即座にクライアントに送信できるので、送受信ともに遅いWebSocketクライアントからアプリを保護できるのです。
ActionCableが遅いクライアントから保護されるかをテストする
理論についてはこのぐらいにして、実際の結果を見てみましょう。Railsアプリが遅いクライアント攻撃に対して脆弱かどうかを確認するため、ちょっとしたツールをこしらえました。このツールは、ActionCableを使うあらゆる接続済みクライアントに対してデータを連続送信します。
上の画像は、アプリを起動して4つのクライアントが接続している様子を示しています。各クライアントには自身の現在の遅延が表示されます。古いMacBookで動かしたときの平均遅延はおよそ10msecでした。この遅延にはJSONオブジェクトのレンダリングや解析に要する時間も含めている点にご注意ください。通常のアプリよりも遅延がずっと大きくなっていますが、これはテスト用に遅延をかなり大きくしてあるためです。
アプリが正常に動作していることを確認できたので、テスト条件に切り替えられる状態になりました。遅いクライアントを1つ導入して「遅いクライアント」状態を再現し、速いクライアント(=通常のクライアント)でのレスポンスタイムの変化を観察します。速いクライアントのレスポンスタイムに変化がなければ、問題は発生していないことになります。ほかの速いクライアントのレスポンスタイムが著しく変化すれば、「遅いクライアント問題」が発生していると断定できます。
OS Xのネットワークトラフィック絞り込みツール
今どき56kモデムなど見当たりませんし、ここオランダのインターネット接続は大変残念なことに実に高速です。そういうわけで、遅いクライアントのネットワーク条件をシミュレートするよりありませんでした。今どきのOSにはたいていその種のツールがありますが、OS Xの場合はpf
が使えます。
最初に以下を実行して、pf
が現在有効かどうかを表示します。
sudo pfctl -s
無効な場合は、以下を実行して有効にします。
sudo pfctl -e
続いて以下のような感じでダミーネットを1つ作ります。
(cat /etc/pf.conf && echo "dummynet-anchor \"mop\"" && echo "anchor \"mop\"") | sudo pfctl -f -
このコマンドは既存の/etc/pf.conf
をダミーネットで拡張し、この設定でpf
が動作するようにします。以下の2つのコマンドでいつでも元の設定にロールバックできます。
sudo dnctl flush
sudo pfctl -f /etc/pf.conf
次に、絞り込むネットワークを特定します。この例のクライアントは、ポート3000でWebサーバーに接続する4つのChromeインスタンスです。以下のコマンドで送信ポート番号を検索します。
sudo lsof -i -n -P | grep TCP | grep 3000
必要なポート番号をこのコマンドで確認できたら、その中からひとつを選び(ここでは58983)、以下のコマンドに貼り付けて実行します。
echo "dummynet in quick proto tcp from any to any port 58983 pipe 1" | sudo pfctl -a mop -f -
これで、このポートからの全データがダミーネットワークを経由します。いよいよトラフィックの絞り込みに取りかかりましょう。
sudo dnctl pipe 1 config bw 56kbit/s
トラフィック絞り込みは瞬時に有効になり、秒単位の遅延が発生します。
結果
まず、サーバーがデフォルトのままのRailsアプリからテストしてみました。以前のデフォルトサーバーはWEBrickでしたが、現在は本番により適したPuma Webサーバーに変わりました。以下でアプリを起動します。
./bin/rails s
遅いクライアントを導入した結果、そのクライアント自身のみならず、すべての通信相手(訳注: サーバーと全クライアント)で遅延が発生しました。つまりPumaは遅いクライアント問題に対して脆弱であり、リバースプロキシで保護しなければWebSocketをインターネット上に公開するにはふさわしくないということです。残念なことに、上述の「従来の遅いクライアント対策がWebSocketと組み合わさった場合に発生する特殊な問題」があるために、もっともおすすめのリバースプロキシであるnginxでは保護できません。
Passengerを使うRailアプリを起動して遅いクライアントを導入した場合、接続する全クライアントでの遅延は発生しませんでした。Passengerは、リバースプロキシによるバッファがなくても「遅いクライアント問題」に関してインターネット上で安全を保てます。
Passengerが遅いクライアントを扱う仕組み
Passengerは遅いクライアントに対抗するために、アプリとネットワークの間にバッファを配置します。このバッファはアプリからのデータを即座に受信し、クライアントの受信が遅いときにはメモリやディスク上にデータを保存します。これにより、アプリは超高速なクライアントと接続しているかのように動作できます。
PassegerはイベントベースのI/Oアーキテクチャを内蔵しており、スレッドやプロセスがバッファ内のデータストリームの処理で手一杯にならないようになっています。つまり、接続ごとのオーバーヘットがほとんどなく、遅いクライアントを問題なく扱えます。さらに、Passegerはバッファ中にもデータをただちにクライアントに送信するので、クライアント側に遅延を感じさせません。
まとめ
今回の実験では、簡単なデモアプリを作り、ネットワークトラフィックの絞り込みを使って遅いクライアントによる影響とをシミュレートし、ActionCableサーバーの信頼性をチェックしました。実験の結果、Puma上で動作するActionCableアプリには、遅いクライアントがコストの高いワーカープロセスやスレッドを立ち上げることによるDoSのリスクがありました。
同じアプリをPassenger上で動かした場合は、Passengerが外部へのデータをバッファするため、遅いクライアントの影響を受けませんでした。Pumaのフロントにnginxをリバースプロキシとして配置してもこの問題を解決できません。nginxのバッファシステムはWebSocketと共存できないためです。
この問題はPumaチームとRailsチームに報告され、その結果レスポンスバッファをRailsに実装していただきました。このパッチは、最近リリースされたRails 5.0.1にマージされました。
Passengerは、Rubyアプリ、Pythonアプリ、Node.jsアプリ、Meteorアプリ、マイクロサービス、APIを高い信頼性、高いパフォーマンス、高い制御性でサービス提供できるようにします。Enterprise Editionをお求めいただくことで、さらに多くの機能とプレミアムサポートをご利用いただけますので、ぜひご覧ください。
Phusion社の新製品であるUnion Stationは、Passenger上で動作するアプリを監視、分析します。Union Stationはアプリのパフォーマンスボトルネックやエラーの検出と修正を支援します。今すぐ登録して無料試用いただけます。