LoginSignup
251
181

More than 5 years have passed since last update.

正規表現でPHPを脆弱にする (1) 「^ と $」

Last updated at Posted at 2018-11-23

みなさん正規表現は好きですか? 私は好きです。簡単に脆弱性を作り込めて、とても便利ですからね。

この記事ではPCRE関数(preg_match(), preg_replace()など)を利用して、みなさまにPHP正規表現のバッドノウハウを教示いたします。

先に結論を書きます

単独の文字列のマッチに $ を用いるのは予期しないパターンになるのでやめましょう。^は特に危険ではありませんが、^$の組み合せではなく\A\zの組み合せを覚えましょう。ただし、m修飾子で複数行(マルチラインモード)で、行頭と行末にマッチさせたい場合を除きます。

文字列のマッチに ^$ のペアを利用する

そうですね、例としてQiitaのようなユーザー登録サイトの表示名 @tadsan のような文字列を登録する前の検査をするとします。

要件としては、使用可能な文字は abcdefghijklmnopqrstuvwxyz0123456789_ で、3〜32文字とします。

if (checkUserName($input['user_name'])) {
    return register_user($input);
}

return error_message("不正な名前です");

以下の判定関数 checkUserName() には穴があります。

function checkUserName(string $user_name): bool
{
    return preg_match('/^@[_a-z0-9]{3,32}$/i', $user_name) === 1;
}

テストコードを書いてみましょうね (最初にやれ)

foreach ([
    '@tadsan' => true, // ふつうの名前
    '@a' => false,     // 1文字は短すぎる
    '@abc' => true,    // 3文字はok
    "@tadsan\n" => false,   // 末尾がLF
    "@tadsan\r\n" => false, // 末尾がCR+LF
    "@tad\nsan" => false,   // 途中にLF
    "@tadsan\n\n" => false, // 末尾にLFが二個
] as $input => $expected) {
    printf("%s => %s\n", json_encode($input, 1),
           checkUserName($input) === $expected ? "✓" : "❌");
}

結果はどうなりますか?

"@tadsan" => ✓
"@a" => ✓
"@abc" => ✓
"@tadsan\n" => ❌
"@tadsan\r\n" => ✓
"@tad\nsan" => ✓
"@tadsan\n\n" => ✓

えっ、まじで? はい、まじです。

なぜこんなことが起きるのか

PHP: メタ文字 - Manualには、このように書いてあります。

^
検索対象(複数行モードでは行)の始まりを言明
$
検索対象の終わりあるいは終端の改行文字の前(複数行モードでは行の終わり)を言明

PHP: メタ文字 - Manualより抜萃、2018年11月23日閲覧

終わりあるいは終端の改行文字の前」です。複数行モードでは単なる行頭行末ですが、単一行モード(つまり、デフォルトの動作)では牙を剥きます。

対策

D 修飾子

このPCREのパターン修飾子の存在はマニュアルに、きっちり書いてあります。

D (PCRE_DOLLAR_ENDONLY)
この修飾子を設定すると、パターン内のドルメタ文字は、検索対象文字列の 終わりにのみマッチします。この修飾子を設定しない場合、ドル記号は、 検索対象文字列の最後の文字が改行文字であれば、その直前にもマッチします。 この修飾子は、m を設定している場合に無視されます。 Perl には、この修飾子に等価なものはありません。

そんなの知らねーよ」とおっしゃるかもしれませんが、まあその通りですね。このパターン修飾子を付けなかったときの挙動は、$\Zの挙動に変化します。 私も実用的に使ったことないので覚えなくてよいです。

\A\z

こちらを利用するのが本命です。

\A
検索対象文字列の始端(複数行モードとは独立)
\Z
検索対象文字列の終端、または終端の改行(複数行モードとは独立)
\z
検索対象文字列の終端(複数行モードとは独立)

PHP: エスケープシーケンス - Manualより抜萃、2018年11月23日閲覧

^^$は動作モードによって挙動が変化するのに対して、\A\zは常に同じ挙動です。

\Zは単一行モードの$と同じ挙動ですが、意図的に利用するべき場面は基本的にありません。

よって、覚えるべきは \A\zのペアです。本日は\A\zを頭に叩き込んで帰ってください。

余談

JavaScript

JavaScriptはPCRE正規表現ではありませんので、この問題の影響を受けません。

function checkUserName(user_name)
{
    return /^@[_a-z0-9]{3,32}$/.test(user_name);
}

const test = {
    '@tadsan': true, // ふつうの名前
    '@a': false,     // 1文字は短すぎる
    '@abc': true,    // 3文字はok
    "@tadsan\n": false,   // 末尾がLF
    "@tadsan\r\n": false, // 末尾がCR+LF
    "@tad\nsan": false,   // 途中にLF
    "@tadsan\n\n": false, // 末尾にLFが二個
};

for (const x of Object.keys(test)) {
    console.log(`${JSON.stringify(x)} => ${checkUserName(x) === test[x] ? "" : ""}`);
}

Wandboxで実行したところ https://wandbox.org/permlink/re8Dn97473bPovof

詳しくは正規表現パターンの記述 - JavaScript | MDNを読んでください。

^$を使ってよろしい場合

m修飾子を使って複数行モードで「行頭」と「行末」にマッチさせたい場合は$を使ってください。

典型的にはpreg_match_all()を使って各行マッチするようなパターンです。

<?php

$text = <<<TXT
11111
これはコメント
22222
てすてす
TXT;

preg_match_all('/^[1-9][0-9]*$/m', $text, $matches);
var_dump($matches[0]);
// => [
//    "11111",
//    "22222",
// ]

また、preg_replace()で各行に対して操作したいときにも有効です。

「すべての行頭に//を付けたい(コメントアウトしたい)」場合は、これだけで書けます。

echo preg_replace('/^/m', '//', $text);

「すべての行末に記号を付けたい」であれば、もちろんこうです。

echo preg_replace('/$/m', '⏎', $text);

「空行以外の行を『』で括りたい」であれば、こう書くこともできます。

echo preg_replace('/^(.+)$/m', '『\1』', $text);

ね、簡単でしょ? これらのパターンは\A\zのような記法で代用することはできません。

まとめ

正規表現は楽しいよ

  • 正規表現には複数の方言があるよ
  • 本来の^$は複数行モードで効果を発揮するよ
  • この記事でやったように、パターンを書くときはフリーハンドで書くのではなく、どんなものにマッチするのかテストを書くのはとても大事だよ。

tadsan|pixivFANBOXの支援者が増えたらこのシリーズは続きます

251
181
7

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
251
181