『よくわかるPHPの教科書』のSQLインジェクション脆弱性

このエントリでは、数値型の列に対するSQLインジェクションについて説明します。

以前のエントリで、たにぐちまことさんの書かれた『よくわかるPHPの教科書』の脆弱性について指摘しました。その際に、『私が見た範囲ではSQLインジェクション脆弱性はありませんでした』と書きましたが、その後PHPカンファレンス2011の講演準備をしている際に、同書を見ていてSQLインジェクション脆弱性があることに気がつきました。

脆弱性の説明

問題の箇所は同書P272のdelete.phpです。要点のみを示します。

$id = $_REQUEST['id']; // $id : 投稿ID
$sql = sprintf('SELECT * FROM posts WHERE id=%d', mysql_real_escape_string($id)
$record = mysql_query($sql) or die(mysql_error());
$table = mysql_fetch_assoc($record);
if ($table['member_id'] == $_SESSION['id']) { // 投稿者であれば削除
  mysql_query('DELETE FROM posts WHERE id=' . mysql_real_escape_string($id)) or die(mysql_error());
}

このスクリプト断片は一言掲示板の投稿を削除するものです。投稿ID($id)に対して、まず投稿者自身による削除要求かどうかを確認します。投稿者であることが確認できたら、DELETE FROMにより投稿を削除します。

まず、正常系の処理を確認します。id=15の投稿を削除する場合の処理で、投稿者自身による削除要求とします。実行されるSQLは以下となります。

SELECT * FROM posts WHERE id=15
--
-- 投稿者の確認を行い、OKなので以下のSQLを実行
--
DELETE FROM posts WHERE id=15

次に、SQLインジェクション脆弱性の説明です。idとして、「15 or 1=1」が指定されたとします。最初のSELECT文に対して、パラメータはsprintfの%d書式で受けているので、SQLに与えられるパラメータは 15 となります。次に、投稿者のチェックを通った後は、%d書式ではなく、文字列連結でSQLを組み立てているので、「15 or 1=1」がそのまま与えられます。実行されるSQLは以下の通りです。

SELECT * FROM posts WHERE id=15
DELETE FROM posts WHERE id=15 or 1=1

1=1は常に成立するので、全ての投稿が削除されることになります。投稿者のチェックはくぐり抜けます。

ピンポイントで別人の投稿を削除する攻撃

次に、全ての投稿を削除するのではなく、別人の投稿を1つだけ削除する例を紹介します。
攻撃者が別人の投稿(ID=12)を削除したいと想定します。攻撃者には自分の投稿(ID=15)がある時、以下のidを指定すると、ID=15とみせかけて、ID=12を削除できます。

id=15-3

発行されるSQLは以下となります。

SELECT * FROM posts WHERE id=15
DELETE FROM posts WHERE id=15-3

チェックの時はid=15、削除の時はid=15-3 (すなわちid=12)が指定されるため、攻撃者は目的を果たすことができます。
こういうことが起きる原因のひとつは、SELECTとDELETE FROMとでSQL呼び出しの方法が違うことですが、「どうして呼び出し方を統一しないの?」と思ってしまいますね。

エスケープをしているのになぜ脆弱性となるのか

元のスクリプトは、ご覧のように、mysql_real_escape_stringによりパラメータのエスケープをしていますが、脆弱性が混入しています。なぜでしょうか。
mysql_real_escape_stringは、基本的に、文字列終端を示すシングルクォート「'」をエスケープすることが目的です。文字列リテラル中にシングルクォートがあると、文字列を勝手に終わらせてSQLを追加できるというのが文字列型に対するSQLインジェクション攻撃です。
これに対して、数値はシングルクォートで囲まないため、シングルクォートは「特別な記号」ではありません。数値以外の文字であれば、どれでも数値リテラルを終わらせ、その後は追加のSQLと認識されます。
先の攻撃例では、「15 or 1=1」を指定しました。この場合、15の後のスペースが「数値以外の文字」となります。そして、その後の「 or 1=1」が数値を「はみ出し」、追加のSQLと認識されました。この文字列中には、mysql_real_escape_stringの処理対象であるシングルクォートもバックスラッシュもないので、mysql_real_escape_stringは何もしません。つまり、対策にはなりません。

数値型の列のSQLインジェクション脆弱性はどう対策すればよいか

では、数値型の列に対するSQLインジェクションはどう対策すればよいでしょうか。その答えは、引用したサンプルの前半にあります。最初のSQL(SELECT)では、パラメータを書式%dで受けていました。%d書式を用いる限り、数値以外の文字が混入することはありません。したがって、有効なSQLインジェクション対策となります。mysql_real_escape_stringは必要ありません。
しかし、%sと%dの書式が混在している場合、うっかり数値を%sで受けてしまうと脆弱性になります。複雑なSQLの場合、この対応を確認するのは煩雑です。従って、整数型のパラメータであれば、(int)でキャストすればよいでしょう。対策後のSQLを以下に示します。

$sql = sprintf('SELECT * FROM posts WHERE id=%d', (int)$id);
$record = mysql_query($sql) or die(mysql_error());
// 省略
$sql = sprintf('DELETE FROM posts WHERE id=%d', (int)$id);
mysql_query($sql) or die(mysql_error());

不要なmysql_real_escape_stringがなくなりスッキリした上に、脆弱性がないことの確認が容易になります。同じ内容は、「PHP逆引きレシピは概ね良いが、SQLインジェクションに関しては残念なことに - ockeghem(徳丸浩)の日記」でも説明しました。

もっと良い方法はプレースホルダを使うこと

数値列に対するSQLインジェクションの原理は上記の通りですが、これを全ての開発者が理解・咀嚼し応用することは、現実には難しいだろうと思っています。もっと単純明快な対策はないでしょうか。
あります。プレースホルダによるSQL呼び出しがそれです。上記をPDOによるプレースホルダで書き直してみましょう。

$sth = $dbh->prepare('SELECT * FROM posts WHERE id=?');
$sth->bindValue(1, (int)$id, PDO::PARAM_INT);
$sth->execute();
// 省略
$sth = $dbh->prepare('DELETE FROM posts WHERE id=?');
$sth->bindValue(1, (int)$id, PDO::PARAM_INT);
$sth->execute();

少し行数が増えましたが、考え方はシンプルです。文字列型の列の場合は、(int)のキャストをやめ、PDO::PARAM_INTをPDO::PARRAM_STRに変更するだけです。あるいは、PDO::PARAM_STRを省略することも可能です。プレースホルダを用いた場合、リテラルを囲むシングルクォートも必要ありません。
また、PDOのような抽象化されたライブラリを利用することにより、PostgreSQLなど別のDBMSに移植することも容易になります。PHPの入門書等でも、MySQL関数(mysql_xxxx)やPostgreSQL関数(pg_xxxx)による説明をやめ、今後はPDOやMDB2を利用したサンプルで説明するべきだと考えます。その際、文字列連結によるSQL組み立ても説明せず、最初からプレースホルダで説明するべきです。

まとめ

『よくわかるPHPの教科書』を題材として、数値列に対するSQLインジェクションについて説明しました。
数値列に対するSQLインジェクション脆弱性の対策は、SQLに与えるパラメータが数値であることを確実にすることですが、もっと良い方法は、プレースホルダを使ってSQLを呼び出すことです。
以下を守る限り、SQLインジェクション脆弱性は原理的に発生しません。仮に発生するとしたら処理系の脆弱性です。

これについては、id:ajiyoshiさんの素晴らしいエントリ「Webアプリケーションとかの入門本みたいのを書く人への心からのお願い。」も参考になさって下さい。

参考

[PR]
安全なWebアプリケーションの作り方電子書籍版9月28日(水)販売開始します。くわしくはこちら