http://t.co/jSZA9daLQG にもありますように大垣靖男さんの著書『Webアプリケーションセキュリティ対策入門』の付録が更新されています。 更新前を第一版、更新後を第二版と、ここでは呼ぶことに致します。
— ほしくずっ (@s_hskz) 2015, 6月 28
第二版の付録サンプルコードには…被害者のログイン済みユーザに対して、攻撃者が罠のページに誘導しつつ受動的攻撃を仕掛ける形態の… CSRF脆弱性があるように見受けられます。
— ほしくずっ (@s_hskz) 2015, 6月 28
さて。私の理解するところでは、第二版において次のようになっています。すなわち。 《CSRF対策のトークンをDBに保存していて、それが全ユーザー共通です》
— ほしくずっ (@s_hskz) 2015, 6月 28
通称「大垣本」ことWebアプリセキュリティ対策入門 ~あなたのサイトは大丈夫?にクロスサイト・リクエストフォージェリ(CSRF)脆弱性があるというのです。
ここで「第二版」という言葉が分かりにくいと思いますが、書籍の第二版ではなく、同書のサンプルプログラムが実は「間違い探し用のスクリプト」が誤って掲載されていたため、間違い探し用でないものに差し替えられた経緯があり、差し替え後のものを「第二版」と呼んでいます。経緯と差し替え後のスクリプトはこちらから参照できます。
これに対して大垣さんは以下のように返しておられます。
@s_hskz あのサンプルには操作系の機能は記事投稿しかなくて、それは対策してありますが?もしかしてログインフォームが、という指摘ですか?
— Yasuo Ohgaki (大垣靖男) (@yohgaki) 2015, 6月 29
戻るボタンを押すと新しいフォームIDが生成されて、もう一回送信できるから勘違いしたようだ。フォームのソース見れば判ると思うのだが。
— Yasuo Ohgaki (大垣靖男) (@yohgaki) 2015, 6月 29
どうも話がかみあっていないようです。そこで、本当に大垣本にCSRF脆弱性があるか確認してみましたので以下に報告します。
なお、私は「今後大垣さんに対する批判はしない」と宣言している身ですので、この記事は批判云々ではなく、ソフトウェアにつきもののバグ報告に専念し、方法論等に対する批判は慎みたいと考えます。
大垣本のCSRF防御の考え方
大垣本のCSRF防御の考え方を説明するために、同書のP151の「フォームIDの動作」という図を引用します。図のように、大垣本のCSRF対策方式(以下、「大垣方式」と表記)では、トークン(同書ではフォームIDと表記)をランダムな鍵として生成(②)し、それをフォームの隠しフィールドとDBに保存します(③、④)。ユーザーがフォームをサブミット(⑤)すると、送信されてきたトークンがDB上に存在するか確認(⑥)し、あればトークンを削除(⑦)して、サーバー上の処理に進みます。⑥でトークンがDBにない場合は、エラーとして処理には進みません。
一般的なCSRF対策手法との違い
大垣方式が一般的なCSRF対策と異なる点は以下の2点です。- フォームの2重投稿防止機能を兼ねている
- トークンがセッション変数ではなくDBに保存される
トークンの有効範囲は?
トークンがDBに保存される場合、トークンの有効範囲が気になるところです。大垣本および第二版のソースを見ると、トークンを保存するテーブルの定義は以下の通りです。sha1がトークン、createdが生成日時を保持します。CREATE TABLE form_id (sha1 TEXT PRIMARY KEY, created TEXT NOT NULL)
シンプルな構造ですが、これだとトークンは、ユーザーやセッションを超えて、アプリケーション全体で共通になっています。これはまずそうですね。
トークンをホテルの部屋の鍵に例えると、こうです。大垣方式の鍵は、ホテルの全ての部屋に共通で使える鍵です。本来は、鍵は特定の1部屋のみに使えるべきですが、そうなっていないのです。
攻撃方法
どうもまずそうだということで、大垣方式への攻撃方法を検討します。まず攻撃者イブは、自分のIDとパスワードを用いてログインして、投稿フォームに遷移します。
この段階では自ら投稿せず投稿フォームのHTMLソースを見ると、以下の様にトークン(form_id)があります。
<input type="hidden" name="form_id" value="e25b108116bb9f6d698537937a87d1fc8ccae8a5" />
この段階で、DB内部ではform_idは以下のようになっています(攻撃者には見えません)。
sqlite> SELECT * FROM form_id;
e25b108116bb9f6d698537937a87d1fc8ccae8a5|1435588303
攻撃者はHTMLソースから得たform_idを用いて、以下の様な罠のHTMLを作成します。
<body onload="document.forms[0].submit();">
<form action="http://samplebbs.jp/thread_create.php" method="POST">
<input name="title" value="最初の一撃"><br>
<input name="message" value="〇〇小学校を襲撃しますよ"><br>
<input name="form_id" value="e25b108116bb9f6d698537937a87d1fc8ccae8a5"><br>
<input name="submit_btn" value="送信"><br>
</form>
</body>
うっかり、掲示板にログイン中の利用者アダムがこの罠を閲覧してしまうと、以下のメソッド(database.class.php)によりトークンチェックが実行されます。実行されるSQL文は以下の通りです。public function GetFormID($sha1) { $sql = "SELECT * FROM form_id WHERE sha1 = ". $this->quote($sha1) .";"; $stmt = $this->Query($sql); if (!is_object($stmt)) { trigger_error('データベースエラーが発生しました'); } $rec = $stmt->fetchAll(PDO::FETCH_ASSOC); assert(count($rec) === 1 || count($rec) === 0); if (!$rec) { return 'no record'; } else { return $rec[0]['sha1']; } }
SELECT * FROM form_id WHERE sha1 = 'e25b108116bb9f6d698537937a87d1fc8ccae8a5';
この行は存在するため、GetFormIDはトークン(e25...)を返します。正常です。つづいて、同じクラスのRemoveFormIDメソッドによりトークンを削除します。
実行されるSQL文は下記のとおりです。public function RemoveFormID($sha1) { $sql = "DELETE FROM form_id WHERE sha1 = ". $this->quote($sha1) .";"; if (!$this->Exec($sql)) { // GCがあれば無い場合もある return false; } return true; }
DELETE FROM form_id WHERE sha1 = 'e25b108116bb9f6d698537937a87d1fc8ccae8a5';
これも正常に実行されます。CSRFのトークンチェックを通ったので、以下のSQL文により罠からのリクエストが、アダム(被害者)の投稿として実行されます。実行結果は下記のとおりです。INSERT INTO thread (title, message, created) VALUES ('最初の一撃', '〇〇小学校を襲撃しますよ', '2015-06-29 23:48:07');
すなわち攻撃の成功です。
いったんまとめ
大垣本記載のCSRF対策方式には抜けがあり、悪意のある掲示板のユーザーから、他のユーザーに対してCSRF攻撃により、なりすまし投稿ができることを示しました。その原因は、トークン管理がアプリケーション全体として共通となっており、ユーザないしセッション毎に分けて管理されてないことにあります。対策
簡単に対策するには、データベースにトークンを保存する方法の代わりに、セッション変数にトークンを保存することです。セッション管理は元々セッション毎に割り当てられているため、アプリケーション側でセッションを区別する必要がありません。その他のバグ(バリデーションの不備)
掲示板のタイトル欄はinput要素がソースなので改行は入力できないはずですが、PROXYツールなどで改行を入力してやると、バリデーションで弾かれず、以下のように改行を含む投稿が登録できてしまいます(「改行を含むタイトル」のところ)。タイトルに改行が入力できたとしても大きな実害はなく些細な問題かもしれませんが、意図した結果ではないように思えますので報告します。
レースコンディション
また、バグとまでも言えず微妙な問題ですが、form_idを二重投稿防止機能としてとらえると、レースコンディションの問題があります。form_idチェックの際に排他制御がされていないからです。このため、二重投稿のうち 2番目のリクエスト処理が、1番目のリクエスト処理に追いついてしまった場合、form_idが削除されないうちに 2番目のリクエストのSELECT文が実行されると、両方のリクエストが「form_idが有効」とみなされます。つまり局所的に見るとレースコンディションの問題があり二重投稿となりそうです。
なぜこれが「バグとまではも言えず」かというと、現実には2番目のリクエストが1番目のリクエストに追い付くことはないからです。その理由は、同一のセッションIDで二重にsession_start()しようとすると、二番目のsession_start()はロックされることによります。
注意:ただしこれは上記もあるように、ファイルベースのセッションの実装上の制限であり、PHPのセッションが一般的にそのような仕様であるわけではありません。それは、上記の引用のすぐ後ろにも書いてあります。
ファイルベースのセッション (PHP のデフォルト) は、 session_start() でオープンしたり session.auto_start で暗黙のうちに開始したりしたセッションのセッションファイルをロックします。 いったんロックがかかったら、そのスクリプトが終了するなり session_write_close() を呼んでセッションを閉じるなりしない限り、 他のスクリプトからはそのセッションファイルにアクセスできません。
http://php.net/manual/ja/session.examples.basic.phpより引用
これは、たとえば AJAX を使いまくっていて同時に複数のリクエストが発生したりするウェブサイトで問題になります。 この問題への対処方法として一番お手軽なのは、セッションに対して必要な変更が終わったらすぐに session_write_close() を呼ぶことです。スクリプトの最初のほうで呼ぶほうが好ましいでしょう。 あるいは、ファイルではなく別のバックエンド (同時アクセスに対応しているもの) を使うという手もあります。さまざまな環境で動かすことを考えると、2重投稿のチェック処理のレースコンディションに配慮しておくべきでしょう。
防御的プログラミング
このように、局所的に正しさを保証していくプログラミングスタイルは防御的プログラミング(defensive programming)と呼ばれ、1970年代に構造化プログラミングとあわせて提唱されたと記憶しています。皆様おなじみのカーニハンは防御的プログラミングが好きだったと見えて彼の書籍にはよく防御的プログラミングが言及されています(たとえばプログラム書法…これはFORTRANプログラムが題材です)。防御的プログラミングは元々セキュアコーディングとは関係なくバグの出にくい高品質なプログラムの開発手法として提唱されたものですが、脆弱性もバグの一種なので、バグが出にくいということは間接的に脆弱性も出にくくなるという効果があります。二重投稿チェック処理の改良
さて、防御的プログラミングの精神にのっとり、局所的なレースコンディションを解消しましょう。すぐに思いつく方法は、トランザクションとロックを使用することですが、実はもっと良い方法があります。先に引用したRemoveFormIDは、DELETE文をexecメソッドで呼び出した戻り値が 0 の場合 falseを返し、それ以外の場合 trueを返しています。execメソッドの戻り値が 0 ということは、DELETE文の影響を受けた行が 0 行、すなわち削除対象の行はなかったということです。さらに言えば、「先に誰かが消していた」わけですから、本来この場合はエラーにすべきです。しかし、残念ながらRemoveFormIDの戻り値は、呼び出し側は捨ててしまっています(bbs.class.phpのChcekFormIDメソッド)。
public function CheckFormID($sha1, $disable=true) {
$form_id = $this->db->GetFormID($sha1);
if (!$form_id || $form_id !== $sha1) {
$GLOBALS['error']='送信済みです';
return false;
} else {
$this->db->RemoveFormID($sha1);
}
return true;
}
そこで、RemoveFormIDの戻り値もチェックすれば…となりますが、実はその前のGetFormIDは不要なのです。今のスクリプトはform_idがテーブル上にあることをSELECT文で確認し、あればDELETE文で削除していますが、SQLなのですからいきなりDELETE文で削除して、その結果削除された行数が 1 行であればform_idは存在したわけですから投稿処理に進み、0行の場合はform_idがなかったわけですからエラーにすればよいのです。このようにするメリットは、スクリプトが単純になるだけでなく、SQL文がDELETE一つですむことから、クリティカルセクションがDELETE文の内部だけとなり、DELETE文の実行にともなう暗黙の排他制御だけで排他制御が完結するところにあります。すなわち、明示的なトランザクションと排他制御の指定は不要になります。
ただし、セキュリティの入門書であるという本書の特性を考えると、冗長でも敢えてSELECT…DELETEを用い、クリティカルセクションと排他制御の説明をするという方針もありだとは思います。