2018.12.23

脆弱なサイトと罠サイトを実際に作って学ぶ『CSRF』とその対策


この記事では、Web セキュリティの基本である CSRF をハンズオン形式で解説します。

CSRF の解説は検索すればたくさん出てきますが、私が新人エンジニアだったころは言葉の説明だけ読んでもどうしてもよく分からなかったです。というわけで、実際に攻撃される側の脆弱なサイトと罠サイトを作成して、CSRF 攻撃を実現しながら説明していきたいと思います。

CSRF とは

CSRF=クロスサイトリクエストフォージェリ(Cross Site Request Forgeries)は Web アプリケーションへのサイバー攻撃の一種です。日本語にすると「サイトをまたいだリクエストの偽造」でしょうか。「罠サイト」から「標的サイト」へ HTTP リクエストを送信することで「標的サイト」を操作してしまおうという攻撃手法です。

サンプルサイト

攻撃される側の標的サイト

攻撃される側のサイトは Laravel で構築します。認証機能をコマンド一発で作れるので。

アプリケーションコードはこちらのリポジトリに載せています。
手元で動かしたい場合は(せっかくなのでぜひそうしてほしいですが)リポジトリをクローンするか、以下でポイントのみ説明するのでそちらを参考に作っていってください。

今回はコメントを投稿するアプリケーションを作成します。

👇 コメント投稿画面

攻撃される側のサイト コメント投稿画面

👇 コメント一覧画面

攻撃される側のサイト コメント一覧画面

ログイン済みのユーザーでなければコメントの表示や投稿はできません。
コメントの下には投稿したユーザー名が表示されます。

まずは artisan コマンドで認証機能から作成します。

$ php artisan make:auth

以下のコントローラーについて、リダイレクト先を編集します。

👉 LoginController.php(repo
👉 RegisterController.php(repo
👉 ResetPasswordController.php(repo

protected $redirectTo = '/';

あとはリポジトリを参考に以下のファイルを追加・編集していってください。

📜 マイグレーションファイル(repo
🤖 Comment モデル(repo
🧭 ルーティング(repo
📐 テンプレート(repo
🎮 コントローラー(CommentController, HomeController
🛡 ミドルウェア(VerifyCsrfToken.php

ポイントは、認証済みのログインユーザーだけがコメントの操作を行える点です。

CommentController.php
public function __construct()
{
    // このコントローラーのメソッドはすべて認証が必要
    $this->middleware('auth');
}

また、Laravel はデフォルトで CSRF 対策が施されています。今回はまず CSRF 攻撃を実証したいので、VerifyCsrfToken ミドルウェアでCSRF 対策を外す URL 指定を加えます。

VerifyCsrfToken.php
protected $except = [
    '/comments/create',
];

罠サイト

次に先ほど作成したコメント投稿アプリに攻撃を仕掛けるための罠サイトを作成します。

ソースコードはこちらのリポジトリに載せています。
静的なファイルだけで構成されているので、リポジトリをクローンするかファイルを作成したあとに、こちらの記事などを参考にサイトを立ち上げてください。

罠サイトは以下の2ページからなっています。

まずは怪しげな?リンクが表示されているトップページ。

罠サイト リンクページ

リンクを踏むと、猫の GIF が表示されます。

罠サイト 写真ページ

トップページは2種類あります。HTML フォームを使用しているのが index.html で、Ajax 通信を利用しているのが ajax.html です。

まず index.html を見てみましょう。

index.html
<h1>😈 怪しくないサイト 😈</h1>
<a href="/photo.html" id="link" target="_blank">超面白い画像はこちら!→</a>

<form action="#" method="post" name="trap" style="display: none;">
  <input type="hidden" name="body" value="罠サイトからこんにちは" />
</form>

<script>
  const targetURL = 'http://csrf-tutorial.test/comments/create';
  const link = document.getElementById('link');
  link.addEventListener('click', function(e) {
    document.forms.trap.action = targetURL;
    document.forms.trap.submit();
  });
</script>

index.html ではリンクをクリックすると見えない(display: none;)フォームが送信されます。送信先は、先ほど作った Laravel アプリの、コメント投稿 URL です。targetURL のドメイン部分は動作環境によって異なるので、アプリが動いているドメインに変えてください。

次に ajax.html です。

ajax.html
<script>
  const targetURL = 'http://csrf-tutorial.test/comments/create';
  const link = document.getElementById('link');
  link.addEventListener('click', function(e) {
    e.preventDefault();

    const formData = new FormData();
    formData.append('body', '罠サイトからこんにちは');

    const fetchOption = {
      method: 'POST',
      mode: 'no-cors',
      credentials: 'include',
      body: formData
    };

    fetch(targetURL, fetchOption).then(() => {
      location.href = '/photo.html';
    });
  });
</script>

こちらもやっていることは index.html と同じですが、HTML フォームの代わりに Ajax(Fetch API)を利用しています。リンクをクリックするとコメント投稿機能に Ajax 通信して、通信完了とともに猫ページに遷移します。

CSRF 攻撃

罠サイトからの攻撃

では実際に標的サイトと罠サイトを開いて CSRF 攻撃を実践してみましょう。標的サイトの方はログインしておいてください。CSRF 攻撃においてはこれが重要なポイントとなります。

罠サイトのトップページ(index.html)で画像へのリンクを踏んでみましょう。猫画像が別タブで開き、元のタブでは標的サイトのコメント一覧が開かれているはずです。

コメント一覧を見てみると...

攻撃されたコメントアプリ

ログインしたユーザーの名前で罠サイトからのコメントが投稿されています 😱

さらに今度は ajax.html から攻撃を仕掛けてみましょう。
index.html と同様にリンクをクリックしてからコメント一覧を確認すると、また罠サイトからのコメントが追加されているはずです。Ajax を使っているため別タブなど開かず、自然に猫ページに遷移するので、より巧妙な罠と言えるでしょう。

何が起きたのか

以上が CSRF 攻撃です。いかがでしょうか、難しい名前の割に実はかなりシンプルな攻撃であることが理解いただけると狙い通りなのですが。

「サイトをまたいだリクエストの偽造」という名前通り、「罠サイト」から「標的サイト」に対して、つまり今回の例ではコメント投稿機能の URL に対して、サイトをまたいで HTTP リクエストを送信しました。

区別されないリクエスト

CSRF 攻撃を成立させる条件のひとつは、HTTP リクエストの送信元の正当性がチェックされないことです。

HTTP の仕様から言えば Web サーバーに到達したすべてのリクエストは正しいリクエストなので、「偽造」という言葉はあくまで論理的な、標的サイトを運営する人間から見て好ましくない、リクエストが送られるべき場所から送られていないという意味です。

標的サイトには「送られるべき場所から送られたリクエストか」をチェックする機構が備わっていないので、罠サイトからのリクエストも自サイトからのリクエストと区別することなく処理します。その結果、コメントの投稿処理が実行されました。

認証とクッキー

もうひとつのポイントは、認証で守っていたはずの機能がいとも簡単に実行されてしまった点です。これはクッキーの性質に関連しています。

Web アプリケーションの認証をセッションによって実現している場合、認証済みのクッキーさえ標的サイトに送ることができれば認証は突破できます。そして CSRF が(悪)賢いのは、クッキーを盗む必要などなく、そもそもの自然なクッキーの仕様を利用している点です。

クッキーには domain という属性があります。ブラウザは HTTP リクエストに自動的にクッキーを含めて送信しますが、このときそれぞれのクッキーは domain 属性のドメインへのリクエストにしか含まれません。ざっくり一般的なケースで言うと、あるサイトから送信されたクッキーは、そのサイトへのリクエストにのみ含まれるということです。

CSRF 攻撃はこの仕組みを逆手にとっています。罠サイトからであろうと標的サイトへのリクエストには違いないので、このときのリクエストには標的サイトが発行したクッキーが付属します。その中にはもちろん認証クッキーも含まれるので、標的サイトは素直にセッションと突き合わせして認証済みのユーザーからのリクエストとして扱ってしまうわけです。

CSRF 攻撃の怖さ

攻撃に気づきにくい

サンプルサイトで示したように、攻撃に必要なのは標的サイトにログインしている誰かにリンクを踏ませることだけです。もっと言えば JavaScript を使うのであればリンクを踏ませる必要すらありません。スクリプトを実行するために罠サイトの画面を表示させさえすればよいでしょう。そのため罠サイトが真っ当な見た目であれば異変に気づけない可能性が高くなります。

ログインユーザーの名義で操作される

こちらもサンプルサイトで示しましたが、標的サイトへのリクエストは正しくログインユーザーからのアクセスとして処理されてしまいます。コメント一覧画面で、罠サイトからのコメントには投稿者としてログインユーザー名が記載されていたでしょう。そのため罠サイトによって悪質な操作が行われたとしてもログインユーザーが実行したという事実になってしまいます。過去には罠に引っかかったユーザーが誤認逮捕された事件もあったようです。

CSRF 対策

上の説明をまとめると、CSRF 攻撃は HTTP の次の特徴を利用して成立します。

  • HTTP リクエストはどこからでも送ることができる
  • リクエストとともにリクエスト先のドメインで有効なクッキーも送信する

これらの挙動は HTTP の仕様なので、変更できません。
ではどうするかというと、リクエストの送信元の正当性をチェックします。

どこからリクエストが送られてこようと、まず正当な送信元からやってきたかチェックして、正当性が認められなければ処理をせずに弾いてしまうということです。

一般的には CSRF トークンを用いてチェック機構を実現します。
まずは Laravel での CSRF 対策を見てみましょう。

Laravel での対策

まず、ミドルウェア VerifyCsrfToken$except から URL を削除します。

VerifyCsrfToken.php
protected $except = [
];

次にテンプレート comments/create.blade.php を編集します。

create.blade.php
<form action="{{ route('comments.create') }}" method="post">
  @csrf

フォームの中に @csrf を追加します。

Laravel における CSRF 対策はこれだけです 😎
前に少しだけ述べましたが、Laravel には CSRF 対策の仕組みがデフォルトで備わっているので難しい設定や実装は必要ありません。

対策内容の説明はあとにして、まずは対策が施された状態でもう一度罠サイトから攻撃を試みてください。index.html のリンクを踏むと、対策前のようにコメント一覧には遷移せず「419」と書かれたエラーページが表示されているはずです。レスポンスコード「419」は標準的なコードには含まれていない、Laravel 独自の番号でセッション切れや CSRF トークンが含まれない不正なアクセスを意味します。ajax.html でリンクをクリックした場合も、猫ページには遷移しますがコメントは追加できていないでしょう。

CSRF トークンの仕組み

トークンを利用した CSRF 対策を理解するために、まずはコメント投稿画面のテンプレートに記述した @csrf の意味を調べましょう。ブラウザの開発者コンソールで @csrf を記述した箇所の HTML を確認します。

CSRF トークン

ご覧の通り、@csrf はランダムな文字列が値として割り振られた input 要素に書き換わっています。このランダムな文字列が CSRF トークンです。コメントが投稿される際に入力値と一緒に送信されるこのトークンによって、リクエストの正当性をチェックするのです。

このトークンの値はまずセッションに暗号化されてから格納され、それからテンプレートにも埋め込まれます。リクエストが送信されたときに、(1) トークンを含んでいて、(2) そのトークンの値がセッションに格納した値と一致する場合にのみ、正当な送信元からのリクエストとして受理することで、CSRF 対策を実現します。トークンは標的サイト側が発行し、標的サイトのページでしか得ることができないのがポイントです。

このような対策が施されると、先ほどのような罠サイトで CSRF 攻撃を仕掛けようとしても、まずトークンが含まれないリクエストは弾かれてしまいます。かつデタラメなトークンを送ったとしてもセッションの情報と一致しないので弾かれます。トークンにはランダムな文字列が用いられるので推測は困難というかほぼ不可能です。Laravel の CSRF トークンを例にとると上で見られる通りアルファベット大文字+小文字+数値の62文字種が40文字なので、62の40乗=4千9百億かける10の60乗通りもパターンがあることになります。

説明が少し長くなってしまいましたが、トークンを使った CSRF 対策も意外とシンプルです。ランダムな文字列をセッションとフォームに埋め込んで、リクエストを受け取ったときに一致しているかをチェックしているだけです。

今回は Laravel を例として紹介しましたが、トークンを使った CSRF 対策は一般的に知られているため、たいていのモダンな Web アプリケーションには仕組みが用意されているはずです。開発時には必ずフレームワークの仕様を確認して対策漏れのないようにしましょう。


以上です。
この記事では標的サイトと罠サイトを実際に作成して CSRF の攻撃と対策を紹介しました。