2018年11月15日木曜日

解答:間違ったCSRF対策~中級編~

この記事は、先日の記事「問題:間違ったCSRF対策~中級編~」に対する解答編です。まだ問題を見ていない方は、先に問題を読んで(できれば自分で解答を考えて)からこの記事をお読みいただくとよいと思います。
それでは、解答を説明します。

はじめに

出題時のわざとらしさから、この問題のポイントはstrcmp関数の挙動にあると気づいた方が多いと思います。

if (empty($_SESSION['token']) || empty($_POST['token'])
   || strcmp($_POST['token'], $_SESSION['token'])) {  // ワンタイムトークン確認
  die('正規の画面からご使用ください');
}
そして、strcmpの引数はどちらもempty()によるチェックが入っています。また、$_SESSION['token'] は、状態遷移図(下図)により、NULLかトークン文字列(乱数)のいずれかが入っている事がわかります。
これらから、CSRF対策を通り抜ける条件は以下となります。
  • empty($_POST['token']) が偽 かつ
  • strcmp($_POST['token'], 乱数文字列) が偽
一般に$_POST['token'] が返す値の型は以下のいずれかです。
  • null型    token=がない
  • 文字列   token=foo
  • 配列      token[]=foo → array('foo') が返る
そして、null型はempty()で真を返すこと、文字列型はトークンと一致させなければならず現実には困難であることから、残る可能性として配列を試してみましょう。
以下のサンプルスクリプトを試します。
<?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
NULL
警告は出ますが、strcmp関数の戻り値は NULL、すなわち偽と判定される値となることがわかります。これは凄い罠ですね。この挙動はPHPのマニュアルには載っていませんが、User Contributed Notesには掲載されています(→マニュアル)。
では、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例を以下に示します。
<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>
PoCの表示例です。


5秒経過すると、自動サブミットにより、以下の状態になります。


メールアドレスが変更されていることがわかります。

対策の考え方

この記事で紹介した攻撃が成立する原因は、文字列を想定しているトークンとして配列を指定できるところにあります。これを防ぐ目的で、生の$_POSTではなく、filter_input関数を使う方法があります。
$p_token = filter_input(INPUT_POST, 'token');
こう書くと、$_POST['token']が配列の場合、filter_inputはfalseを返すので、empty()で空と判定され、エラーに倒すことができます。

また、トークンの比較にstrcmpを使うこともよくありません。これは初級編で書いていたように === を用いる方が安全ですが、もっと良い方法として、PHP 5.6で導入された hash_equals関数を使うべきです。hash_equals関数はタイミング攻撃の緩和のために導入されましたが、より現実的なメリットとして、配列やnull値など文字列以外の値が指定された場合falseを返すので、意図しない入力に対して頑健です。実装例を以下に示します。
$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('正規の画面からご使用ください');
}
このケースでは、empty()によるチェックはなくてもよいのですが、保険的に残しています。なくても良い理由は、empty()がtrueを返す値で、かつhash_equalsがtrueになる可能性は、トークンが空文字列(長さ0の文字列)同士で一致する場合だけですが、$_SESSION['token']が空文字列になるシナリオはないからです。

実際のアプリケーション開発の際には上記のように手作りの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 件のコメント:

コメントを投稿

フォロワー

ブログ アーカイブ