『よくわかる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日(水)販売開始します。くわしくはこちら。
体系的に学ぶ 安全なWebアプリケーションの作り方 脆弱性が生まれる原理と対策の実践
- 作者: 徳丸浩
- 出版社/メーカー: SBクリエイティブ
- 発売日: 2011/03/01
- メディア: 単行本
- 購入: 119人 クリック: 4,283回
- この商品を含むブログ (146件) を見る