2013年12月25日水曜日

間違いだらけのSQL識別子エスケープ

これから3回連載の予定で、SQL識別子のエスケープの問題について記事を書きます。SQL識別子のエスケープについてはあまり解説記事などがなく、エンジニア間で十分な合意がないような気がしますので、これらの記事が議論のきっかけになれば幸いです。
3回の予定は以下のとおりです。
ということで、まずはSQL識別子のエスケープの失敗例について説明します。この失敗例はあくまで説明のために作ったもので、実際のものではありません。また、想定が「ありえない」と思われるかもしれませんが、意図的なものですのでご容赦いただければと思います。また、「間違いだらけの」というタイトルは、今回の題材が間違いだらけという意味であり、巷のSQL呼び出しがそうであるという意味ではありません。本稿に登場する人物と団体は全て架空のものです。

SQL識別子のクォートとエスケープ

SQLの識別子(テーブル名や列名など)には、引用符で囲う形式と囲わない形式があります。以下のいずれかに該当する場合は、識別子を引用符(標準ではダブルクォート)で囲む必要があります。引用符で囲むことを「クォートする」と呼びます。
  • 識別子が予約語になっている場合(例: "table")
  • 識別子に記号が含まれる場合(例: "j&j")
  • 識別子が数字で始まる場合(例: "0x")
さらに、識別子中に引用符が含まれる場合は、引用符を重ねます(例: "x""y")。この引用符を重ねる処理を「識別子のエスケープ処理」と呼ぶことにします。
MySQLの場合は、デフォルトでは、ダブルクォートを文字列リテラルを囲む目的で使うため、識別子はバッククォートで囲みます。このため、先の例はそれぞれ、`table`、`j&j`、`x"y`、`0x`となります(ただし、MySQLは数字で始まる識別子を引用符で囲まなくてもエラーにならないようです)。識別子にバッククォートを含む場合は、`x``y`のようにバッククォートを重ねます。後述するように、ANSI_QUOTESモードを設定している場合は、ダブルクォートは識別子のクォートに使えるようになります。

脆弱なスクリプトの説明

ここで脆弱なスクリプトについて説明します。とある地域の観光協会では、春・夏・秋・冬の季節毎にホームページの表示を切り替えており、コンテンツは、それぞれtb1、tb2、tb3、tb4という季節毎のテーブルに格納されています。
このため、表示スクリプトは、season=tb1 のようにテーブル名を指定して、そのテーブルの内容を表示しています。スクリプトを以下に示します(注: このスクリプトには脆弱性があります)。
<?php
header('Content-Type: text/html; charset=Shift_JIS');
$table = $_GET['season'];
try {
  $db = new PDO("mysql:host=xxxxx;dbname=db;charset=sjis", "xxxx", "xxxx");
  $db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
  $sql = "SELECT * FROM $table";
  $ps = $db->query($sql);
  // 検索結果の表示
} catch (Exception $e) {
  echo htmlspecialchars($e->getMessage(), ENT_COMPAT, 'Shift_JIS');
}
スクリプトの呼び出しは、http://example.jp/search.php?season=tb2 のように行います。この場合、呼び出されるSQL文は下記となります。
SELECT * FROM tb2

単純なSQLインジェクション

このスクリプトを運用してしばらくすると、コンテンツの内容が消去されるという不正アクセスが発生しました。アクセスログを調べると、以下のリクエストが原因のようです。
/search.php?season=tb2;delete+from+tb2
この際に生成されるSQL文は以下となります。
SELECT * FROM tb2;delete from tb2
SELECT文に続き、DELETE FROM文が追加されています。PDO+MySQLの場合、複文の実行が可能なので、テーブルtb2の内容は全て削除されてしまいました。

観光協会は地元のセキュリティ会社に相談したところ、テーブル名を`で囲むと良いとアドバイスを受けました。該当の箇所は下記となります。
  $sql = "SELECT * FROM `$table`";
この場合、左記の攻撃リクエストの結果生成されるSQL文は下記となります。
SELECT * FROM `tb2;delete from tb2`
`tb2;delete from tb2`がテーブル名として解釈されますが、この名前のテーブルは存在しないのでエラーにはなりますが、SQL文の構造は変化せず、SQLインジェクション攻撃は成立しなくなりました。

バッククォート「`」によるSQLインジェクション

観光協会はこの状態でしばらく運用をしていましたが、再度コンテンツが削除されるという不正アクセスがありました。今度は以下のリクエストが問題のようでした。
/search.php?season=tb2`;delete+from+tb2%23
tb2の後に「`」が追加されています。生成されるSQL文は下記の通りです。
SELECT * FROM `tb2`;delete from tb2#`
今度は、外部から注入された「`」で識別子の引用符が閉じられて、その後の文字列が追加のSQL文と解釈されていました(#以降はコメント)。
観光協会は再度地元のセキュリティ会社に相談に行くと、「識別子のエスケープをしていないのが原因だ。識別子のエスケープは世界の常識です。」と言われてしまいました。だったら先に教えてくれればいいのにとは思いましたが、識別子のエスケープの方法を教えてもらって、スクリプトに組み込みました。
// 識別子エスケープ関数
function escape_ident($id) {
  return preg_replace('/`/', '``', $id);
}
  // 中略
  $etable = escape_ident($table);
  $sql = "SELECT * FROM `$etable`";
この結果、生成されるSQL文は下記となります。
SELECT * FROM `tb2``;delete from tb2#`
バッククォート「`」が重ねられた結果、`tb2``;delete from tb2#`がテーブル名と解釈されるようになり、SQLインジェクション攻撃は防げるようになりました。

文字エンコーディングの取り扱い不備によるSQLインジェクション

観光協会のホームページはしばらく平穏な日々が続きましたが、またしてもデータが全て削除される事件が起こってしまいました。
今度の原因は次のリクエストのようでした。
search.php?season=%8d%60;delete+from+tb2%23
生成されたSQL文は下記の通りです。%8d%60 は、Shift_JISで「港」という文字になります。運の悪いことに、「港」という名前のテーブルが元々あったのです(わざとらしい想定スマソ)。
SELECT * FROM `港`;delete from tb2#`
港は良いとして、その後に「`」が追加されているのは、どうしてでしょうか?
その秘密は「港」の2バイト目の%60にあります。これ、実は、バッククォート「`」と同じコードなのです。preg_replaceの文字列置換の際に文字エンコーディングを考慮していないので、「港」の2バイト目を「`」と見なして、これを重ねる処理をしてしまったのでした。
地元のセキュリティ会社では、最初「文字エンコーディングのバリデーションががが」と言っていましたが、攻撃文字列はShift_JISとしてはバリッドでありバリデーションでは防御できないことがわかり、preg_replaceではなくmb_ereg_replaceを使ってエスケープ関数を書き換えました。
function escape_ident($id) {
  mb_regex_encoding('Shift_JIS');
  return mb_ereg_replace ('`', '``', $id);
}
また、バッククォート「`」は文字コードが0x60でShift_JISの2バイト目になることがあるため、予防的対策として、MySQLをANSI_QUOTESモードに設定して、識別子はダブルクォート「"」で囲むことにしました。
ANSI_QUOTESモードの設定は、my.cnfに以下を設定します。
sql-mode="ANSI"
これにあわせて、SQL文組み立ての式を以下のように変更しました。
$sql = "SELECT * FROM \"$etable\""
ダブルクォートの文字コードは0x22なので、Shift_JISの2バイト目に重なることはありません。

MySQLの設定考慮漏れに起因するSQLインジェクション

観光協会のホームページはしばらく平穏な日々が続きましたが、またしてもデータが全て削除される事件が起こってしまいました。
今度の原因は次のリクエストのようでした。
/search.php?season=tb2";delete+from+tb2%23
生成されたSQL文は下記の通りです。
SELECT * FROM "tb2";delete from tb2#"
識別子をダブルクォートで囲むように変更した際に、エスケープの対象文字もダブルクォートに変更しておかなければならなかったのですが、それを忘れていたのが原因でした。その変更を入れたところ、攻撃はできなくなりました。

任意テーブルの情報漏洩

観光協会のホームページはしばらく平穏な日々が続きましたが、今度はデータベースに保存された個人情報が漏えいするという事故が起こってしまいました。
今度の原因は次のリクエストのようでした。
/search.php?season=users
生成されたSQL文は下記の通りです。
SELECT * FROM "users"
usersというテーブルには、ホームページに会員登録したユーザーの個人情報が登録されています。単純な攻撃ですね。
観光協会は、あわててサイトをいったん閉鎖して、個人情報漏洩の告知など事後対処に追われました。

最終的にどうしたか

結局、任意のテーブル名を外部から指定できる実装に問題があるということになり、こちらの記事を参考にして、季節は1~4の番号で指定する仕様に変更しました。そうすると、テーブル名を引用符で囲むこともエスケープも不要になりました(下記)。アプリケーション内部で使用する文字エンコーディングがShift_JISであることもよくないので、UTF-8に変更しました(コードは省略します)。
$season_tables = array(1 => 'tb1', 2 => 'tb2', 3 => 'tb3', 4 => 'tb4');
$season = $_GET['season'];
if (! isset($season_tables[$season])) {
  die('invalid "season"');
}
$table = $season_tables[$season];
$sql = "SELECT * FROM $table";

とりあえずのまとめ

テーブル名を外部から指定できる実装のアプリケーションを題材として、SQL識別子の扱いを原因とするSQLインジェクション攻撃と、間違った対策例を紹介しました。具体的には下記の通りです。

  • 識別子のクォートもれ
  • 識別子のエスケープもれ
  • 識別子エスケープの際の文字エンコーディング対応不備
  • MySQLのANSI_QUOTESモードの考慮洩れ
  • 外部から指定するテーブル名の権限のチェック洩れ

本稿では、このアプリケーションの最終的な実装を示しましたが、一般論としてSQL識別子をどう扱うのが良いかについては、稿を改めて説明します…が、その前に、次回は、SQL識別子のエスケープもれによる不具合の実例を紹介します。

0 件のコメント:

コメントを投稿

フォロワー