PHPで簡易なCSRF対策

日頃、Railsなどのフレームワークに守られてヌクヌク生きててきたので、セキュリティに対する意識が低いなーと思います。
現在、PHPのオレオレフレームワークでコードを書いているので、CSRF対策について調べてみました。


参考:

開発者のための正しいCSRF対策
http://www.jumperz.net/texts/csrf.htm

CSRF対策に「ワンタイムトークン」方式を推奨しない理由
http://takagi-hiromitsu.jp/diary/20060409.html#p01

毎度毎度、よそのサイト様の焼き増しですみませんw


まず、CSRF(Cross Site Request Forgery)とはどういうのもか、大雑把に説明します。
http://example.com/order?pid=12345
というURLを開くと、pid(商品番号) が「12345」の商品を注文できるとします。
Amazon的な何かで1クリックで注文できる場合、このサイトにログインしているユーザが、このリンクをクリックするだけで「商品番号12345の商品の注文」が確定してしまいます。
他にも、スパムな日記を書き込まれたり、パスワードの変更を実行させられたり、退会処理を実行されたりなどできるかもしれません。
この方法では、1リクエストで可能なことであれば、何でもできてしまいます。
とても危険です。(短絡的
実際にいろいろと事件も起きてるようですし。


「1リクエストで可能なことであれば」というのは、攻撃対象となるページが、複数ページあり、入力結果をセッションに保存していき、最後にまとめてセッションから値を読み取るような実装のサイトでは、値を直接保存させるアクションがないので実現できません。(と理解しています)
# そういう実装のサイトは、別のことで苦しんでそうですが…

追記: ・・・と書きましたが、参考サイトの「不適切な対策」に挙げられてました。よく読めよ自分orz
複数のページそれぞれに対して順番にアクセスさせれば、当然セッションに情報が追加されていくので攻撃可能でした。


ちなみに、上の例では、URLをGETなリクエストパラメータで書いてますが、実際の登録・更新処理はフォームを使ったPOSTメソッドでのリクエストがほとんどだと思います。
そういう場合には、javascript をつかってフォームをその場で生成してリクエストを送信させるなどのXSS的な手段が使われます。たぶん。
単純に偽サイトの偽フォームでもできますが、それはフィッシングに近い気がします。

対策方法

ここでは、セッションに本人確認用のトークンを保持し、POSTで送られてくるトークンと比較する方法で対策を行います。

基本的には、

  • あらかじめセッションに、本人確認用のトークンを保存し、
  • フォームにそのトークンを一緒にPOSTされるように記述し、
  • POSTで受け取ったトークンとセッションのトークンを比較する

という手順です。


POST毎に異なるトークンを生成する方法をワンタイムトークンとよぶようです。
ワンタイムトークンの場合、ブラウザを複数開いちゃう人がいると、タイミングによってはトークンが変わってしまい、不正アクセス扱いになるかもしれません。
それがまずい場合は別の方法を考える必要があります。


なので、同一ユーザの並列操作は許容可能で実装が単純な1セッション1トークンを採用しました。
対策しないよりはマシ、というレベルかもしれませんが。


処理の大雑把な特徴

  • チェックするのはPOSTのみ
  • トークンの生成も、POST時の検査も前処理でやってしまう
  • 検査処理は、セッションのトークンと、hiddenフィールドで送られてくるトークンの値を比較するだけ


自作フレームワーク(もどき)をつかってる(むしろ作りながら使ってる)ですので、前処理フィルタをかけたりできません。
なので、処理開始の冒頭でいきなりチェックしています。(ここにするのは面倒くさいという理由で)
間違えてPOSTしたら、関係ない場所でも不正アクセス扱いになりますが、まぁいいかと。


実際のコードとは全然ちがいますが、抜きだして書いてみます。
PHPの哲学は理解してないので、コードが汚いのはご愛嬌。
# 下のはサンプルです。実際はスパゲッティで書いてません


検査する側:

<?php
define("AUTH_TOKEN_NAME", "auth_token");
session_start();

// トークン作成
if (! isset($_SESSION[AUTH_TOKEN_NAME])) {
  $_SESSION[AUTH_TOKEN_NAME] = sha1(mt_rand() . "@" . session_id());
}

// リクエストメソッドがPOSTの場合、トークンを検査する
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
  if (! isset($_POST[AUTH_TOKEN_NAME]) || $_POST[AUTH_TOKEN_NAME] != $_SESSION[AUTH_TOKEN_NAME]) {
     die("不正アクセス");
  }
}
?>


POSTするフォーム側:

<html><head></head><body>
<form action="xxx" method="post">
<input type="hidden" name="<?php echo AUTH_TOKEN_NAME; ?>" value="<?php echo $_SESSION[AUTH_TOKEN_NAME]; ?>" />
<input type="submit" />
</form>
</body></html>


検査側ですが、トークンの生成規則はすごい適当です。
session_id() 単体でsha1() に通してもいい気がしますが、念のため、乱数をくっつけてます。
余計な掛け合わせをやると、逆に乱数が偏った気がしますが気にしない方向で。
むしろ、session_id() つかうなとエライ人に怒られそうです。
まぁ、SHA1を逆算する時間があれば、セッションは切り替わってると思います。
あと、参考サイトにはCookieも使うような感じでかかれていましたが、Cookieの扱いがよくわからない(←大問題)ので、つかってません。


HTML側では、formの中に hidden フィールドとしてトークンを入れておくだけです。
セッションは、ヘマしてなければ書き換えられることはないはずなので、エスケープしてませんが、心配ならhtmlspecialchars()などをかけておきます。

telnetからのPOSTは、不正アクセス扱いにできました。
これで、とりあえずの対策ができました。