hnwの日記

mb_ereg_replace関数でe修飾子を使う際の注意点

mb_ereg_replace関数でe修飾子を使ってevalする場合の注意点をまとめてみました。

概要

マニュアルの下記引用部にもあるとおり、mb_ereg_replace関数はe修飾子の指定があっても特にエスケープなどを行いません。

信頼できない入力に対しては、絶対に e 修正子を使用してはいけません。 (preg_replace() と同様、) 自動的なエスケープは行いません。このことを忘れていると、自分の書いたアプリケーションにリモートコード実行の脆弱性を作りこんでしまうことになります。


PHP: mb_ereg_replace - Manual


preg_replaceでe修飾子をつけた場合、後方参照について自動でaddslashes相当のエスケープを行ってくれます(「例えばPHPのpreg_replace関数でe修飾子を避ける」参照)ので、この点において両者は挙動が異なっているわけです。

"\0"、"'"、'\'の3文字に注意

このような挙動のため、preg_replaceでは可能なことでもmb_ereg_replaceにはできないことがあります。以前の記事と同じ例題「空白文字以外が連続する部分文字列の後ろに、その部分文字列の長さを括弧付きの数字で追記する」をmb_ereg_replaceで実装すると次のようになります。

<?php
$str = 'abc 12345';
$str = mb_ereg_replace('(\S+)', "'\\1('.strlen('\\1').')'", $str, 'e');
echo $str, "\n"; // abc(3) 12345(5)


しかし、上のプログラムが"\0"(ヌル文字)、"'"(シングルクォート)、'\'の3文字のいずれかがマッチした場合には期待通りには動きません。エラーになるか、期待と異なる結果になってしまいます。

Parse error: syntax error, unexpected T_ENCAPSED_AND_WHITESPACE in /tmp/hoge.php(3) : mbregex replace on line 1

Fatal error: mb_ereg_replace(): Failed evaluating code: 
' in /tmp/hoge.php on line 3


上の例はヌル文字をマッチさせた場合の例です。これを防ぐには、これらの文字をマッチさせないように工夫するしかありません。たとえば事前にエスケープしておくなどです。

mb_ereg_replace_callback関数を自作してみる

以前の記事では、preg_replace関数に関して、e修飾子を使うかわりにpreg_replace_callback関数を使うことをお勧めしました。今回はmb_ereg_replace関数のe修飾子についても問題となる状況があることを指摘しましたが、mb_ereg_replace関数についてはpreg系のときのような代替の関数が存在しません。


そこで、preg_replace_callback関数のmb_ereg版を自作してみました。

<?php
function mb_ereg_replace_callback($pattern, $callback, $subject, $limit = -1, $option = null) {
  if ($option === null) {
    $option = mb_regex_set_options();
  }
  mb_ereg_search_init($subject, $pattern, $option);
  $unmatched_start_pos = 0;
  // マッチしなかった部分とマッチした部分を交互に格納する配列
  $replacement_substrings = array();
  while ($limit !== 0 && $pos_info = mb_ereg_search_pos()) {
    list($matched_start_pos, $matched_length) = $pos_info;
    // 正規表現にマッチしなかった部分を取得する
    $unmatched_length = $matched_start_pos - $unmatched_start_pos;
    $unmatched = mb_strcut($subject, $unmatched_start_pos, $unmatched_length);
    $replacement_substrings[] = $unmatched;
    // 正規表現マッチした部分について置換用関数を呼び出す
    $matches = mb_ereg_search_getregs();
    $replacement = call_user_func($callback, $matches);
    $replacement_substrings[] = $replacement;
    // 次にマッチングを開始する位置を記録
    $unmatched_start_pos = mb_ereg_search_getpos();
    $limit--; // 0になったらループ脱出
  }
  $unmatched = mb_strcut($subject, $unmatched_start_pos);
  $replacement_substrings[] = $unmatched;
  return implode('', $replacement_substrings);
}


このプログラムを書いていて気づいたことですが、mb_ereg_search_pos関数およびmb_ereg_search_getpos関数はマルチバイトの文字数でなくバイト数を返します。マニュアルにも明記してあるのですが、ちょっと使いにくい気がしますね。おそらく鬼車*1の制約なんでしょう。プログラム中でmb_substr関数でなくmb_strcut関数を使っているのはそのためです。


この関数はpreg_replace_callback関数と全く同じように動作します。

<?php
require_once('mb_ereg_replace_callback.php')
mb_internal_encoding("UTF-8");
mb_regex_encoding("UTF-8");
$str = 'abc 12345 あいう #",; $foo';
$str = mb_ereg_replace_callback('(\S+)', function($m){return $m[1].'('.strlen($m[1]).')';}, $str);
echo $str, "\n"; // abc(3) 12345(5) あいう(9) #",;(4) $foo(4)


一通りこれを作ってから気づいたのですが、id:rskyさんが5年以上前に同じものを作っていました(「mb_ereg_replace_callback() - 讃容日記」)。仕様が少し違いますけど、実装も限りなく一緒です。悲しい…。


まあ、僕の実装を使うかどうかはさておき、このような関数を作れば一件落着というわけです。そもそも内部エンコーディングUTF-8ならpreg_replace_callback関数を使った方がいいと思いますが…。

まとめ

  • mb_ereg_replace関数でe修飾子を使う場合は要注意
    • preg_replace_callbackのように勝手にエスケープする機構はない
    • "\0"、"'"、'\'の3文字が含まれるようなキャプチャを展開するとエラーやevalインジェクションの原因になる
  • mb_ereg_replace_callback関数を使えば安心
    • 僕の実装は頭の体操的に作っただけで実戦経験はありません、念のため
  • UTF-8にしてpreg_replace_callback関数を使うのが無難だと思います

*1:mb_ereg系を実現している正規表現ライブラリ