それでは、解答を説明します。
はじめに
出題時のわざとらしさから、この問題のポイントはstrcmp関数の挙動にあると気づいた方が多いと思います。そして、strcmpの引数はどちらもempty()によるチェックが入っています。また、$_SESSION['token'] は、状態遷移図(下図)により、NULLかトークン文字列(乱数)のいずれかが入っている事がわかります。if (empty($_SESSION['token']) || empty($_POST['token']) || strcmp($_POST['token'], $_SESSION['token'])) { // ワンタイムトークン確認 die('正規の画面からご使用ください'); }
これらから、CSRF対策を通り抜ける条件は以下となります。
- empty($_POST['token']) が偽 かつ
- strcmp($_POST['token'], 乱数文字列) が偽
- null型 token=がない
- 文字列 token=foo
- 配列 token[]=foo → array('foo') が返る
以下のサンプルスクリプトを試します。
結果は以下となります。<?php var_dump(strcmp('xyz', array('a')));
PHP Warning: strcmp() expects parameter 2 to be string, array given in Standard input code on line 2警告は出ますが、strcmp関数の戻り値は NULL、すなわち偽と判定される値となることがわかります。これは凄い罠ですね。この挙動はPHPのマニュアルには載っていませんが、User Contributed Notesには掲載されています(→マニュアル)。
NULL
では、PHPのどのバージョンからこの仕様なのだろうと思ってphpallで調べたところ、PHP 5.3.0から文字列と配列の比較でNULL(警告あり)を返すようになっており、PHP 5.2までは整数 32 を返していました。この仕様変更は ChangeLog にも掲載されていないのですね。
Burp Suiteで方法を確認する
それでは、PoC(概念実証コード)を書く前に、Burp Suiteで上記のアイデアを確認しましょう。まずmypage.phpからchgmailform.phpに遷移し、トークンをセッション変数にセットします。これは、empty()によるチェックを回避するために必要になります。次に、メールアドレスを適当に入力しますが、メールアドレス変更ボタンを押す前に、Burp Suite上で Intercept is on の状態にします。
その後メールアドレス変更ボタンを押します。下図のようにHTTPリクエストが捕捉されます。
上図の囲みの箇所で、トークンを下図のように変更します。
あとは Intercept is onボタンを押して(Intercept is offと表示が変わる)流しましょう。以下のように、メールアドレス変更に成功しました。
PoC作成
それでは、上記と同じ操作を再現するPoCを作成しましょう。まず、chgmailform.phpを閲覧する操作が必要ですが、iframe要素にchgmailform.phpを表示することにより実現することにします。その後時間を置いてchgmail.phpをPOSTします。PoC例を以下に示します。
PoCの表示例です。<body onload="setTimeout('document.forms[0].submit()', 5000)"> <iframe src="http://example.jp/chgmailform.php" width="250" height="100"></iframe> <form method="POST" action="http://example.jp/chgmail.php"> <input name="mail" value="evil@example.com"> <input name="token[]" size="10" value="1"> <input type="submit"> </form> </body>
5秒経過すると、自動サブミットにより、以下の状態になります。
メールアドレスが変更されていることがわかります。
対策の考え方
この記事で紹介した攻撃が成立する原因は、文字列を想定しているトークンとして配列を指定できるところにあります。これを防ぐ目的で、生の$_POSTではなく、filter_input関数を使う方法があります。こう書くと、$_POST['token']が配列の場合、filter_inputはfalseを返すので、empty()で空と判定され、エラーに倒すことができます。$p_token = filter_input(INPUT_POST, 'token');
また、トークンの比較にstrcmpを使うこともよくありません。これは初級編で書いていたように === を用いる方が安全ですが、もっと良い方法として、PHP 5.6で導入された hash_equals関数を使うべきです。hash_equals関数はタイミング攻撃の緩和のために導入されましたが、より現実的なメリットとして、配列やnull値など文字列以外の値が指定された場合falseを返すので、意図しない入力に対して頑健です。実装例を以下に示します。
このケースでは、empty()によるチェックはなくてもよいのですが、保険的に残しています。なくても良い理由は、empty()がtrueを返す値で、かつhash_equalsがtrueになる可能性は、トークンが空文字列(長さ0の文字列)同士で一致する場合だけですが、$_SESSION['token']が空文字列になるシナリオはないからです。$p_token = filter_input(INPUT_POST, 'token'); $s_token = $_SESSION['token'] ?? null; if (empty($s_token) || empty($p_token) || ! hash_equals($s_token, $p_token)) { die('正規の画面からご使用ください'); }
実際のアプリケーション開発の際には上記のように手作りのCSRF防御システムではなく、フレームワーク等が提供する仕組みを利用することをおすすめします。本稿で用いたサンプルは、脆弱性の説明のためにイケてない実装を採用しているので、改良したとしても本番システムでは使わないでください。
参考文献
PHPのstrcmp関数が配列を受け取った際の挙動については、OWASPのPHP Security Cheat Sheetに解説があり、JPCERT/CCの日本語訳で読むことができます。この解説の中に、Drupageddon(CVE-2014-3704)と「これとまったく同じ問題(原文はExactly the same issue)」とありますが、どうですかね。Drupageddonの原因については拙記事「DrupalのSQLインジェクションCVE-2014-3704(Drupageddon)について調べてみた」を参照ください。Drupageddonと類似の問題としては「JSON SQL Injection、PHPならJSONなしでもできるよ」もあります。もっとも、PHP Security Cheat Sheetは先程見に行ったら「This page has been recommended for deletion.」と削除されていました。なかなか激しいです。拙著「体系的に学ぶ 安全なWebアプリケーションの作り方 第2版」には、$_POSTではなくfilter_inputを用いる方がよいこと(P111)、トークンの比較にhash_equalsを用いるべきこと(P263)等が解説されています。
strcmpのPHP 5.3.0における仕様変更の状況については、海老原昂輔さんの「PHP 5.3 でネイティブ関数の引数型エラー時の返り値が変更になったけど大丈夫?」が参考になります。この記事の話は私も知っておらず、勉強になりました。(2018/11/15 12:00追記)
以上で、中級問題の解答編は終わりです。よろしければ「OWASP CSRFの防止策に関するチートシートにツッコミを入れる」にもチャレンジしてみてください。
0 件のコメント:
コメントを投稿