PHP逆引きレシピは概ね良いが、SQLインジェクションに関しては残念なことに



 404方面でも絶賛されていたPHP逆引きレシピを購入した。本書はとても丁寧な仕事で素晴らしいと思ったが、セキュリティに関しては若干残念な思いをしたので、それを書こうと思う。
 目次は以下のようになっている。

第1章 準備
第2章 PHPの基本構文
第3章 PHPの基本テクニック
第4章 ファイルとディレクト
第5章 PEARSmarty
第6章 Webプログラミング
第7章 クラスとオブジェクト
第8章 セキュリティ
8.1 セキュリティ対策の基本
8.2 PHPの設定
8.3 セキュリティ対策
第9章 トラブルシューティング
第10章 アプリケーション編

PHP逆引きレシピ オフィシャルサポート

 本書は、タイトルの示すように、コレコレしたいという目的ごとにPHPでの書き方が書かれている。よくある逆引き辞典タイプの本だが、類書に比べて丁寧に書かれている印象を受けた。私が感心したのは、PHP4.4.9、PHP5.2.9、PHP5.3(RC2-dev)での対応を書いてあるだけでなく、『本書では、レンタルサーバー「さくらインターネット」「XREA」「ロリポップ」でサンプルスクリプトの動作確認を行い、その結果を以下のマークで表しています』というところで、丁寧な仕事ぶりだと思った。

 第8章の「セキュリテイ」についても概ねよく書けている。先行する仕事をよく調べつつ、その内容をしっかり咀嚼した上で書いてあるという印象だ。書かれている内容すべてに同意するわけではないが、少なくとも、本書で書かれている方法で書いてセキュリティホールが混入するということは、ほとんどないのではないか。本書で省略された内容についても、HTTPヘッダインジェクションくらいで*1PHPで問題になりやすいヌルバイト攻撃や、文字エンコーディングの問題、インクルード攻撃などもしっかり記述してある。筆者たちの努力に、まずは敬意を払いたい。

 しかし、SQLアクセスに関しては、残念なところがある。まず、本書のP614には以下のように正しい指摘がある。

SQLインジェクションへの対策は、プリペアドステートメントプレースホルダの仕組みを持つ関数を使うことです。

 そして、その後のサンプル06-2.phpも、以下のようにバインド機構を用いたスクリプトが示されている。

  if ($stmt = mysqli_prepare($link, $sql)) {
# 準備したクエリに変数をバインドします。第2引数は、変数の型が2つとも
# 文字列(s)であることを指定しています。
    mysqli_stmt_bind_param($stmt, 'ss', $_POST['id'], $_POST['password']);
# クエリを実行します。
    mysqli_stmt_execute($stmt);

 しかし、その前の第6章のP524に書かれているサンプルスクリプトは以下のようにエスケープが使われている。

# SQL文を作成します。
  $sql = sprintf("SELECT * FROM example WHERE id = '%d' OR language = '%s'",
                 mysql_real_escape_string('1'), mysql_real_escape_string('Ruby')
                );

 エスケープを使うこと自体は間違いではないが、第8章のセキュリティの説明では第一にプリペアドステートメントを推奨しているのであれば、サンプルもそちらで統一できるとよかった。
 そして、上記のサンプルには気になるところがある。リテラルをわざわざエスケープしていること自体はよいことだろう。ユーザ入力であろうとなかろうと、常にエスケープするという正しい習慣を身につけることに役立つからだ。
 問題は、列idに関してだ。この列は整数型である。テーブルexampleの定義は以下のようになっている(本書P520)。

CREATE TABLE IF NOT EXISTS `example` (
  `id` int(2) NOT NULL auto_increment,
  `language` varchar(10),
  PRIMARY KEY  (`id`)
) ENGINE=MyISAM  DEFAULT CHARSET=utf8;

 そして、エスケープ後のSQLは以下のようになる。

SELECT * FROM example WHERE id = '1' OR language = 'Ruby'

 このように、数値をクォートしてエスケープする方法は、従来から大垣靖男氏が主張している方法だ。たとえば以下の説明。

文字列,整数などデータ型に関わらず変数すべてを文字列としてエスケープすることにより,SQLインジェクションを100%防ぐことが可能となります。

http://gihyo.jp/dev/serial/01/php-security/0005?page=2

 今まで追随者を見かけなかったが、最近になって大垣氏以外の方がこの方法を紹介する例が増えてきているように思う。しかし、このパターンを以下のように一般化すると、どうだろうか。

# SQL文を作成します。
  $sql = sprintf("SELECT * FROM example WHERE id = '%d' OR language = '%s'",
                 mysql_real_escape_string($id), mysql_real_escape_string($lang)
                );

 $idに元々整数型の値が入っていた場合に、このスクリプトは整数と文字列の間で、何度も型変換が行われる。

$id 整数
mysql_real_escape_stringの引数 整数→文字列
sprintf(..%d ..) 文字列→整数→文字列
where id='1' 文字列→整数

 右向き矢印「→」の数だけ型変換が行われるので、上記の場合は4回型変換が行われ、整数と文字列の間を行ったり来たりすることになるのだ。これはいささか筋の悪いプログラムと言えるだろう。
 また、SQLの文字列→数値の暗黙の型変換は色々問題がある。この暗黙の型変換の結果がどうなるかは処理系依存であって、この点は以前数値リテラルをシングルクォートで囲むことの是非 - ockeghem(徳丸浩)の日記で紹介した。さらに、id:teracc氏が2009-06-05 - T.Teradaの日記にて補足いただいた内容によると、DB2ではそもそも文字列→数値の「暗黙の型変換」はエラーになるとのことである。
 MySQLの場合、実行効率も悪くなることは、奥 一穂氏が、Kazuho@Cybozu Labs: MySQL の高速化プチBKで紹介されている。私もあらためてMySQL5.1にて追試したところ、数値をクォートしない場合が0.23秒、クォートする場合が0.41秒と、倍近い差となっている。
 これら書法上、効率上の問題は以前変数に型のない言語におけるSQLインジェクション対策に対する考察(5): 数値項目に対するSQLインジェクション対策のまとめ - 徳丸浩の日記(2007-09-24)にてまとめている。

 思うに、上記は以下のように書けばよいのではないか。

  $sql = sprintf("SELECT * FROM example WHERE id = %d OR language = '%s'",
                 (int)$id, mysql_real_escape_string($lang)
                );

 このようにすれば、何度も型変換することはないし、非常に読みやすい。効率も良い(悪くならない)。
 この書き方の良いところとして、ソースコードのチェックがやりやすいこともある。sprintfの書式文字列は、%dはクォートしない、%sはクォートする、この二点を確認する。また、パラメータのところでは、(int)のキャストか、mysql_real_escape_stringによるエスケープのどちらかを実行していることを確認する。これで、SQLインジェクション対策については問題ないのだ。心配なのは、数値を'%s'で受けたり、文字列を%dで受けることだが、下表のようにSQLインジェクションにはならない。ただし、文字列を%dを受けると当然ながら処理はおかしくなるが、これはこの方法固有の問題ではなくて単なるバグだ。その場合でもSQLインジェクション脆弱性が混入せず、きわめてミクロのチェックですむところがミソといえる。

書式 数値 文字列
%d 数値に変換されるがSQLインジェクションにはならない
'%s' 大垣氏の方法でありSQLインジェクションにはならない

 本書では、これ以降のところでも、概ね似たようなサンプルが並ぶのだが、とくにおかしいのがP548の「テーブルを作成したい」に出てくるサンプルだ。これは以下のようになっている。

# 作成するテーブルの中身を変数に代入します。
  $newTable = 'id INT(2) NOT NULL AUTO_INCREMENT PRIMARY KEY, ' .
              'data VARCHAR(100) NOT NULL';

# テーブルを追加するためのSQL文を作成します。
  $sql = sprintf("CREATE TABLE example2 (%s) ENGINE=MyISAM " .
                 "DEFAULT CHARSET=utf8",
                 mysql_real_escape_string($newTable));

// ロリポップの場合はこちらを有効にする
//  $sql = sprintf("CREATE TABLE example2 (%s) ENGINE=MyISAM ",
//                 mysql_real_escape_string($newTable));

 SQLのCREATE TABLE文について、列定義の部分のみ$newTableとして切り出しているが、それをSQLとして組み立てる際に、mysql_real_escape_string関数でエスケープしている。これはおかしい。SQLエスケープは、SQLの文字列リテラル内をエスケープすべきなのであって、このようなSQL断片に対してmysql_real_escape_stringを通してしまうと、処理がおかしくなる。このサンプルの場合は正常に動作するが、それは偶然に過ぎないと考えた方がよい。
 うまく行かない例として、MySQLの場合、列定義にコメントがつけられる。たとえば以下のように。

CREATE TABLE example3 (id INT(2) COMMENT 'this is a comment');

 このような場合に括弧の中身を無理にエスケープすると、次のように文法違反となる。

CREATE TABLE example3 (id INT(2) COMMENT ''this is a comment'');

 COMMENTなんて使わないかもしれないが、そもそも列定義の部分をエスケープをする必要などまったくないのだ。

 本書は750ページ近いボリュームで、\2,600というお値打ち価格であるにも関わらずとても手の掛かった丁寧な仕事でお買い得なのだが、それだけに、上記はとても残念な気がした。本書の読者の参考になれば幸いである。

参考:
WASForum Conference 2008講演資料「SQLインジェクション対策再考」
数値項目に対するSQLインジェクション対策のまとめ
SQLの暗黙の型変換はワナがいっぱい
quoteメソッドの数値データ対応を検証する

追記(2010/3/19)

IPA非常勤研究員として「安全なSQLの呼び出し方」執筆に参画いたしました。数値に対するSQLインジェクション対策も説明していますので、あわせて参考にいただければと思います。

*1:これはPHPのheader関数がHTTPヘッダインジェクション対策されているから、アプリケーション側で対策する必要はないという趣旨なのだと想像する