2010年7月1日木曜日

ぼくがPDOを採用しなかったわけ(Shift_JISによるSQLインジェクション)

補足

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


PHPのデータベース・アクセス・ライブラリPDOは、DB接続時の文字エンコーディング指定ができないため、文字エンコーディングの選択によっては、プレースホルダを使っていてもSQLインジェクション脆弱性が発生します。

追記(2011/06/19) ここに来て急にブクマが追加されはじめていますが、このエントリを書いてから状況が改善しています。PHP5.3.6(2011/03/17)にて、PDOでもデータベース接続の文字エンコーディングを指定できるようになりました。この版で、UNIX版のPHPでは解決しましたが、Windows版のPHPではバグがあり、解決には至っていませんでした。その後、千葉征弘さん(@nihen)により原因が判明し、PHP5.3.7RC1では修正されていることを確認しています。したがって、PHP5.3.7の正式版が出れば、この問題は解決すると思われます。(追記終わり)

はじめに

本を書いています。SQLインジェクションの節から書き始めて、初稿はレビュアーの方々にお送りしましたところ、さっそく有意義なコメントを多数頂戴しています。ありがとうございます。
その原稿の中で、DBアクセスに使用するPHPのライブラリとしてMDB2を紹介していたところ、PDOを紹介すべきではという意見を複数頂戴しました。そこで、現時点でPDOを採用していない理由を報告したいと思います。これは、「安全なSQLの呼び出し方」でPDOを取り上げていない理由でもあります。
PDOは接続時に文字エンコーディングを指定できない(指定しても無視される)ので、データベースへの接続時の文字エンコーディングはLatin1が暗黙に指定されます。すると、日本語の読み書きで文字化けが生じるため、「SET NAMES SJIS」により、文字エンコーディングを接続後に指定するようなプログラミングがなされているようです(MySQLの場合)。
そのようなプログラム例を示します。このプログラムでは、内部文字エンコーディングとしてShift_JISを利用しているという想定です。PHP5.3.0とMySQL5.1.37の組み合わせで確認しました。
<?php
  $dbh = new PDO('mysql:host=localhost;dbname=test;charset=sjis', 'username', 'password');
  $dbh->query("SET NAMES sjis");
  $sth = $dbh->prepare("select * from test WHERE name=?");
  $sth->setFetchMode(PDO::FETCH_NUM);
  $name = ...
  $sth->execute(array($name));
  while ($data = $sth->fetch()) {
    var_dump($data);
  }
PDO+MySQLの場合、プレースホルダは「動的プレースホルダ」あるいは、俗に「クライアントサイドのプリペアードステートメント」などと呼ばれる実装になっています*1
このため、$nameとして「ソ' OR 1=1#」という文字列を指定した場合、MySQLに対して以下のようなクエリが送信されます(Wiresharkによるダンプ)。

0x83は「ソ」の先頭バイトです。これをPDOではLatin1(ISO-8859-1)として扱うので、0x83はNBHという制御文字を意味し、1バイト文字になります。PDOは、続く「\'」を「\\\'」とエスケープします。
一方、MySQLサーバー側では、受け取ったSQLをShift_JISと想定しているので「ソ\\'」という並びと解釈します。「\\」は「\」をエスケープしたものなので、後続の「'」は文字列リテラルの終端に使われ、残りの「 OR 1=1#'」はSQL文の一部とみなされます。SQL文が注入できたので、SQLインジェクション脆弱性があるということになります。ここまでの説明を下図にまとめました。


my.cnfの修正でもダメ

「set names」がセキュリティ上問題があることが広く知られるようになったせいか、PDOの文字化けを別の方法で対処するようにすすめているエントリもあります。たとえばこのエントリでは、my.cnf(my.ini)の修正でPDOの文字化けに対応しているのですが、Shift_JISを使う場合はこれもダメです*2。その理由は、「PDOはLatin1として処理したものを、MySQLはShift_JISとして解釈する」状態には変わらないからです。

まとめ

  • PDO+MySQLでは、動的プレースホルダ(なんちゃってプリペアード・ステートメント)が使われる
  • PDOでは文字エンコーディングが指定できない
  • PDO+MySQL+Shift_JISでは、プレースホルダを使ってもSQLインジェクション脆弱性となる

解決策・回避策

PDOの文字化け問題とSQLインジェクション脆弱性問題を解決するにはどうすればよいでしょうか。私が思いつくのは以下の2つの方法です。
  1. PDOではなくMDB2など文字エンコーディングを正しく扱えるライブラリを使用する(根本的対策)
  2. アプリケーションからDBまで一貫してUTF-8で処理し、set names utf8により文字化けを回避する(回避策)
2.で具体的な問題が起こるかどうかは分かりませんが、文字エンコーディングを正しく扱えないことで潜在的なリスクがあると考えます。そのため、書いている本の中では1.を採用したという訳です。
しかし、私自身PHPを熟知しているわけではないので、ひょっとすると、PDOの使い方などでもっとよい方法があるかもしれません。識者のご指摘をいただければ幸いです。

追記(2010/07/01 22:20)

id:nihenさんが、PDOに対して文字エンコーディングを設定する方法を調べてくださいました。nihenさん、どうもありがとうございます。以下に引用します。
PDOからよばれるreal_escape_stringで文字コードを考慮させたい場合は
$dbh = new PDO('mysql:host=localhost;dbname=sandbox;charset=cp932', 'sandbox', 'sandbox', array(
    PDO::MYSQL_ATTR_READ_DEFAULT_FILE => '/etc/mysql/my.cnf',
    PDO::MYSQL_ATTR_READ_DEFAULT_GROUP => 'client',
));
もしくはserver-side-prepareを使う場合(文字コード気にしなくておk)
$dbh = new PDO('mysql:host=localhost;dbname=sandbox;charset=cp932', 'sandbox', 'sandbox', array(
    PDO::ATTR_EMULATE_PREPARES => false,
));
ちなむと前者は接続時に使われるオプションなのでnew PDOしたあとにあとから
$dbh->setAttribute
しても、ダメ。後者はおk。
gist:459499 - GitHubより引用
ご覧のように、MySQLサーバーへの接続時に、MySQL APIに渡すパラメータとして、my.cnfのファイル名を指定しています。上記の例ではGROUPを「client」に設定していますので、my.cnfに以下の設定を追加することになります。
[client]
default-character-set=sjis
ただし、Windows版のPHPでは、PDO::MYSQL_ATTR_READ_DEFAULT_FILEが使えないようです。このため、サーバー接続時に文字エンコーディングを指定する方法がありません。このため、Windows版のPHPを使う場合は、PDO::ATTR_EMULATE_PREPARESをfalseにする(真のプリペアード・ステートメントを使う)ことで対策することになります。


*1 高木浩光氏のいわれる「なんちゃってプリペアド・ステートメント
*2 元エントリはUTF-8なので問題はおきませんが、逆に言えば、set names utf8でも問題ないことになります


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

_ kanehama (2010年07月01日 12:23)
再現しなかったので、もう少し現象が再現できる設定を教えていただけると幸いです。

私は下記のような環境で試しました。

■PHPソースコード(Shift_JIS)
mb_internal_encoding('sjis');
echo mb_internal_encoding() . "\n";
$dbh = new PDO('mysql:host=localhost;dbname=test', 'root', '');

$sth = $dbh->prepare("SHOW VARIABLES LIKE 'char%'");
$sth->setFetchMode(PDO::FETCH_NUM);
$sth->execute();
while ($data = $sth->fetch()) {
    echo $data[0] . ' ' . $data[1] . "\n";
}

$sth = $dbh->prepare("select ?");
$sth->setFetchMode(PDO::FETCH_NUM);
$name = urldecode("%83%5C'%20OR%201=1#"); // ソ' OR 1=1#
$sth->execute(array($name));
while ($data = $sth->fetch()) {
    var_dump($data);
}


■実行結果
SJIS
character_set_client latin1
character_set_connection latin1
character_set_database sjis
character_set_filesystem binary
character_set_results latin1
character_set_server sjis
character_set_system utf8
character_sets_dir /usr/local/mysql/share/mysql/charsets/
array(1) {
  [0]=>
  string(11) "ソ' OR 1=1#"
}

■バージョン
 MySQL:5.1.34
 PHP:5.3.2

ちなみにPHPのソースコード、内部エンコーディングなどはShift_JISで統一しています。
今回のSQLインジェクションが成功するのであれば
var_dumpの結果は「1」になる予定でSQLを変更しています。

よろしくお願いします。

_ 徳丸浩 (2010年07月01日 13:26)
kanehamaさん、こんにちは
再現テストをしていただき、ありがとうございます。
当方では、SQLインジェクションとなるようです。
頂戴した結果ですが、

> character_set_client latin1
> character_set_connection latin1

この状態ですと、データベースの日本語に対して正常にアクセスできないのではないでしょうか。
当方の条件を変えて、上記エンコーディングをlatin1になるように調整すると、SQLインジェクションは出ない代わりに、日本語が文字化けします。
お手数ですが、いったんテーブルに格納した日本語が正常に読み書きできるかご確認いただけないでしょうか。

_ 心は萌え (2010年07月01日 15:01)
時間があったら試してみたいと思いますが そもそもlatin1などがなぜ出てくるのでしょうか?
日本語の場合はMysqlをビルドするときに--with-charset=で利用する文字コードをsjisなりutf8なりに固定したほうが良いのではないでしょうか?元々英語圏が基本でビルドされたmysqlを使っていませんか?

_ kanehama (2010年07月01日 15:09)
徳丸浩さんありがとうございます。

確認してみると確かに書き込み/読み込みで文字化けていました。(確認が足りなくて申し訳ありません。。
読み書きを正しく処理できるようにMySQLの設定を変更すると
徳丸浩さんが書かれているようにSQLインジェクションが起きました!!
# MySQLの文字コード関連の設定は下記のようになっています。
character_set_client sjis
character_set_connection sjis
character_set_database sjis
character_set_filesystem binary
character_set_results sjis
character_set_server sjis
character_set_system utf8

ただし、同じPHPソースを別環境で実行すると、SQLインジェクションは起きませんでした。
確認した組み合わせは PHP 5.2.3 と MySQL 5.0.37
# MySQLの文字コード設定は下記のようになっています。
character_set_client sjis
character_set_connection sjis
character_set_database sjis
character_set_filesystem binary
character_set_results sjis
character_set_server sjis
character_set_system utf8

PHP 5.3から何か変更あったのですかね。。
念のためあとでPHP 5.3.2が入ってる環境のPHPのバージョンを5.2.3に変えて確認してみます。

_ bero (2010年07月01日 18:19)
> PDO+MySQLの場合、プレースホルダは「動的プレースホルダ」あるいは、俗に「クライアントサイドのプリペアードステートメント」などと呼ばれる実装になっています

mysqlがよほど古いバージョンでもなければサーバサイドのを使うと思うんですが

ext/pdo_mysql/mysql_statement.c
#if HAVE_MYSQL_STMT_PREPARE
...
mysql_stmt_bind_param(...)

php 5.1.6と5.3.1で確認

_ bero (2010年07月01日 18:37)
上記mysql->libmysql

ドキュメントにも書いてた
http://php.net/manual/ja/ref.pdo-mysql.php
> PDO_MYSQL は、MySQL 4.1 以降に存在するプリペアドステートメントを ネイティブにサポートしているという利点があります。 古いバージョンの mysql クライアントライブラリを使用している場合は、 PDO がこの機能をエミュレートします。

_ carrot (2010年07月01日 19:41)
PHP5.2.1から、PDO_MYSQLは、デフォルトではクライアントサイドのプリペアードステートメントになったようですね。

http://www.php.net/releases/5_2_1.php に
PDO_MySQL now uses buffered queries by default and emulates prepared statements to bypass limitations of MySQL's prepared statement API.
とあります。

プリペアードステートメントだと、MySQLのクエリキャッシュが効かないためのようです。

こちらに動作検証された方がいらっしゃいます。
http://d.hatena.ne.jp/do_aki/20100221/1266746673

フォロワー