なぜPHPアプリにセキュリティホールが多いのか?

第41回PHP 5.3.4におけるセキュリティ上重要な仕様変更

PHP 5.3.4のリリースは2010年12月にリリースされました。このリリースにはセキュリティ上重要な変更が追加されています。

  • Paths with NULL in them (foo\0bar.txt) are now considered as invalid. (Rasmus)

パスに⁠foo\0bar.txt⁠などのようにNULLが含まれる場合は無効として処理される、とPHP 5.3.4のリリースノートには記載されています。PHP開発者の間でもあまり大きなニュースとして取り上げられていないので、この仕様変更をご存知でない方も多いと思います。2011年4月現在でもこの仕様変更はマニュアルには記載されていません。しかし、この修正はセキュリティ上非常に重要な意味を持っているので解説します。

仕様変更の必要性

PHP本体はC言語で記述されているため、ファイルを開く場合、最終的にはC言語のライブラリにファイル名が渡されます。C言語の文字列はバイナリセーフではなく、NULL文字(\0)は文字列の終端を表す文字になっています。一方、PHPの文字列はバイナリセーフであるためNULL文字は特別な意味を持ちません。

このPHP言語とC言語の文字列型変数の仕様の違いにより、脆弱なスクリプトが存在するとNULL文字を利用した強制ブラウズが行えるようになっていました。

サンプルコード1
<?php
// 拡張子をチェックしてpngファイルを送信する
if (preg_match('|\.png$|', $_GET['png_file'])) {
  echo readfile($_GET['png_file']);
}
?>

サンプルコード1には2つの問題があります。一つは⁠..⁠や最初の文字の⁠/⁠をチェックしていないため、システム上の任意のディレクトリにアクセスできる問題です。もう一つはNULL文字を利用した任意ファイルへのアクセス脆弱性です。この二つの脆弱性によりリモートユーザーはPHPがアクセスできるシステム上の任意ファイルにアクセスできてしまいます。

少なくとも⁠.png⁠ファイルだけにアクセスは制限されるのでは? と考える方も居るかも知れません。

  • preg_match('|\.png$|', $_GET['png_file']))

この行で拡張子をチェックしようとしていますが、このチェックは意味をなしません。preg_match関数はバイナリセーフであるためNULL文字を特別な文字列として取り扱いません。このため、最後の文字列が⁠.png⁠であれば途中にNULL文字が含まれていても終端の⁠.png⁠にマッチしてしまいます。

<?php
var_dump( preg_match('|\.png$|', "../../../etc/passwd\0.png"));
?>

このコードはint(1)を返し、⁠.png⁠で終わる文字列として評価します。このため、

  • http://example.com/get_image.php?..%2F..%2F..%2Fetc%2Fpasswd%00.png

のようにアクセスすると、/etc/passwdファイルを読み取ることができました。サンプルコードの場合はフルパスでのアクセスもチェックしていないのでもっと簡単に、

  • http://example.com/get_image.php?%2Fetc%2Fpasswd%00.png

としてUNIX系OSのパスワードファイルを取得できてしまいます。

今までのPHPの防御

PHP 5.3.4以前のPHPでも、上記のようにセキュリティ上問題があるスクリプトがあっても問題の影響範囲を小さくする仕組みが用意されており、次の2つのphp.ini設定で利用できるようになっています。

  • safe_mode
  • open_basedir

セーフモード(Safe Mode)はphp.ini(sefe_mode=on)で設定し、PHPスクリプトファイルのユーザーID・グループIDと開こうとするファイルのユーザーID・グループIDが一致しない場合にファイルの利用を制限する機能です。フェイルセーフ対策としては有効な機能ですが、安全性を維持する機能として誤用されることが多かったため、次のメジャーリリース(PHP 5.4以降)では削除される機能になっています。

オープンベースディレクトリ(open_basedir)はPHPスクリプトファイルが開くことが可能なトップレベルのディレクトリを指定する機能です。例えば、サンプルコード1のように脆弱なスクリプトがあっても

  • open_basedir=/var/www/php-application

のように設定していれば、/var/www/php-application以外の/etc/passsなどのファイルには「PHPのファイル関数」を利用してアクセスできなくなります。

注意

PHPのファイル関数からアクセスできなくするだけで、データベースなどからシステム上のファイルにアクセスすることは可能です。open_basedirもsafe_modeと同様、問題が発生した場合の影響範囲を制限するフェイルセーフ対策です。

NULL文字無効化

PHP 5.3.4で導入されたNULL文字の無効化は、サンプルコードのような脆弱なスクリプトの問題の主要な部分を解決します。分かりやすくするためにファイル名はスクリプト中に記述します。

<?php
$filename = "/etc/passwd\0.png";
if (preg_match('|\.png$|', $filename)) {
  echo readfile($filename);
}
?>

このコードをPHP 5.3.4以前で実行すると

root:x:0:0:root:/root:/bin/bash
bin:x:1:1:bin:/bin:/sbin/nologin
daemon:x:2:2:daemon:/sbin:/sbin/nologin
adm:x:3:4:adm:/var/adm:/sbin/nologin
lp:x:4:7:lp:/var/spool/lpd:/sbin/nologin
sync:x:5:0:sync:/sbin:/bin/sync
shutdown:x:6:0:shutdown:/sbin:/sbin/shutdown
halt:x:7:0:halt:/sbin:/sbin/halt
mail:x:8:12:mail:/var/spool/mail:/sbin/nologin
uucp:x:10:14:uucp:/var/spool/uucp:/sbin/nologin
operator:x:11:0:operator:/root:/sbin/nologin
[省略]

のようにパスワードファイルをダンプしてしまいます。PHP 5.3.4以降で実行するとreadfile関数がFALSEを返すため何も表示されません。

ローカルスクリプトインクルード

inlude/require文もファイル関数の一種です。サンプルコード1とまったく同じ構造のコードでinlude/require文を使ってしまうとローカルスクリプトインクルードが可能になります。

サンプルコード2
<?php
//モジュール名が指定されたらインクルード
if (preg_match('|\.php$|', $_GET['module_name'])) {
  include('./' . $_GET['module_name'] . '.php');
}
?>

このスクリプトはreadfile関数で脆弱なスクリプトとなってしまう場合よりもさらに悪いスクリプトです。ローカルファイルに限定されますがシステム上の任意のファイルが取得可能なうえ、ファイルアップロードが可能な場合は任意のスクリプトを実行される可能性がありました。

仕様変更の効果

PHPは埋め込み型の言語であるため、イメージファイルなどに埋め込まれたPHPスクリプトでも問題なく実行できます。PHPスクリプトのアップロードができなくても、イメージのアップロードなどができれば攻撃用のコードを埋め込み実行させることが可能でした。サンプルコード2はローカルスクリプトインクルードに脆弱な典型的なコードですが、PHP 5.3.4以降では上記のように脆弱なコードがあっても任意ローカルスクリプト実行と任意ファイル参照のリスクが大幅に低減します。

ファイル関数へNULL文字が含まれる文字列が渡された場合に無効化される仕様変更により、NULL文字攻撃に脆弱なスクリプトへの攻撃を不可能にします。今まで致命的とも言える脆弱性を持つPHPスクリプトであっても脆弱性がなくなる場合もあります。

この仕様変更はPHPスクリプトの安全性向上に大きく貢献します。しかし、この仕様変更は万能ではありません。サンプルコード2のようなコードでは.php拡張子を持つファイルはinclude文で読み込まれてしまいます。ユーザーから送信されたファイル名はしっかりとチェックするようにしなければなりません。

例えば、PHPアプリケーションのモジュールをインクルードする場合なら、数字・小文字アルファベット・アンダースコアのみで構成される文字列であることをチェックします。

if (strspn($_GET['module'], 'abcdefghijklmnopqrstuvwxyz0123456789_') != strlen($_GET['module'])) {
  die('Invalid module!');
}

注意

正規表現関数を利用しなくても済む文字列チェックは文字列関数を使うほうが効率がよい場合が多く、間違いが発生する可能性も少ないでしょう。

まとめ

PHP 5.2よりphp.iniにallow_url_includeディレクティブが導入されたことにより、簡単にサーバ乗っ取りが可能となるリモートファイルインクルード脆弱性のリスクが大幅に低減されました。今回のNULL文字を受け付けなくなる仕様変更によりローカルスファイルインクルード脆弱性のリスクも大幅に低減されました。

PHPは埋め込み型言語であるため、ファイルインクルード脆弱性は任意スクリプト実行につながる致命的な脆弱性となるケースが多くありました。しかし、PHP 5.3.4以降ではローカルの任意スクリプトの実行もかなり難しくなりました。PHPはほかの言語に比べ任意スクリプト実行が容易であったため、PHPによるアプリケーション構築はリスクが高い、と認識されていた開発者も多いでしょう。この仕様変更はその認識を改めさせることとなる仕様変更であり、PHPアプリケーションのセキュリティに重要な意味を持っています。

おすすめ記事

記事・ニュース一覧