the Month of PHP Security

結構、前になりますが、2010/05/01 から 1ヶ月間、http://php-security.org/ で the Month of PHP Security として、PHP とその周辺のセキュリティ問題が報告がされていました。
そのほとんどの問題について、実証コードも掲載されています。その中で、PHP 自体のセキュリティ問題について簡単にまとめてみました。

先日、PHP 5.3.3RC1 および、PHP 5.2.14RC1 が http://qa.php.net/ でテスト用に公開されました。このため、近日中に公開される PHP 5.3.3 と PHP 5.2.14 でほとんどの問題は修正されるはずです。

MOPS-2010-001: PHP hash_update_file() Already Freed Resource Access Vulnerability

2年前の the Month of PHP Bugs で報告されていましたが、まだ修正されていない問題です。
hash_update_file() の第2引数にユーザが定義した不正なエラーハンドラや不正なストリームハンドラを渡すと、PHP 内部のハッシュポインタテーブルを書き換えが可能です。最悪の場合、任意のコードが実行されてしまう可能性があります。

MOPS-2010-003: PHP dechunk Filter Signed Comparison Vulnerability

PHP 5.3.0 で追加されたフィルタの dechunk を使用して不正な文字列をデコードすると、PHP がクラッシュする問題です。dechunk フィルタはチャンク形式でエンコードされた文字列をデコードするフィルタです。コンテンツ長に負の値を指定すると、メモリ破壊が発生します。この問題が悪用されると、DoS 攻撃などが可能になります。
最新のスナップショットでは、既に修正されています。

MOPS-2010-009: PHP shm_put_var() Already Freed Resource Access Vulnerability

shm_put_var() の第3引数に __sleep() が定義されたオブジェクトを渡すと、shm_put_var() の内部で __sleep() が使用されるため、共有メモリを開放することが可能です。その後も、開放したメモリにアクセスすることが可能なため、任意のメモリアドレスに書き込みを許してしまう可能性があります。
最新のスナップショットでは、既に修正されています。

MOPS-2010-012: PHP sqlite_single_query() Uninitialized Memory Usage Vulnerability

MOPS-2010-014: PHP ZEND_BW_XOR Opcode Interruption Address Information Leak Vulnerability

MOPS-2010-015: PHP ZEND_SL Opcode Interruption Address Information Leak Vulnerability

MOPS-2010-016: PHP ZEND_SR Opcode Interruption Address Information Leak Vulnerability

ビット演算子 の '^'(排他的論理和)、'>>'(右シフト)、'<<'(左シフト) の処理において、エラー発生時にユーザのエラーハンドラを割り込ませると、メモリアドレス情報を返してしまう問題があります。
最新のスナップショットでは、既に修正されています。

MOPS-2010-021: PHP fnmatch() Stack Exhaustion Vulnerability

fnmatch() の第1引数に、長い文字列を入力すると、スタックを使い果たして PHP がクラッシュします。この問題が悪用されると、DoS 攻撃などが可能になります。
最新のスナップショットでは、既に修正されています。

MOPS-2010-022: PHP Stream Context Use After Free on Request Shutdown Vulnerability

PHP がリクエスト終了時に、既に開放されたストリームコンテキスト構造にアクセスすることにより、PHP がクラッシュする問題です。この問題は、任意のコード実行が可能かどうかは不明とのことですが、DoS 攻撃には使用される可能性があるそうです。
最新のスナップショットでは、既に修正されています。

MOPS-2010-024: PHP phar_stream_flush Format String Vulnerability

MOPS-2010-025: PHP phar_wrapper_open_dir Format String Vulnerability

MOPS-2010-026: PHP phar_wrapper_unlink Format String Vulnerability

MOPS-2010-027: PHP phar_parse_url Format String Vulnerabilities

MOPS-2010-028: PHP phar_wrapper_open_url Format String Vulnerabilities

phar 拡張モジュールのいくつかの関数において、エラー処理でフォーマット文字列脆弱性があります。このため、phar:// で細工した文字列を渡すと、メモリの取得や任意のコードを実行される可能性があります。
最新のスナップショットでは、既に修正されています。

MOPS-2010-049: PHP parse_str() Interruption Memory Corruption Vulnerability

parse_str() が処理する文字列に巨大なネスト配列となる文字列を与えると、メモリ破壊が発生します。最悪の場合は、任意のコードが実行される可能性があります。
最新のスナップショットでは、既に修正されています。

MOPS-2010-056: PHP php_mysqlnd_ok_read() Information Leak Vulnerability

MOPS-2010-057: PHP php_mysqlnd_rset_header_read() Buffer Overflow Vulnerability

MOPS-2010-058: PHP php_mysqlnd_read_error_from_line() Buffer Overflow Vulnerability

MOPS-2010-060: PHP Session Serializer Session Data Injection Vulnerability

PHP 標準のセッションデータのシリアライズ処理に不備があるため、条件によっては、外部から任意のセッションデータを挿入できるという問題です。セッションのキーおよび、値ユーザからの入力を使用している場合や、$_SESSION を array_merge() で上書きしている場合は危険です。
最新のスナップショットでは、既に修正されています。

Information Leak Vulnerability

多くの関数で Call time pass by reference 機能(関数の呼び出し元で変数を参照渡しする機能)を使用すると、特定の条件でメモリ情報をリークしてしまう脆弱性が報告されています。
最新のスナップショットでは、既に修正さています。

PHP 5.3.2 での修正点や機能追加について

先日、PHP 5.3.2 が公開されました。セキュリティ問題を含む多くの修正や機能追加が行われています。
PHP 5.2.12 や PHP 5.2.13 で修正され、PHP 5.3.1 に含まれていないセキュリティ問題の修正も含まれていますので、特に PHP 5.3.0 や PHP 5.3.1 を使っている場合は、アップデートした方が良いと思います。

以下に、今回のアップデートで、気になった部分についてメモしておきます。

文字エンコーディング関連の修正

PHP 5.3.2 の ChangeLog などには書かれていないような気がしますが、以前、以下に書いた問題の修正が行われています。

PHP 5.3.0 や PHP 5.3.1 では、これらの修正は行われていませんので、PHP 5.3.2 にアップデートした方が安全な場合があるかもしれません。

ReflectionMethod::setAccessible が実装

以前の日記に、ReflectionMethod::setAccessible があると、プライベートメソッドのテストをする時に簡単という主旨のことを書いたのですが、PHP 5.3.2 で実装されていました。

実行例は以下の通りです。

<?php

class TestClass
{
	private function testMethod( $args )
	{
		echo htmlspecialchars( 'Private Method - Args:(' . implode( ",", $args ). ")\n",
		   	ENT_QUOTES, 'UTF-8' );
   	}
}

$obj    = new TestClass();
$method = new ReflectionMethod( 'TestClass', 'testMethod' );
try {
	// setAccessible( TRUE ) を実行せずにプライベートメソッドを呼び出す
	echo "1: ";
	$method->invoke( $obj, array( 1, 2, 3 ) );
}
catch ( ReflectionException $ex ) {
	echo htmlspecialchars( $ex->getMessage(), ENT_QUOTES, 'UTF-8' ) . "\n";
}

try {
	// setAccessible( TRUE ) を実行してプライベートメソッドを呼び出す
	echo "2: ";
	$method->setAccessible( TRUE );
	$method->invoke( $obj, array( 4, 5, 6 ) );
}
catch ( ReflectionException $ex ) {
	echo htmlspecialchars( $ex->getMessage(), ENT_QUOTES, 'UTF-8' ) . "\n";;
}

try {
	// setAccessible( FALSE ) を実行してプライベートメソッドを呼び出す
	echo "3: ";
	$method->setAccessible( FALSE );
	$method->invoke( $obj, array( 7, 8, 9 ) );
}
catch ( ReflectionException $ex ) {
	echo htmlspecialchars( $ex->getMessage(), ENT_QUOTES, 'UTF-8' ) . "\n";;
}

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

1: Trying to invoke private method TestClass::testMethod() from scope ReflectionMethod
2: Private Method - Args:(4,5,6)
3: Trying to invoke private method TestClass::testMethod() from scope ReflectionMethod

crypt() で SHA-256 と SHA-512 のサポート

crypt() で $5$ や $6$ から始まる salt を指定することで、SHA-256 や SHA-512 が使用できるようになりました。

<?php
echo 'SHA-256' . crypt( 'test', '$5$rounds=5000$salt$' ) . "\n";
echo 'SHA-512' . crypt( 'test', '$6$rounds=5000$salt$' ) . "\n";

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

SHA-256: $5$rounds=5000$salt$lNwM/2EW94.5e484ZZwetUClB7.Z/Z3buPmQvXdPEj4
SHA-512: $6$rounds=5000$salt$xdLuw21n.5WciQUUpHTTPfR6QwS..Z1Q/4xGfiyYa51WSQktzSXYXSk2zBp.Is5r9WiXrGqRmHpEG0iG0HaSk.

その他いくつかの関数追加

標準の関数として、以下が追加されました。今までのバージョンでは取得できなかった情報が取得できるようになっています。特にデバッグの際に役立つかもしれません。

実行例としては以下の通りです。

<?php
chdir( '/tmp' );
var_dump( realpath_cache_get() );
echo "--\n";
var_dump( realpath_cache_size() );
echo "--\n";
var_dump( stream_resolve_include_path( 'PEAR.php' ) );
array(2) {
  ["/tmp/test.php"]=>
  array(4) {
    ["key"]=>
    int(-1263812340)
    ["is_dir"]=>
    bool(false)
    ["realpath"]=>
    string(13) "/tmp/test.php"
    ["expires"]=>
    int(1267965765)
  }
  ["/tmp"]=>
  array(4) {
    ["key"]=>
    int(-2034802845)
    ["is_dir"]=>
    bool(true)
    ["realpath"]=>
    string(4) "/tmp"
    ["expires"]=>
    int(1267965765)
  }
}
--
int(83)
--
string(27) "/usr/local/lib/php/PEAR.php"

PHP 5.3.0 の日付処理クラスと関数の追加・変更について

PHP 5.3.0 が公開されたのは結構前ですが、日付関係の処理について、PHP 5.3.0 でクラスや関数の追加・変更がありましたので、気になった部分だけ調べてみました。

新しく追加されたクラスとメソッド

PHP 5.3.0 では、以下のクラスが追加されました。

DateTime クラスには、以下のメソッドが追加されました。

詳細は、PHP マニュアルの PHP 5.2.x から PHP 5.3.x への移行で確認できます。追加されたクラスは、主に、追加されたメソッド(DateTime::add(), DateTime::diff(), DateTime::sub())の引数や返り値として使用されます。

使用例としては、以下の通りです。

<?php
// 1年と3日後を求める
$datetime = new DateTime( '2010-01-11' );
$datetime->add( new DateInterval( 'P1Y3D' ) ); // 1年と3日
echo "1. " . $datetime->format( 'Y-m-d' ) . "\n";

// 500日前を求める
$datetime = new DateTime( '2010-01-11' );
$datetime->sub( new DateInterval( 'P500D' ) ); // 500日
echo "2. " . $datetime->format( 'Y-m-d' ) . "\n";

// 2010-01-11 から 2014-01-11 までの日数を求める
$datetime1 = new DateTime( '2010-01-11' );
$datetime2 = new DateTime( '2014-01-11' );
echo "3. " . $datetime1->diff( $datetime2 )->format( '%a' ) . "\n";;

// 2010-01-01 から 2010-01-31 までを 1週ごとにループ
$start_date = new DateTime( '2010-01-01' );
$end_date   = new DateTime( '2010-01-31' );
$interval   = new DateInterval( 'P7D' ); // 7日間隔
foreach ( new DatePeriod( $start_date, $interval, $end_date ) as $d ) {
	echo "4. " . $d->format( "Y-m-d\n" );
}

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

1. 2011-01-14
2. 2008-08-29
3. 1461
4. 2010-01-01
4. 2010-01-08
4. 2010-01-15
4. 2010-01-22
4. 2010-01-29

DatePeriod を使用すると、一定間隔で日付や時間を進めることができますので、カレンダーやスケジューラのようなアプリケーションの作成には便利かもしれません。

次月を取得する方法

Obtaining the next month in PHP によると、PHP 5.3.0 以降では、"first day of next month" という書き方ができるようになったそうです。
strtotime() や、DateTime クラスで、次月を取得する場合、最初に "next month" を使用することを思い付きますが、1/31 などを指定すると、正しく次の月にならない場合があります。

<?php
$d = new DateTime( '2010-01-31' );
$d->modify( 'next month' );
echo $d->format( 'F' );

結果は、以下のように "March" となります。

March

PHP 5.3.0 からは、"first day of next month" という書き方で、"February" が取得できます。

<?php
$d = new DateTime( '2010-01-31' );
$d->modify( 'first day of next month' );
echo $d->format( 'F' );

PHP 5.3.0 以降では、以下のように "February" が表示されます(PHP 5.2.x では、"March" が表示)。

February

DateTime::modify() が変更後の DateTime を返すようになった

PHP 5.3.0 からは、以下のように DateTime::modify() の後ろにメソッドを続けて書くことができるようになりました(参考: DateTime::modify)。

<?php
$datetime = new DateTime( '2010-01-11' );
echo $datetime->modify( 'last day' )->format( 'Y-m-d' ) . "\n";

// 以下のような書き方も可能です
echo date_create( '2010-01-11' )->modify( 'last day' )->format( 'Y-m-d' ) . "\n";

結果は以下のようになります(PHP 5.2.x ではエラーになります)。

2010-01-31
2010-01-31

指定した年月の第[1-5][日-土]曜日の日付を求める関数を書き直してみる

以前にも、指定した年月の第[1-5][日-土]曜日の日付を求める関数例を紹介したことがあるのですが、今回、strtotime() や DateTime::modify() メソッドで、使用可能なキーワードを調べてみたところ、もう少し簡単な方法を思い付いたので、書いておきたいと思います。
strtotime() を使用した場合は以下のように書くことができます。こちらは、PHP 4.4.9 でも動作することを確認しました。ただし、strtotime() には 2038-01-19 以降の日付は正しく取得できないなどの制限がある場合があります(PHP マニュアル strtotime の注意を参照)。

<?php
/**
 * 指定した年月の第[1-5][日-土]曜日の日付を求める
 * strtotime() 使用版
 */
function calc_day( $year, $month, $week_of_month, $day_of_the_week )
{
	$year_month = sprintf( '%04d-%02d', (int)$year, (int)$month );
	$time = strtotime( (int)$week_of_month . ' '  . $day_of_the_week, strtotime( $year_month ) );

	if ( $time < 0 || $time === FALSE || $year_month !== date( 'Y-m', $time ) ) {
		return FALSE;
	}
	$result = (int)date( "j", $time );
	if ( ! checkdate( $month, $result, $year ) ) {
		return FALSE;
	}
	return $result;
}

DateTime クラスを使用すると、以下のようになります。こちらの場合は、strtotime() のような制限がありません。PHP 5.2.0 以降を使用している場合は、DateTime クラスを使用した方が良いと思います。

<?php
/**
 * 指定した年月の第[1-5][日-土]曜日の日付を求める
 * DateTime クラス使用版
 */
function calc_day( $year, $month, $week_of_month, $day_of_the_week )
{
	$year_month = sprintf( '%04d-%02d', (int)$year, (int)$month );
	$datetime = new DateTime( $year_month );
	$datetime->modify( (int)$week_of_month . ' ' . $day_of_the_week );

	if ( $year_month !== $datetime->format( 'Y-m' ) ) {
		return FALSE;
	}
	$result = (int)$datetime->format( "j" );
	if ( ! checkdate( $month, $result, $year ) ) {
		return FALSE;
	}
	return $result;
}

使用方法は以下の通りです。

<?php
// 2010年1月の第1日曜日から第5日曜日までを求める
echo calc_day( 2010, 1, 1, 'sun' ) . "\n";
echo calc_day( 2010, 1, 2, 'sun' ) . "\n";
echo calc_day( 2010, 1, 3, 'sun' ) . "\n";
echo calc_day( 2010, 1, 4, 'sun' ) . "\n";
echo calc_day( 2010, 1, 5, 'sun' ) . "\n";

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

3
10
17
24
31

strtotime() や DateTime::modify() で使用可能なキーワード

strtotime() や DateTime::modify() では、英文形式の日付を解釈できることになっていますが、PHP マニュアルには、正式にどのようなキーワードが使用可能なのかが載っていません。PHP 5.3.x のソースには ext/date/lib/parse_date.re に strtotime() や DateTime::modify() で使用可能なキーワードが定義されています。一覧のキーワードと後ろの数値を見るだけで、ある程度、使用方法を想像できるのではないでしょうか。

static timelib_relunit const timelib_relunit_lookup[] = {
	{ "sec",         TIMELIB_SECOND,  1 },
	{ "secs",        TIMELIB_SECOND,  1 },
	{ "second",      TIMELIB_SECOND,  1 },
	{ "seconds",     TIMELIB_SECOND,  1 },
	{ "min",         TIMELIB_MINUTE,  1 },
	{ "mins",        TIMELIB_MINUTE,  1 },
	{ "minute",      TIMELIB_MINUTE,  1 },
	{ "minutes",     TIMELIB_MINUTE,  1 },
	{ "hour",        TIMELIB_HOUR,    1 },
	{ "hours",       TIMELIB_HOUR,    1 },
	{ "day",         TIMELIB_DAY,     1 },
	{ "days",        TIMELIB_DAY,     1 },
	{ "week",        TIMELIB_DAY,     7 },
	{ "weeks",       TIMELIB_DAY,     7 },
	{ "fortnight",   TIMELIB_DAY,    14 },
	{ "fortnights",  TIMELIB_DAY,    14 },
	{ "forthnight",  TIMELIB_DAY,    14 },
	{ "forthnights", TIMELIB_DAY,    14 },
	{ "month",       TIMELIB_MONTH,   1 },
	{ "months",      TIMELIB_MONTH,   1 },
	{ "year",        TIMELIB_YEAR,    1 },
	{ "years",       TIMELIB_YEAR,    1 },

	{ "monday",      TIMELIB_WEEKDAY, 1 },
	{ "mon",         TIMELIB_WEEKDAY, 1 },
	{ "tuesday",     TIMELIB_WEEKDAY, 2 },
	{ "tue",         TIMELIB_WEEKDAY, 2 },
	{ "wednesday",   TIMELIB_WEEKDAY, 3 },
	{ "wed",         TIMELIB_WEEKDAY, 3 },
	{ "thursday",    TIMELIB_WEEKDAY, 4 },
	{ "thu",         TIMELIB_WEEKDAY, 4 },
	{ "friday",      TIMELIB_WEEKDAY, 5 },
	{ "fri",         TIMELIB_WEEKDAY, 5 },
	{ "saturday",    TIMELIB_WEEKDAY, 6 },
	{ "sat",         TIMELIB_WEEKDAY, 6 },
	{ "sunday",      TIMELIB_WEEKDAY, 0 },
	{ "sun",         TIMELIB_WEEKDAY, 0 },

	{ "weekday",     TIMELIB_SPECIAL, TIMELIB_SPECIAL_WEEKDAY },
	{ "weekdays",    TIMELIB_SPECIAL, TIMELIB_SPECIAL_WEEKDAY },
	{ NULL,          0,          0 }
};

/* The relative text table. */
static timelib_lookup_table const timelib_reltext_lookup[] = {
	{ "first",    0,  1 },
	{ "next",     0,  1 },
	{ "second",   0,  2 },
	{ "third",    0,  3 },
	{ "fourth",   0,  4 },
	{ "fifth",    0,  5 },
	{ "sixth",    0,  6 },
	{ "seventh",  0,  7 },
	{ "eight",    0,  8 },
	{ "eighth",   0,  8 },
	{ "ninth",    0,  9 },
	{ "tenth",    0, 10 },
	{ "eleventh", 0, 11 },
	{ "twelfth",  0, 12 },
	{ "last",     0, -1 },
	{ "previous", 0, -1 },
	{ "this",     1,  0 },
	{ NULL,       1,  0 }
};

/* The month table. */
static timelib_lookup_table const timelib_month_lookup[] = {
	{ "jan",  0,  1 },
	{ "feb",  0,  2 },
	{ "mar",  0,  3 },
	{ "apr",  0,  4 },
	{ "may",  0,  5 },
	{ "jun",  0,  6 },
	{ "jul",  0,  7 },
	{ "aug",  0,  8 },
	{ "sep",  0,  9 },
	{ "sept", 0,  9 },
	{ "oct",  0, 10 },
	{ "nov",  0, 11 },
	{ "dec",  0, 12 },
	{ "i",    0,  1 },
	{ "ii",   0,  2 },
	{ "iii",  0,  3 },
	{ "iv",   0,  4 },
	{ "v",    0,  5 },
	{ "vi",   0,  6 },
	{ "vii",  0,  7 },
	{ "viii", 0,  8 },
	{ "ix",   0,  9 },
	{ "x",    0, 10 },
	{ "xi",   0, 11 },
	{ "xii",  0, 12 },

	{ "january",   0,  1 },
	{ "february",  0,  2 },
	{ "march",     0,  3 },
	{ "april",     0,  4 },
	{ "may",       0,  5 },
	{ "june",      0,  6 },
	{ "july",      0,  7 },
	{ "august",    0,  8 },
	{ "september", 0,  9 },
	{ "october",   0, 10 },
	{ "november",  0, 11 },
	{ "december",  0, 12 },
	{  NULL,       0,  0 }
};

文字エンコーディング判定スクリプト

最近、忙しかったのですが、久しぶりに少し時間があったので、随分前に書いた文字エンコーディング判定スクリプトを見直してみました。とりあえず、出来上がったものを投稿しておきます。
何か適当な日本語の文字列を与えると、JIS/eucJP-win/SJIS-win/UTF-8 からそれなりに妥当な文字エンコーディングを返します。短い文字列で、mb_detect_encoding() だけでは判定に失敗する場合に役立つかもしれません。PHP 5.2.12 での動作を確認しています。

<?php
/**
 * 日本語文字列の文字エンコーディング判定(ASCII/JIS/eucJP-win/SJIS-win/UTF-8)
 */
function detect_encoding_ja( $str )
{
  $enc = mb_detect_encoding( $str, 'ASCII,JIS,eucJP-win,SJIS-win,UTF-8', TRUE );

  switch ( $enc ) {
  case FALSE    :
  case 'ASCII'  :
  case 'JIS'    :
  case 'UTF-16' :
  case 'UTF-8'  : break;
  case 'eucJP-win' :
    // ここで eucJP-win を検出した場合、eucJP-win として判定
    if ( mb_detect_encoding( $str, 'SJIS-win,UTF-8,eucJP-win', TRUE ) === 'eucJP-win' ) {
      break;
    }
    $_hint = "\xbf\xfd" . $str; // "\xbf\xfd" : EUC-JP "雀"

    // EUC-JP -> UTF-8 変換時にマッピングが変更される文字を削除( ≒ ≡ ∫ など)
    mb_regex_encoding( 'eucJP-win' );
    $_hint = mb_ereg_replace(
      "\xad[\xe2\xf5\xf6\xf7\xfa\xfb\xfc\xf0\xf1\xf2\xf5\xf6\xf7\xfa\xfb\xfc]|" .
      "\x8f\xf3[\xfd\xfe]|\x8f\xf4[\xa1-\xa8\xab\xac\xad]|\x8f\xa2\xf1",
      '', $_hint );

    $_tmp  = mb_convert_encoding( $_hint, 'UTF-8', 'eucJP-win' );
    $_tmp2 = mb_convert_encoding( $_tmp,  'eucJP-win', 'UTF-8' );
    if ( $_tmp2 === $_hint ) {
      // 例外処理( EUC-JP 以外と認識する範囲 )
      if (
        // SJIS と重なる範囲(2バイト|3バイト|iモード絵文字|1バイト文字)
        ! preg_match( '/^(?:'
        . '(?:[\x8e\xe0-\xe9][\x80-\xfc])+|'
        . '(?:\xea[\x80-\xa4])+|'
        . '(?:\x8f[\xb0-\xef][\xe0-\xef][\x40-\x7f])+|'
        . '(?:\xf8[\x9f-\xfc])+|'
        . '(?:\xf9[\x40-\x49\x50-\x52\x55-\x57\x5b-\x5e\x72-\x7e\x80-\xb0\xb1-\xfc])+|'
        . '[\x00-\x7e]+'
        . ')+$/', $str ) &&

        // UTF-8 と重なる範囲(全角英数字・記号|漢字|1バイト文字)
        ! preg_match( '/^(?:'
        . '(?:\xef[\xbc-\xbd][\x80-\xbf])+|(?:\xef\xbe[\x80-\x9f])+|(?:\xef\xbf[\xa0-\xa5])+|'
        . '(?:[\xe4-\xe9][\x8e-\x8f\xa1-\xbf][\x8f\xa0-\xef])+|'
        . '[\x00-\x7e]+'
        . ')+$/', $str )
      ) {
        // 条件式の範囲に入らなかった場合は、eucJP-win として検出
        break;
      }
      // 例外処理2(一部の頻度の多そうな熟語を eucJP-win として判定)
      // (狡猾|珈琲|琥珀|瑪瑙|碼碯|絨緞|耄碌|膃肭臍|薔薇|蜥蜴|蝌蚪)
      if ( preg_match( '/^(?:'
        . '\xe0\xc4\xe0\xd1|\xe0\xdd\xe0\xea|\xe0\xe8\xe0\xe1|\xe0\xf5\xe0\xef|'
        . '\xe2\xfb\xe2\xf5|\xe5\xb0\xe5\xcb|\xe6\xce\xe2\xf1|\xe9\xac\xe9\xaf|'
        . '\xe9\xf2\xe9\xee|\xe9\xf8\xe9\xd1|\xe7\xac\xe6\xed\xe7\xc1|'
        . '[\x00-\x7e]+'
        . ')+$/', $str )
      ) {
        break;
      }
    }

  default :
    // ここで SJIS-win と判断された場合は、文字コードは SJIS-win として判定
    $enc = mb_detect_encoding( $str, 'UTF-8,SJIS-win', TRUE );
    if ( $enc === 'SJIS-win' ) {
      break;
    }
    $enc = 'SJIS-win';

    // UTF-8 の記号と日本語の範囲の場合は UTF-8 として検出(記号|全角英数字・記号|漢字|1バイト文字)
    if ( preg_match( '/^(?:'
      . '(?:[\xc2-\xd4][\x80-\xbf])+|'
      . '(?:\xef[\xa4-\xab][\x80-\xbf])+|'
      . '(?:\xef[\xbc-\xbd][\x80-\xbf])+|'
      . '(?:\xef\xbe[\x80-\x9f])+|'
      . '(?:\xef\xbf[\xa0-\xa5])+|'
      . '(?:[\xe2-\xe9][\x80-\xbf][\x80-\xbf])+|'
      . '[\x09\x0a\x0d\x20-\x7e]+|'
      . ')+$/', $str )
      ) {
      $enc = 'UTF-8';
    }
    // UTF-8 と SJIS 2文字が重なる範囲への対処(SJIS を優先)
    if ( preg_match( '/^(?:[\xe4-\xe9][\x80-\xbf][\x80-\x9f][\x00-\x7f])+/', $str ) ) {
      $enc = 'SJIS-win';
    }
  }
  return $enc;
}

SJIS-win と UTF-8 が重なる部分の判定が難しいので、場合によってはうまく判定できない場合があります。

以下については、以前に書いた時と変わりません。

  • EUC-JP のいわゆる半角カタカナ(\x8e[\xa1-\xfc])や、EUC-JP の一部の文字([\xe0-\xea][\xa1-\xfc])のみで構成される文字列は SJIS-win として判定される
  • UTF-8 の一部の記号(数学記号とギリシア文字の一部)が含まれる文字列が eucJP-win として判定される

PHP 5.2.12 の文字エンコーディング関連の修正点

PHP 5.2.12 がリリースされました。

Release Announcement にも載っていますが、以前、日記に書いた htmlspecialchars() が Shift_JIS の一部の文字を通してしまう問題は、セキュリティ問題として修正されました。

他にもセキュリティ問題の修正がありますので、バージョンアップした方が安全です。PHP 5.2.12 では、htmlspecialchars() の他にも、mbstring 関連でも文字エンコーディング関連の問題が修正されましたので、メモしておきます。

htmlspecialchars()/htmlentities() で文字エンコーディングを指定した場合に、一部の不正な文字が排除されない問題の修正

以前の日記(最新の PHP スナップショットでの htmlspecialchars()/htmlentities() の修正内容について)に書いたように、修正されました。
最近になって、UTF-8サロゲートペアの問題も修正され、PHP 5.2.12 にもこの修正が含まれています。
また、PHP 5.3 系では、PHP 5.3.2 でこの修正が反映される予定です。

mb_convert_encoding() が UTF-16 の BOM 付き Little Endian の文字列を正しく変換できない問題の修正

以前の日記で書いた、UTF-16(BOM 付き Little Endian) を mb_convert_encoding() で変換すると文字列が壊れる問題が修正されました。
PHP 5.2.12 で修正され、PHP 5.3.2 でも反映される予定です。これで、UTF-16 を扱う場合でも、正しく変換できるようになりました。以下は PHP 5.2.12 の実行結果です。

<?php
// テスト(UTF-16: BOM 付き Little Endian の文字列)
$str = "\xFF\xFE\xC6\x30\xB9\x30\xC8\x30";
var_dump( bin2hex( mb_convert_encoding( $str, "UTF-16", "UTF-16" ) ) );
var_dump( mb_convert_encoding( $str, "UTF-8", "UTF-16" ) );
string(12) "30c630b930c8"
string(9) "テスト"

mb_detect_encoding() の第3引数(strict)を有効にすると、文字エンコーディングの判定が厳格になる

mb_detect_encoding() は文字コード判定として使用できるかで書いた問題です。
Patch を PHP-dev メーリングリスト に投稿したところ、取り込んでいただけました。

この修正によって、以下のコードは、TRUE となりますが、

<?php
// Shift_JIS として有効な先行バイトのみで判定
var_dump( "SJIS" === mb_detect_encoding( "\x81", "SJIS" ) );
bool(true)

以下のコードは、FALSE になります(PHP 5.2.12/PHP 5.3.2 以降)。

<?php
var_dump( "SJIS" === mb_detect_encoding( "\x81", "SJIS", TRUE ) );
bool(false)

いわゆる半角カナや種依存文字などをメールで送信する方法

随分前から書こうと思っていて忘れていたのですが、役に立つ人がいるかもしれませんので、書いておきたいと思います。

メールの送信エンコーディングUTF-8 を使用すれば、機種依存文字という問題を気にする必要はほとんどありませんが、まだ一般的には、ISO-2022-JP を使用してメールを送る方が多いと思います。ただ、PHP の mb_send_mail() では、いわゆる半角カタカナや、機種依存文字を送信することはできません。これらの文字は '?' に変換されます。

PHP 5.2.1 からは、レガシーエンコーディングの追加として、ISO-2022-JP-MS というエンコーディングが追加されました。ISO-2022-JP-MS を使用すれば、このエンコーディングでサポートされている文字を送信することができます。ISO-2022-JP-MS については、[PHP-dev 1345] PHP への CP932 系エンコーディングの追加パッチを参照してください。

ISO-2022-JP-MS を使用してメールを送信するには、mb_send_mail() ではなく、mail() を使用し、メールヘッダなどを定義します。例えば、関数化すると以下のようになります。

<?php
function send_mail( $subject, $msg_body, $to_addr, $from_addr )
{
//      言語設定は Japanese、内部文字エンコーディングは UTF-8 を使用していることを想定
//	mb_language( 'Japanese' );
//	mb_internal_encoding( 'UTF-8' );

	$subject      = mb_encode_mimeheader( $subject, 'ISO-2022-JP-MS', 'UTF-8' );
	$mailto       = $to_addr;
	$body         = mb_convert_encoding( $msg_body, 'ISO-2022-JP-MS', 'UTF-8' );
	$add_headers  = "Content-Type: text/html; charset=ISO-2022-JP\n"
	              . 'From:' . mb_encode_mimeheader( $from_addr, 'ISO-2022-JP-MS', 'UTF-8' );

	return mail( $mailto, $subject, $body, $add_headers );
}

実用する場合は、Envelope From の設定や、メールアドレスチェックを行った方が安全です。また、受信側で必ずしもこれらの文字が正しく読めるかどうかは分からないことには注意してください。

最新の PHP スナップショットでの htmlspecialchars()/htmlentities() の修正内容について

前の記事(Shift_JIS では、htmlspecialchars() を使用しても XSS が可能な場合がある)で Shift_JIS ではブラウザによっては XSS が発生する可能性があることを書きました。この問題は、PHP の開発版のスナップショット(snaps.php.net)で修正されたことを確認しましたが、それ以外にも、EUC-JP や UTF-8 について修正が行われました。この件で、行動された方に感謝します。どうもありがとうございました。その後、修正内容について調べましたので、メモしておきます。他の修正点など、何か気付いた方がおられましたら、ぜひ教えてください。

この修正は、PHP 5.3.2/PHP 5.2.12 で反映されることになると思います。実際の修正内容の大半は、以下で行われました。

今後、まだ修正が行われる可能性がありますが、現時点(2009.10.16)で私が確認した変更内容は、以下の通りです。

  1. Shift_JIS
    • \xf0 - \xfc が単独で指定された場合、空文字列を返すようになった
    • Shift_JIS の先行バイト(\x81 - \x9f, \xf0 - \xfc)に続くバイト列が Shift_JIS として不正な場合、空文字列を返すようになった
  2. EUC-JP
    • EUC-JP の先行バイト(\xa1 - \xfe)に続くバイト列が EUC-JP として不正な場合、空文字列を返すようになった
  3. UTF-8

以下は検証コードです。変更されなかった点についても書いておきました。

1. Shift_JIS

(1) \xf0 - \xfc が単独で指定された場合、空文字列を返すようになった
<?php
var_dump( bin2hex( htmlspecialchars( "\xf0", ENT_QUOTES, "Shift_JIS" ) ) );
var_dump( bin2hex( htmlspecialchars( "\xf1", ENT_QUOTES, "Shift_JIS" ) ) );
var_dump( bin2hex( htmlspecialchars( "\xf2", ENT_QUOTES, "Shift_JIS" ) ) );
var_dump( bin2hex( htmlspecialchars( "\xf3", ENT_QUOTES, "Shift_JIS" ) ) );
var_dump( bin2hex( htmlspecialchars( "\xf4", ENT_QUOTES, "Shift_JIS" ) ) );
var_dump( bin2hex( htmlspecialchars( "\xf5", ENT_QUOTES, "Shift_JIS" ) ) );
var_dump( bin2hex( htmlspecialchars( "\xf6", ENT_QUOTES, "Shift_JIS" ) ) );
var_dump( bin2hex( htmlspecialchars( "\xf7", ENT_QUOTES, "Shift_JIS" ) ) );
var_dump( bin2hex( htmlspecialchars( "\xf8", ENT_QUOTES, "Shift_JIS" ) ) );
var_dump( bin2hex( htmlspecialchars( "\xf9", ENT_QUOTES, "Shift_JIS" ) ) );
var_dump( bin2hex( htmlspecialchars( "\xfa", ENT_QUOTES, "Shift_JIS" ) ) );
var_dump( bin2hex( htmlspecialchars( "\xfb", ENT_QUOTES, "Shift_JIS" ) ) );
var_dump( bin2hex( htmlspecialchars( "\xfc", ENT_QUOTES, "Shift_JIS" ) ) );
PHP 5.3.0 / PHP 5.2.11 の結果です。
string(2) "f0"
string(2) "f1"
string(2) "f2"
string(2) "f3"
string(2) "f4"
string(2) "f5"
string(2) "f6"
string(2) "f7"
string(2) "f8"
string(2) "f9"
string(2) "fa"
string(2) "fb"
string(2) "fc"
修正後(最新のスナップショット)の結果です。これらの文字列は出力されないようになりました。
string(0) ""
string(0) ""
string(0) ""
string(0) ""
string(0) ""
string(0) ""
string(0) ""
string(0) ""
string(0) ""
string(0) ""
string(0) ""
string(0) ""
string(0) ""
(2) Shift_JIS の先行バイト(\x81 - \x9f, \xf0 - \xfc)に続く1バイトが Shift_JIS として不正な場合、空文字列を返すようになった
<?php
var_dump( bin2hex( htmlspecialchars( "\x81\x3e", ENT_QUOTES, "Shift_JIS" ) ) );  // \x3e は '>'
var_dump( bin2hex( htmlspecialchars( "\x9f\x3e", ENT_QUOTES, "Shift_JIS" ) ) );  // \x3e は '>'
var_dump( bin2hex( htmlspecialchars( "\xf0\x3e", ENT_QUOTES, "Shift_JIS" ) ) );  // \x3e は '>'
var_dump( bin2hex( htmlspecialchars( "\xfc\x3e", ENT_QUOTES, "Shift_JIS" ) ) );  // \x3e は '>'
PHP 5.3.0 / PHP 5.2.11 の結果です。
string(10) "812667743b"
string(10) "9f2667743b"
string(10) "f02667743b"
string(10) "fc2667743b"
修正後(最新のスナップショット)の結果です。これらの文字は出力されないようになりました。
string(0) ""
string(0) ""
string(0) ""
string(0) ""
(3) \x80, \xa0, \xfd, \xfe, \xff については、そのまま出力される
<?php
var_dump( bin2hex( htmlspecialchars( "\x80", ENT_QUOTES, "Shift_JIS" ) ) );
var_dump( bin2hex( htmlspecialchars( "\xa0", ENT_QUOTES, "Shift_JIS" ) ) );
var_dump( bin2hex( htmlspecialchars( "\xfd", ENT_QUOTES, "Shift_JIS" ) ) );
var_dump( bin2hex( htmlspecialchars( "\xfe", ENT_QUOTES, "Shift_JIS" ) ) );
var_dump( bin2hex( htmlspecialchars( "\xff", ENT_QUOTES, "Shift_JIS" ) ) );
PHP 5.3.0 / PHP 5.2.11 の結果です。
string(2) "80"
string(2) "a0"
string(2) "fd"
string(2) "fe"
string(2) "ff"
修正後(最新のスナップショット)の結果です。これらの文字列については変更ありません。
string(2) "80"
string(2) "a0"
string(2) "fd"
string(2) "fe"
string(2) "ff"

2. EUC-JP

(1) EUC-JP の先行バイト(\x8e, \x8f, \xa1 - \xfe)に続くバイト列が EUC-JP として不正な場合、空文字列を返すようになった
<?php
var_dump( bin2hex( htmlspecialchars( "\x8e\x3e",     ENT_QUOTES, "EUC-JP" ) ) );  // \x3e は '>'
var_dump( bin2hex( htmlspecialchars( "\x8f\x3e\x3c", ENT_QUOTES, "EUC-JP" ) ) );  // \x3e は '>', \x3c は '<'
var_dump( bin2hex( htmlspecialchars( "\xa1\x3e",     ENT_QUOTES, "EUC-JP" ) ) );  // \x3e は '>'
var_dump( bin2hex( htmlspecialchars( "\xfe\x3e",     ENT_QUOTES, "EUC-JP" ) ) );  // \x3e は '>'
PHP 5.3.0 / PHP 5.2.11 の結果です。
string(10) "8e2667743b"
string(18) "8f2667743b266c743b"
string(10) "a12667743b"
string(10) "fe2667743b"
修正後(最新のスナップショット)の結果です。これらの文字列は出力されないようになりました。
string(0) ""
string(0) ""
string(0) ""
string(0) ""
(2) \x80 - \x8d, \x90 - \xa0, \xff については、そのまま出力される
<?php
var_dump( bin2hex( htmlspecialchars( "\x80", ENT_QUOTES, "EUC-JP" ) ) );
var_dump( bin2hex( htmlspecialchars( "\x8d", ENT_QUOTES, "EUC-JP" ) ) );
var_dump( bin2hex( htmlspecialchars( "\x90", ENT_QUOTES, "EUC-JP" ) ) );
var_dump( bin2hex( htmlspecialchars( "\xa0", ENT_QUOTES, "EUC-JP" ) ) );
var_dump( bin2hex( htmlspecialchars( "\xff", ENT_QUOTES, "EUC-JP" ) ) );
PHP 5.3.0 / PHP 5.2.11 の結果です。
string(2) "80"
string(2) "8d"
string(2) "90"
string(2) "a0"
string(2) "ff"
修正後(最新のスナップショット)の結果です。これらの文字列については変更ありません。
string(2) "80"
string(2) "8d"
string(2) "90"
string(2) "a0"
string(2) "ff"

3. UTF-8

(1) 冗長な表現が使用された場合、空文字列を返すようになった
<?php
var_dump( bin2hex( htmlspecialchars( "\x3c",                     ENT_QUOTES, "UTF-8" ) ) ); // U+003c ('<' の冗長でない表現)
var_dump( bin2hex( htmlspecialchars( "\xc0\xbc",                 ENT_QUOTES, "UTF-8" ) ) ); // U+003c ('<' の2バイト表現)
var_dump( bin2hex( htmlspecialchars( "\xe0\x80\xbc",             ENT_QUOTES, "UTF-8" ) ) ); // U+003c ('<' の3バイト表現)
var_dump( bin2hex( htmlspecialchars( "\xf0\x80\x80\xbc",         ENT_QUOTES, "UTF-8" ) ) ); // U+003c ('<' の4バイト表現)
var_dump( bin2hex( htmlspecialchars( "\xf8\x80\x80\x80\xbc",     ENT_QUOTES, "UTF-8" ) ) ); // U+003c ('<' の5バイト表現)
var_dump( bin2hex( htmlspecialchars( "\xfc\x80\x80\x80\x80\xbc", ENT_QUOTES, "UTF-8" ) ) ); // U+003c ('<' の6バイト表現)
PHP 5.3.0 / PHP 5.2.11 の結果です。
string(8) "266c743b"
string(8) "266c743b"
string(8) "266c743b"
string(8) "266c743b"
string(8) "266c743b"
string(8) "266c743b"
修正後(最新のスナップショット)の結果です。冗長な表現の場合は出力されなくなりました。
string(8) "266c743b"
string(0) ""
string(0) ""
string(0) ""
string(0) ""
string(0) ""
(2) UTF-8 の 5, 6 バイト表現が指定された場合、空文字列を返すようになった
<?php
var_dump( bin2hex( htmlspecialchars( "\xf8\xbf\xbf\xbf\xbf",     ENT_QUOTES, "UTF-8" ) ) ); // U+3FFFFFF  (UTF-8 では 5バイトで表現)
var_dump( bin2hex( htmlspecialchars( "\xfc\xbf\xbf\xbf\xbf\xbf", ENT_QUOTES, "UTF-8" ) ) ); // U+7FFFFFFF (UTF-8 では 6バイトで表現)
PHP 5.3.0 / PHP 5.2.11 の結果です。
string(10) "f8bfbfbfbf"
string(12) "fcbfbfbfbfbf"
修正後(最新のスナップショット)の結果です。5バイト、6バイトの表現は出力されなくなりました。
string(0) ""
string(0) ""
(3) U+10000 以降の一部の文字が不正に変換される問題が修正された
徳丸さんが書かれた htmlspecialchars/htmlentitiesはBMP外の文字を正しく扱えない を参照ください。
(4) サロゲートペア(U+D800 - U+DFFF)の領域は、そのまま出力される
(4) サロゲートペア(U+D800 - U+DFFF)の領域を指定すると、空文字列を返すようになった(2009.12.08)
<?php
var_dump( bin2hex( htmlspecialchars( "\xed\xa0\x80", ENT_QUOTES, "UTF-8" ) ) ); // U+D800
var_dump( bin2hex( htmlspecialchars( "\xed\xbf\xbf", ENT_QUOTES, "UTF-8" ) ) ); // U+DFFF
PHP 5.3.0 / PHP 5.2.11 の結果です。
string(6) "eda080"
string(6) "edbfbf"
修正後(2009.12.06 以前のスナップショット)の結果です。これらの文字列についてはそのまま出力されます。
string(6) "eda080"
string(6) "edbfbf"
2009.12.07 以降のスナップショットでは、これらの文字列については出力されなくなりました。
string(0) ""
string(0) ""

2009.12.08 追記
id:moriyoshi さんからサロゲートペアの問題も回避するようになったというコメントをいただきました。2009.12.07 以降のスナップショットを使用すれば、サロゲートペアの領域は出力されなくなります。