mb_send_mail(),mail()で第5引数を設定する際の注意点

以下の徳丸さんの記事を読んで、以前にmb_send_mail()の関連で調べたことがあったのを思い出しましたので、少し書きます。

環境は Unix 系の OS で OS コマンドを使用してメールを送信する場合です。メール送信コマンドは php.ini で sendmail_path を設定します(デフォルト: "sendmail -t -i")。
mb_send_mail関数(mail関数も同様)ですが、第5引数(additional_parameter)にユーザの入力を使用する場合は注意が必要です。mb_send_mail関数の第5引数は、内部でescapeshellcmd(内部関数名:php_escape_shell_cmd)によって引数の文字列全体がエスケープされます。

escapeshellcmd() は、以前に徳丸さんから OS コマンドインジェクションの防止には不適切であると指摘されています。ユーザの入力値を十分にチェックしておかないと、sendmailコマンドに任意のコマンドライン引数を渡されてしまうことになります。

mb_send_mail() の第5引数の使用例として、sendmail コマンドに envelope-from を渡す(-f オプション)を考えます。

<?php
$envelope_from = 'from@example.com ;ls -l';
mb_send_mail( "test1@example.com", "subject", "body", "From: test2@example.com", "-f " . $envelope_from );

上記の場合、「-f from@example.com ;ls -l」が php_escape_shell_cmd のエスケープ対象になり、「;」が「\;」にエスケープされます。sendmailコマンドに渡る引数は「sendmail -t -i -f from@example.com \;ls -l」になりますので、ls コマンドは実行されません。ただし、この時、不正なコマンド引数「;ls」と「-l」が sendmail コマンドに渡ることになります。
つまり、$envelope_from の妥当性をチェックせずに、ユーザからの入力を使用していると、sendmailコマンドに任意の引数を設定可能です。

対処方法

対処方法としては、引数の妥当性を確認するのが良いと思います。上記のようにメールアドレスである場合は、メールアドレスとして正しいかどうかをチェックします。
コマンドライン引数に対して escapeshellarg() によるエスケープをする方法は止めておいた方が良いと思います。
例えば、「&」は、メールアドレスにも使用できる文字ですが、escapeshellarg() でエスケープすると、mb_send_mail() の内部でさらに escapeshellcmd() でエスケープされ、別のメールアドレスになってしまいます。

<?php
$envelope_from = escapeshellarg( 'mail&1@example.com' );
mb_send_mail( "test1@example.com", "subject", "body", "From: test2@example.com", "-f " . $envelope_from );
// コマンドライン: sendmail -t -i -f 'mail\&1@example.com'

検証方法

sendmailコマンドで実際に試すのは大変ですし、実際に渡されているコマンドライン引数を確認するのは難しいため、検証用に以下のような PHP スクリプトを用意すると便利です。

<?php
// input_vars.php
// コマンドライン引数と標準入力の結果表示
echo "* argv :\n";
print_r( $_SERVER['argv'] );

echo "* stdin:\n";
echo preg_replace( '/^/mu', '  ', stream_get_contents( STDIN ) );
<?php
// mail.php
// テスト用スクリプト
$envelope_from = 'mail&1@example.com';
$envelope_from = escapeshellarg( $envelope_from );

echo '* $envelope_from: [' . $envelope_from . "]\n";
mb_send_mail( "test1@example.com", "subject", "body", "From: test2@example.com", "-f " . $envelope_from );

実行する時は、PHPCLI版、CGI版では、引数 -d で php.ini の設定を上書きできますので、以下の様に、sendmail コマンドのパスを先程の PHP スクリプトに設定して実行します。

$ php -d "sendmail_path=php input_vars.php" mail.php 

結果は以下のようになります。

* $envelope_from: [''\''mail&1@example.com'\''']
* argv :
Array
(
    [0] => input.php
    [1] => -f
    [2] => mail\&1@example.com
)
* stdin:
  To: test1@example.com
  Subject: subject
  X-PHP-Originating-Script: 1000:mail.php
  From: test2@example.com
  MIME-Version: 1.0
  Content-Type: text/plain; charset=ISO-2022-JP
  Content-Transfer-Encoding: 7bit
  
  body

マルチバイト正規表現関係のメモ

久しぶりに mbstring モジュール(特にマルチバイト正規表現)のソースコードを眺めていたのですが、いくつか面白い(知らなかった)挙動がありましたでメモしておきます。
確認した PHP のバージョンは 5.4.10 ですが、PHP 5.0 以降であれば、あまり変わりません。

パターン修飾子 m と s の解釈

これまでは Perl 互換の正規表現と同じと思っていたのですが、マルチバイト正規表現のパターン修飾子 m と s は Perl 互換の正規表現(preg)と挙動が違います。
mb_ereg_replace() と preg_replace() で、改行を含む文字列置き換えの結果を比較すると以下のようになります。

$ php -r 'echo mb_ereg_replace( ".", "A", "|\n|", "s" );'
A
A

$ php -r 'echo preg_replace( "/./s", "A", "|\n|" );'
AAA
$ php -r 'echo mb_ereg_replace( ".", "A", "|\n|", "m" );'
AAA

$ php -r 'echo preg_replace( "/./m", "A", "|\n|" );'
A
A

上記の挙動に関して、PHPマニュアルのmb_regex_set_options()に以下の記載があります。


オプション意味
m'.' が改行にマッチする
s'^' -> '\A', '$' -> '\Z'

PHPマニュアルのPCRE(Perl互換の正規表現)のパターン修飾子には、以下のように記載されています。


m (PCRE_MULTILINE)
デフォルトで、PCRE は、検索対象文字列を(実際には複数行からなる 場合でも)単一の行からなるとして処理します。 「行頭」メタ文字 (^) は、対象文字列の最初にしかマッチしません。 一方、「行末」メタ文字 ($) は、文字列の最後、または (D 修飾子が設定されていない場合) 最後にある改行記号の前のみにしかマッチしません。 この動作は Perl と同じです。 この修飾子を設定すると、「行頭」および「行末」メタ文字は 対象文字列において、文字列の最初と最後に加えて、各改行の直前と直後にそれぞれマッチします。 この動作は、Perl の /m 修飾子と同じです。 対象文字列の中に "\n" 文字がない場合や、またはパターンに ^ または $ がない場合は、この修飾子を設定しても意味はありません。
s (PCRE_DOTALL)
この修飾子を設定すると、パターン中のドットメタ文字は 改行を含む全ての文字にマッチします。 これを設定しない場合は、改行にはマッチしません。この修飾子は、Perl の /s 修飾子と同じです。 [^a] のような否定の文字クラスは、この修飾子の設定によらず、常に改行文字にマッチします。
ただ、mb_ereg_replace()では、preg_replace()の m 修飾子と同様に、各行(^ と $ を使用)に対して置き換えを実行したい場合、第4引数のオプションを空にする必要があります。

$ php -r 'echo mb_ereg_replace( "^.*$", "A", "|\n|", "" );'
A
A

mb_ereg_replace() の第1引数には数値が入力可能

mb_ereg_replace() の第1引数(パターン文字列)には数値が指定可能です。
数値を指定すると、1文字の文字列に変換して処理されます(0 → NULLバイト, 65 → A)。

$ php -r 'var_dump( mb_ereg_replace( 0, "_", "A\0Z" ) );'
string(3) "A_Z"

$ php -r 'var_dump( mb_ereg_replace( 65, "_", "A\0Z" ) );'
string(3) "_Z"

第1引数に文字列でない型を入力すると、数値変換された結果が適用されます。例えば、配列を与えると以下のようになります(空の配列を数値変換すると 0、空以外の配列を数値変換すると 1)。

$ php -r 'var_dump( mb_ereg_replace( [], "_", "A\0Z" ) );'
string(3) "A_Z"

$ php -r 'var_dump( mb_ereg_replace( [0], "_", "A\0Z" ) );'
string(3) "AZ"

$ と \Z が改行にマッチした場合の文字列置き換えがおかしい?

mb_ereg_replace() の場合、置き換え対象文字列の最後に改行があり、$ や \Z(行末にマッチ) がマッチする場合、改行まで置き換えてしまいます。

$ php -r 'var_dump( mb_ereg_replace( ".\Z", "B", "A\n" ) );'
string(2) "BB"

$ php -r 'var_dump( mb_ereg_replace( ".$", "B", "A\n" ) );'
string(2) "BB"

preg_replace() では以下のようになるので、上記の挙動はバグな気がします。

$ php -r 'var_dump( preg_replace( "/.\Z/", "B", "A\n" ) );'
string(2) "B
"

$ php -r 'var_dump( preg_replace( "/.$/", "B", "A\n" ) );'
string(2) "B
"

PHP 5.4.1 以降の mbstring の変更点

mb_ereg_replace_callback() の追加(PHP 5.4.1)

PHP 5.4.1 で mb_ereg_replace_callback() が追加されました。正規表現にマッチした文字列に対してコールバック関数による置換ができます。以前から、mb_ereg_replace() のオプションに "e" を設定すると同様のことはできましたが、信頼できない入力に使用するとリモートから PHP スクリプト実行される可能性がありました。
同様の関数として、preg_replace_callback() があります。ただし、置換文字列の文字エンコーディングがシングルバイト(ASCII, ISO-8859-1 など)または、UTF-8(u 修飾子を使用)に限られます。preg_replace_callback() は PHP 4.1.0(Win32 では、PHP 4.2.3)からサポートされています。

mb_substr() と mb_strcut() の第3引数に NULL が指定可能になった(PHP 5.4.8)

PHP 5.4.8 で mb_substr() と mb_strcut() の第3引数($length)に NULL が指定可能になりました。PHP 5.4.7 以前では、mb_substr() と mb_strcut() の第4引数($encoding)に文字エンコーディングを設定したい場合、第3引数を省略できませんでした。
ただし、第4引数を省略した場合、php.ini の mbstring.internal_encoding や mb_internal_encoding() で設定した文字エンコーディングが適用されます。このため、通常は第4引数を指定する必要はありません。

php.ini で mbstring.encoding_translation を有効にすると max_input_vars が有効にならない(PHP 5.4.8以前、PHP 5.3.18以前)

少し前の話題ですが、PHP 5.4.9 と PHP 5.3.18 が公開されました。その中で、mbstring の項目に「mbstring.encoding_translation = On になっていると max_input_vars が有効にならない」問題が修正されています。

また、詳細や確認方法は徳丸さんが以下にまとめておられます。

再現条件

この件は再現条件が分かりにくいので、少し調べてみました。その結果、以下のことが分かりました。

  1. PHP 5.3.9 で max_input_vars が導入された(hashdos 脆弱性対策)
  2. PHP 5.3.9 〜 PHP 5.3.10 では、mbstring.encoding_translation を有効にしても max_input_vars は有効になる
  3. PHP 5.3.11 と PHP 5.4.0 でチェック方法が変更された
  4. PHP 5.3.11 から mbstring.encoding_translation を有効にすると、max_input_vars が有効にならない

PHP 5.3.11 での修正は以下にあります。Log Message には「Improved max_input_vars directive to check nested variables」とあり、PHP 5.3.9 の修正ではまだ不十分だったのかもしれません。

以上から、PHP 5.3.8 以前のバージョンを配布していた Linux ディストリビューションなどでは PHP 5.3.9 での修正が取り込まれたため、mbstring.encoding_translation が有効でも問題なかったと考えられます。
このため、本件の影響は PHP 5.3.11 〜 PHP 5.3.18 および、PHP 5.4.0 〜 PHP 5.4.8 になると思います。

PHP 5.3.11 以降で PHP をバージョンアップしない場合の対処方法

PHP 5.3.11 以降は mbstring.encoding_translation が無効であれば hashdos 脆弱性はありません。その場合、mbstring.encoding_translation は以下のような PHP スクリプトで代用可能です。

<?php
mb_language( "Japanese" );
$encoding = mb_internal_encoding();
$encoding = mb_convert_variables( $encoding, "ASCII,UTF-8,SJIS-win,eucJP-win", $_POST, $_GET, $_COOKIE );

上記のスクリプトが、最初に実行されるようにします。mb_convert_variables() の第2引数は、php.ini の mbstring.http_input で設定している値にします。ただし、auto を設定するのはあまりお勧めしません。理由は以下を参照してください。

最近の mbstring 動向について(PHP 5.4〜)

PHP 5.4 に向けて、久しぶりに PHP の mbstring に対して機能追加と修正がありましたので、メモしておきます。
PHP 5.4.0 の正式リリースまでに「十分なテストが必要」とのことですので、気になる方はテストに参加した方が良いと思います。
変更点は以下の通りです。

  1. 携帯絵文字のサポート
  2. 正しい UTF-8 チェックの強化

詳しくは以下のページを参照してください。

携帯絵文字のサポート

開発中の PHP-5.4.0alpha3 で新たに追加された文字エンコーディングは以下の通りです。

各絵文字の相互変換、Unicode への変換や妥当性チェックが可能になりました。

また、絵文字ではありませんが、以下の文字エンコーディングも追加されています

上記の文字エンコーディングについては後日少し詳しく調査してみたいと思います。

正しい UTF-8 チェックの強化

以下の通り、PHP 5.4 以降では文字列の妥当性チェックが強化されるそうです。


libmbfl 1.3.1 からは、UTF-8の変換や検出時に行われる文字コード範囲検出において整形式であることを確認するチェックを導入します。上記の不正なバイト列は無効な文字として判定され、指定した処理が行われます。libmbfl 1.3.1 は PHP 5.5dev, PHP 5.4beta1 に適用予定です。
libmbflPHP の mbstring 内部で使用しているマルチバイト文字列処理エンジンです。

実際に試してみたところ、結果が以下のように変わりました(PHP 5.3.6 と PHP 5.4.0alpha3 を使用)。
mb_substitute_character() に不正文字列の出力方法を指定しています。

<?php
// 不正文字列を含む文字列
$s = "\x41\xc0\xaf\x41\xf4\x80\x80\x41";

// 不正文字列を"?"(63)に変換(デフォルト)
mb_substitute_character( 63 );
$u = mb_convert_encoding( $s, 'UTF-16BE', 'UTF-8' );
echo format( $u ) ."\n";

// 不正文字列を"U+FFFD"(0xFFFD)に変換
mb_substitute_character( 0xFFFD );
$u = mb_convert_encoding( $s, 'UTF-16BE', 'UTF-8' );
echo format( $u ) ."\n";

// 不正文字列を"BAD+??"(long)に変換
mb_substitute_character( 'long' );
$u = mb_convert_encoding( $s, 'UTF-16BE', 'UTF-8' );
echo format( $u ) ."\n";

// 整形用の関数
function format( $s )
{
    return mb_convert_encoding( $s, 'UTF-8', 'UTF-16BE' ) . '|' .
        preg_replace_callback(
            '/(?:\0B\0A\0D\0\+([\x00-\xff]{4})|[\x00-\xff]{2})/',
            function( $m ) {
                return ( strlen( $m[0] ) > 2 )
                    ? ( 'BAD+' . preg_replace( '/\x00([\x00-\xff])/', '$1', $m[1] ) . ' ' )
                    : ( 'U+'   . strtoupper( bin2hex( $m[0] ) ) . ' '   );
            },
            $s );
}
  • PHP 5.3.6 の結果

以下のように、不正文字列は削除されています。

AAA|U+0041 U+0041 U+0041 
AAA|U+0041 U+0041 U+0041 
AAA|U+0041 U+0041 U+0041 
  • PHP 5.4.0alpha3 の結果

以下のように、不正文字列が表示されるようになりました(見やすさのため、整形しました)。

A??AA          |U+0041 U+003F U+003F U+0041 U+0041 
A・・ AA       |U+0041 U+FFFD U+FFFD U+0041 U+0041 
ABAD+C0BAD+AFAA|U+0041 BAD+C0 BAD+AF U+0041 U+0041 

また、UTF-8 の5バイト、6バイト表現は排除されるようになりました。例えば、以下は5バイト表現や6バイト表現の UTF-8 をチェックするスクリプトです。

<?php
// U+3FFFFFF  (UTF-8 では 5バイトで表現)
var_dump( mb_check_encoding( "\xf8\xbf\xbf\xbf\xbf",     "UTF-8" ) );
// U+7FFFFFFF (UTF-8 では 6バイトで表現)
var_dump( mb_check_encoding( "\xfc\xbf\xbf\xbf\xbf\xbf", "UTF-8" ) );

以下のように、UTF-8 の 5バイト、6バイトの表現は PHP 5.4.0alpha3 では FALSE を返します。

  • PHP 5.3.6 の結果
bool(true)
bool(true)
  • PHP 5.4.0alpha3 の結果
bool(false)
bool(false)

一部の言語構造と関数で引数の値に NULL バイトが含まれていた場合の挙動修正

PHP 5.3.4(最新は PHP 5.3.5) で、引数の値にファイルパスが指定可能な言語構造と関数に NULL バイトが含まれていた場合、処理が失敗するように修正されました。

簡単に調べてみたところ、少なくとも、以下の言語構造と関数が該当するようです。

この修正により、少しだけ NULL バイトを使用した攻撃が成功しにくくなりました。ただ、この修正は脆弱性の原因を根本的に解消するようなものではないため、言語構造・関数の使用前の入力値確認が必要な点は変わりません。

「体系的に学ぶ 安全なWebアプリケーションの作り方」

体系的に学ぶ 安全なWebアプリケーションの作り方 脆弱性が生まれる原理と対策の実践」という書籍が3/1に発売されました。

脆弱性が生まれる原理からその解消方法まで、丁寧に説明されています。タイトル通り、体系的に Web アプリケーションセキュリティについて学ぶには最適の書籍だと思います。これまでに出版された書籍や Web サイトは、攻撃方法とその対策という形で書かれることが多かったように思いますが、この書籍では脆弱性がなぜ生まれるのかに重点を置いて説明されています。脆弱性が生まれる原理を理解することは、小手先の対策ではない根本的な対応が可能になることにつながります。また、この書籍では、Web サイト全体をセキュアに運用するために必要な知識や Web アプリケーションをセキュアにするための開発マネジメントにまで言及されています。
是非、Web アプリケーションの開発や運用に携わる方は読んでいただきたい書籍です。
この書籍には、レビュアーの一人として、参加させていただいたのですが、著者の徳丸さん(id:ockeghem)をはじめ、他のレビュアーの方から多くのことを学ばせていただきました。どうもありがとうございました。



最後に、書籍を読み直している際にサンプルコードで気になった点がありましたので、書いておきたいと思います。
この書籍では、サンプルコードとして、PHP を主に使用しています。サンプルコードの一部に NULL バイトを使用した攻撃がありますが、PHP 5.3.4 以降では攻撃に成功しないと思います。サンプルコードを PHP 5.3.4 以降で動作させる方は注意してください(現在の PHP の最新版は PHP 5.3.5)。詳細は後述します。書籍に付属している VMWare に入っている PHP のバージョンでは攻撃が成功します。
ただ、NULL バイトを使用しなくても成立する攻撃方法が存在する場合があります(例えば、書籍の P.287 の攻撃方法)ので、根本的な対策は必要です。