Replay attack(リプレイアタック:反射攻撃)とは、攻撃者がほかのユーザーのネットワークパケットを傍受してそれを再利用するタイプの攻撃であり、極めて危険で、重大な損害をもたらす場合があります。この種の攻撃がさらに厄介なのは、暗号化された通信チャネル上で、暗号化キーを破らずに実行できてしまうからです。攻撃者は、回線上で盗聴するだけで、パケットの特定のセットがどのようなタスクを実行中か大まかに把握し、パケットやリクエストを再利用して、通信を妨害するばかりか、さらにひどい損害を与えるのです。
この記事では、Webサイトを反射攻撃から守るための基本的で簡単な方法を紹介します。この方法の付随的なメリットは、ユーザーがよく分からないままに不必要にブラウザーを頻繁に更新することで、直近のPOSTリクエストが再送されてしまうというまずい影響も回避できることです。
この方法はまだソリューションとして完成した訳ではなく、欠点や未解決の課題を抱えていますが、トークンと簡単なプロトコルでWebサイトのセキュリティをいかに強化できるかについての全体像を示しています。サンプルコードと実装にASP.NETとC#を使用していますが、このコンセプトはほかの任意のプラットホームやプログラミング言語でも展開可能です。
ワンタイムトークンのコンセプト
記事で提供するソリューションの背後にあるアイデアは、1つ1つのHTTPレスポンスを次のPOSTリクエストでのみ有効なトークン列に結びつけることです。以下は関係するステップの簡単な説明です。
- URLやページの入力、リンクのクリックなどによって、クライアントがGETリクエストを送信する
- サーバーはランダムトークンを生成する。次いで、サーバーはトークンのコピーをセッションに格納し、クライアントに送信するレスポンスの<form>タグにトークンのコピーを埋め込む
- ユーザーがボタンをクリックしたタイミングで、クライアントはコンテンツを処理し、ランダムに生成されたトークンを含むPOSTリクエストをサーバーに送信する
- サーバーはリクエストを受信し、添付されたトークンがユーザーのセッションに格納されたものと同じである場合のみリクエストの処理を進める
- サーバーはトークンを無効にしてステップ2に戻り、新しいランダムトークンでレスポンスを組み立てる
この方法によって、リクエストがサーバーに送信されたあとは、リクエストに含まれるトークンはもはや有効でなくなるため、サーバーに送信された重要なリクエストが悪意のあるユーザーに傍受されたとしても、再利用される恐れはなくなります。同じように、ユーザーがサーバーに情報をPOST送信したあとでうっかりF5キーを押し、リクエストを再送してしまったとしても大丈夫です。
テストベッド
ワンタイムトークンを実装するために、シンプルなテキストボックスと送信ボタンのあるサンプルページを作成します。テスト出力の表示用に、ラベルコントロールも挿入します。
次のコードは、送信時刻とテキストボックスに入っているデータを表示する簡単なスニペットです。
こちらは、最初のGETリクエスト後のページの出力結果です。
ページの送信後、出力結果は次のようになります。
問題は、ページを更新すると、データが再度POST送信されて直近のリクエストが繰り返され、サーバーがあっさりとそれを処理してしまうことです。100万ドルの大切な取り引きを実行した直後に、うっかりF5キーを押してしまったら、と想像してみてください。もしかしたら最悪なことに、悪意のあるユーザーがリクエストを傍受していて「これは出金取り引きだ」とかぎつけ、リクエストを再利用して資金を巻き上げ、損害を与えるかもしれません。
ソリューション
POSTリクエストの繰り返しを防ぐため、コードに非表示フィールドを追加して、そこにトークンを格納します。
次に、ランダムトークンを生成する関数を作成し、非表示フィールドとセッションのコレクションの双方に組み込みます。
その後、POST送信されたトークンがセッションに格納されたものと同じである場合のみPOST送信されたデータを表示するようにPage_Load()関数を変更します。
最後に、クライアントに最終出力を送信する前に新しいトークンを生成するようにOnPreRender()関数をオーバーライドします。これでワンタイムトークンが生成できます。トークンは新しいリクエストが送信されるたびに更新されるようになります。
ここでボタンをクリックしてフォームを送信すると、先ほどと同じように動作します。しかし、ページを更新して反射攻撃をシミュレーションしてみると、フォームとともに送信されたトークンがもはやサーバーに格納されているものと同じではないため、次のようなエラーが出ます。
こうしておけば、ボタンのクリックによる有効な送信と、不正に繰り返されたリクエストを見分けられます。
コードの改良
このコードでページの反射攻撃問題を解決できるとはいえ、次のような対処すべきいくつかの課題があります。
- コードがすべてのページで繰り返し実行されることが必要
- 1つのWebサイトでいくつかのタブが開く場合、トークンがリクエスト間で共有されるため、このコードでは通用しない
- なんとも見苦しい
オブジェクト指向プログラミング(OOP)の熱烈なファンとしては、このコードをリファクタリングし、改良する機会をいつも探しています。
先に挙げた課題に対処するために、まずトークン生成関数をカプセル化するクラスを定義します。クラス名をTokenizedPageとし、あとで複数のページに使用できるようにSystem.Web.UI.Pageから派生させます。
次に、コードをもっと読みやすく、管理しやすくするため、ページトークンとセッショントークンを2つの別個のプロパティにカプセル化してTokenizedPageクラスに追加します。Webページ内でコードを簡単に移植できるように、ページトークンの格納に、非表示フィールドではなくViewStateコレクションを使います。 セッションにトークンを格納するためのキーとしてPage.Titleプロパティも使います。
以上によりコードが改良され、ブラウザーでサイトの単一のタブにしかコードを使えないという2つ目の課題が一部解決されます。この変更を適用すれば、それぞれのタブでサイトの別個のページを開いても大丈夫になりますが、別個のタブで同一のページのインスタンスを開くとなると、依然としてトークンが共有されるので対応できません。この課題にはのちほど取り組みます。
次に、たとえばIsPostBackやIsValidなどのPageプロパティのように、読み取り専用のBooleanプロパティIsTokenValidを追加します。このプロパティは、ページトークンがセッショントークンと同一であることを確認するために設定します。
最後に、GenerateRandomToken()関数と、テストベッドにおけると同様OnPreRender()イベントのオーバーライドを追加します。
ワンタイムトークンのパターンを使用するためにTokenizedPageから派生する新しいページを作成し、ワンタイムトークンが必要な場合はいつでもIsTokenValidを使えばもう大丈夫です。
とても良くなりました。
さらなる改良
このコードの問題点の1つは、ブラウザー上で同一のページを示す2つのタブがある場合、これらのタブが同一のセッショントークンキーを使っているために、一方のタブでPOST送信が実行されると、もう一方のトークンが無効になってしまうことです。
これには、トークンIDを追加して、1つのタブ内で発生する各リクエスト・レスポンスシーケンスに、確実に固有のトークンのセットを使用させ、同一ページ上のほかのリクエストに干渉させないことで対処できます。まず、TokenizedPageクラスに戻ってTokenIDプロパティを追加する必要があります。このプロパティは、初回のGETリクエストで呼び出されたときにランダムIDを生成し、あとで再利用するためにこれをViewStateコレクションに格納します。
次に、Page.Titleプロパティの代わりにTokenIdプロパティを使用するようにSessionHiddenTokenプロパティを変更します。
すばらしいことに、抽象化とカプセル化の原則を使用してきたので(再びOOPのメリットについて声を大にして言いますが)、ほかになにも変更しなくても、TokenizedPageから派生するすべてのページでこのメカニズムが働くことになります。
残された課題
ワンタイムトークンパターンに関して、次の2つの課題が残されています。
- 精度を上げようとすると、各セッション用にトークンIDが(各セッションに送信されるGETリクエストの数に従って)無制限に生成される。これには、IDが制限数を超えた場合や、古いIDが特定の期間にわたって使用されなくなった場合に古いIDをポップするスタックやキャッシュ機構を実装して対処できる。実装は読者に任せる
- デフォルトの乱数生成器は、乱数ソースとしてもっとも安全で信頼できるとは言えず、すご腕のハッカーならトークンのシーケンスを予測できる場合もある。しかしSSL暗号化を使えば、ハッカーは手を尽くしてもトークンをつかめない
※本記事はBenの「Tech Talks」サイトに掲載されたものです。
(原文:How to Prevent Replay Attacks on Your Website)
[翻訳:新岡祐佳子/編集:Livit]