レガシーPHPのセキュリティ対策、大丈夫ですか?

第5回ファイル名にNULLを含む場合に無効化するパッチ

PHP 5.3.4には非常に重要な仕様変更が含まれています。その仕様変更はphp.iniにallow_url_includeオプションを付けてリモートインクルードによる任意スクリプトの実行をできなくした変更に匹敵するくらいセキュリティ上重要な変更です。この仕様変更により多くの任意ファイル読み取りや任意ファイル実行の脆弱性を防ぐことが可能になりました。

NULL文字攻撃

PHP 5.3.4以前のPHPは、ファイル関数(正確にはストリーム型の関数すべて)のファイル名パラメータにNULL文字が含まれていても何事も無かったように処理していました。PHPの文字列型はバイナリセーフであるため、文字列中にどのようなバイトがあっても動作が変化することはありません。例えば、バイナリデータを文字列型に保存して連結を行ってもまったく問題なくデータを連結できます。

一方、PHPはC言語で記述されており、C言語の文字列はバイナリセーフではなくNULL文字が文字列の終端を表します。したがってPHPが内部で利用するファイル関数などのAPIは、PHPから渡された文字列型のデータをC言語の文字列として取り扱います。このPHPとC言語の文字列の取り扱いが異なることが原因でNULL文字のインジェクション攻撃が可能になります。

脆弱なサンプルコード:getfile.php
<?php
const('FILE_PATH', '/var/userfile/'); // PHP 5.3からサポートされている定数定義方法。バイトコードキャッシュする場合に有利

header('Content-Type: text/plain');
readfile(FILE_PATH . $_GET['filename'] . '.txt'); // getfile.php?filename=../../etc/passwd%00 で/etc/passwordを取得可能
?>

この例は任意ファイルの読み取りが可能な例ですが、include/require文を利用した場合はローカルファイルインクルードが可能になります。

ファイル関数にとってNULL文字は文字列の終端を表す特別な意味を持つ文字であるため、NULL文字挿入によってプログラマが意図しない動作をさせることが可能になってしまいます。

出力先で特殊な意味を持つ文字がある場合、正しく出力しないと意図しない動作を起こすことがあります。一つ以上の特殊な意味を持つ文字がある場合、エスケープをするか、エスケープをしなくても安全に出力できる方法で出力しなければなりません。NULL文字の挿入はJavascriptインジェクションやSQLインジェクションと「同じ」インジェクション攻撃です。

PHP 5.3.4のNULL無効化パッチ

Zendエンジン側はzend_vm_execute.hのZEND_INCLUDE_OR_EVAL_SPEC_CONST_HANDLER、ZEND_INCLUDE_OR_EVAL_SPEC_TMP_HANDLER、ZEND_INCLUDE_OR_EVAL_SPEC_VAR_HANDLER、ZEND_INCLUDE_OR_EVAL_SPEC_CV_HANDLERに以下のようなコードが追加されています。

+   if (Z_LVAL(opline->op2.u.constant) !<h3>ZEND_EVAL && strlen(Z_STRVAL_P(inc_filename)) !</h3> Z_STRLEN_P(inc_filename)) {
+       if (Z_LVAL(opline->op2.u.constant)==ZEND_INCLUDE_ONCE ||
+           Z_LVAL(opline->op2.u.constant)==ZEND_INCLUDE) {
+           zend_message_dispatcher(ZMSG_FAILED_INCLUDE_FOPEN, Z_STRVAL_P(inc_filename) TSRMLS_CC);
+       } else {
+           zend_message_dispatcher(ZMSG_FAILED_REQUIRE_FOPEN, Z_STRVAL_P(inc_filename) TSRMLS_CC);
+       }
+       goto done;
+   }

PHPは言語エンジンであるZendエンジンとPHP本体は分離できるようになっています。このため、include/require文がfopenラッパーを使ってファイルにアクセスしていても、ZendエンジンをPHPと一緒に使わない場合でもNULL文字を含むパス名を無効にするよう上記の修正が必要となっています。

PHP本体とNULL文字

PHPではファイルやネットワーク接続などのストリーム型のリソースはPHPストリームという仕組みを介して利用するようになっています。PHP本体側のパッチはfopen_wrapper.cのphp_resolve_path関数に適用されています。以下がNULL文字を無効化するために追加された行です。

    if (strlen(filename) != filename_length) {
        return NULL;
    }

filename_lengthにはPHPストリームのfopen_wrapperに渡されたデータの長さ、filenameには文字列が保存されています。もしfilenameの途中にNULL文字が含まれている場合、C言語のstrlen関数で文字列の長さをカウントすると、次のチェック

  • strlen(filename) != filename_length

は「真」と評価されNULL(失敗)が返されます。

PHP本体側のNULL文字無効化は一箇所チェックするだけですべてのストリーム型のデータのファイル名をチェックできるようになります。PHP 5.3.4以降のPHPであれば

  • readfile($_GET['image_name') . '.jpg');

などという危険なコードが存在しても、

  • http://example.com/image.php?image_name=/etc/passwd%00

などの任意ファイルの読み取りはできなくなります。

非常に単純なパッチなのでバックポートしたい所ですが、PHP 5.3より以前のPHPではfopenラッパーのAPIが異なり、API仕様を変更しなければ同様のパッチは適用できません。API仕様をパッチで変更することは好ましくないのでこのパッチのバックポートは見送りました。

ZendエンジンとNULL文字

Zendエンジンでファイルを扱う関数はinclude/include_once/require/require_once関数です。既に差分で紹介したようにZendエンジンにもNULL文字を無効化するパッチが適用されていますが、PHP的にはこのパッチはあってもあまり意味がないパッチです。include文などに渡されるファイル名はPHPと組み合わせて使っている環境ではPHPのfopenラッパーが使用されます。つまり、PHPと組み合わせて使っているのであればZendエンジンにはパッチは必要ありません。

zend.cの中ではfopen_functionが指定されなかった場合、zend_fopen_wrappperが利用されるようになっています。

zend.c
    zend_fopen = utility_functions->fopen_function;
    if (!zend_fopen) {
        zend_fopen = zend_fopen_wrapper;
    }

PHPと一緒にZendエンジンを利用している場合はzend_fopen_wrapperが利用されることはありません。筆者の知る限りではPHP以外でZendエンジンを利用しているシステムは知りませんが、Zendエンジンをほかの環境で利用する場合はZendエンジンへのパッチが有効になります。

PHP 5.3.4のrequire/include文では期待通りNULL文字が含まれるパス名ではエラーが発生しました。しかし、PHP 5.3.6ではバグのため、エラーが発生せず、古いPHPと同じ動作になってしまっていました。このバグは次のリリースでは修正されています。

何故このようなバグが入ってしまったか不思議に感じる方も居ると思います。NULL文字チェックを行うコードはZend/zend_vm_execute.hに記述されているのですが、このヘッダは自動生成されるようになっています。最初に実装した開発者がこのことを知らなかったか、生成元のファイルの修正を忘れていたため、普通なら考えられないようなバグが混入してしまいました。

PHP 5.2以前のPHPへの対応

パスにNULLが含まれている場合に無効とするパッチは、セキュリティ上非常に有用な仕様変更ですが、PHP 5.2以前のPHPのサポートは終了しているためバックポートされることはありません。仕様変更であるためサポート中であっても古いバージョンのPHPにはバックポートされなかったと思われます。

NULL文字を含むパスを無効にするパッチはPHP本体とZendエンジン用の2種類があり、PHP本体側のパッチに対応するとAPIの変更が伴うので互換性が求められるパッチとしてふさわしくありません。readfile, fopenなど関数ごとにパッチを作成することも可能ですが、全体に適用されるものではないので綺麗な実装とは言えません。ZendエンジンへのパッチであればPHP本体やZendエンジンのAPIや動作を変えずに済みますが、古いZendエンジンは仕様が異なるのでそのままでは適用できず、パッチとしては作り直しと同じになります。

現在、SRA OSS社のPHPセキュリティサポートサービスではこのセキュリティ強化パッチへの対応をしていません。しかし、NULL文字を含むパス名の無効化はPHPアプリの安全性向上に効果的であるため近い将来対応する予定です。

まとめ

PHP 5.3.4より古いPHPを利用している場合、ファイル関連関数はNULL文字の挿入攻撃に脆弱です。しかし、パスを構成する変数にNULL文字が含まれていないかチェックしたり、ファイル関連関数のラッパーを書くことは容易です。

$pathにNULL文字が含まれているいるかチェックする例
if (strstr($path, "\0")) {
  die('Invalid path');
}

文字のチェックに正規表現を利用することも可能ですが、簡単な文字列関数の組み合わせでチェックできる場合は正規表現よりも文字列関数を利用するほうがよいでしょう。

NULL文字を利用した攻撃はファイル関数とinclude/require文のみではありません。入力データのバリデーションで不要なヌル文字を検出することは新しいPHPであっても必要です。

おすすめ記事

記事・ニュース一覧