htmlspecialcharsと不正な文字の話

PHPでは、HTMLエスケープ用の関数としてhtmlspecialcharsが用意されています。

今日の日記では、htmlspecialcharsについて書きます。これと近い働きをするhtmlentitiesについても触れます。

htmlspecialcharsの基本

こんな感じで使います。

<?php echo htmlspecialchars($string, ENT_QUOTES, "UTF-8"); ?>

関数の引数は3つあります。

引数省略概要
第一引数不可エスケープ対象の文字列
第二引数クォート文字の扱い(後述)
第三引数文字コード(後述)

第二引数は、以下の3つの値のいずれかを指定可能です。

エスケープ対象文字
ENT_NOQUOTES< > &
ENT_COMPAT< > & "
ENT_QUOTES< > & " '

第二引数を指定しない場合のデフォルトは、ENT_COMPATです。

第三引数は、第一引数の文字列(バイト列)が、どの文字コードのバイト列であるかを指定します。デフォルトはISO-8859-1です。

htmlspecialcharsのポイント

まずは第二引数についてです。HTMLタグの属性値や属性名を「'」で囲い、その中にhtmlspecialcharsした文字列を挿入する場合、第二引数にENT_QUOTESを指定しなければなりません。

開発チーム内の規約などで、属性は「"」で囲うと決めていても、ついうっかりミスをすることもあるので、無条件にENT_QUOTESをつけておいた方がよいと思います。

次に第三引数です。指定できる文字コードには制限があります。日本語の文字を含むものでは、Shift_JISEUC-JP、UTF-8のみが指定可能です(eucJP-winやSJIS-winなどは使用できません)。

文字コードを指定する目的は、マルチバイト文字の2バイト目などに、0x3C(<) 0x3E(>) 0x26(&) 0x22(") 0x27(')のバイトが含まれている場合などに、これらを誤ってエスケープしないようにすることです。

しかし、日本語で用いられるShift_JISEUC-JP、UTF-8では、2バイト目以降にエスケープ対象の文字は含まれていません。そのため、意味的には第三引数に文字コードを指定するのが正しいですが、指定しなくても実のところ問題はありません。

ネット上には「不正な文字を利用したXSS攻撃*1を防ぐために、第三引数を指定する方がよい」というような記述もありましたが、これは日本語用の文字コードを使う限りは正確ではありません。文字コードを指定したところで、関数の出力結果は何ら変わりません。攻撃を防ぐ効果はないのです。

<?php
$s = htmlspecialchars("\xA1", ENT_QUOTES, "EUC-JP");
echo bin2hex($s); // HEXダンプして出力 → a1

上記の例は、EUC-JPでの不正な文字(0xA1)が、htmlspecialcharsをすり抜けることを示しています。もし上記の$sをHTMLに出力すると、IEなどのブラウザでは後続の文字が破壊される現象が発生します。

htmlentitiesとの違い

htmlspecialcharsと似たような機能を持つ、htmlentitiesという関数があります。

しかし両者の違いは、余り良く理解されていないように思います。両者の違いは、htmlentitiesでは、U+00A0〜U+00FFに相当する文字*2エスケープ(実体参照化)するが、htmlspecialcharsではエスケープしないということです。

htmlentitiesを使う場合、第三引数の文字コードの指定は必須です。指定しないと、ISO-8859-1とみなされて、0xA0〜0xFFまでのバイトがエスケープされます。これにより、日本語の文字の多くは破壊されてしまいます。例えば、SJISの「古」(0x8C 0xC3)という文字をエスケープすると、2バイト目の0xC3が&Atilde;になります。

htmlentitiesの詳細

以降では、第三引数に適切な文字コードを指定する前提で記述します。

さきほど、htmlentitiesでは、U+00A0〜U+00FF相当の文字をエスケープすると書きました。htmlspecialcharsよりもエスケープ対象の文字が多いので、htmlentitiesの方が安全なのか?というと、そういうことでもありません。

まず、U+00A0〜U+00FFの文字がどのようなものかは、wikipediaなどに書かれています。wikipediaのページの、NBSP〜ÿまでの文字が該当します。

Shift_JISEUC-JPなどで考えると、そもそもU+00A0〜U+00FF相当の文字は含まれていません*3。そのため、正規なShift_JISEUC-JP文字列をhtmlentitiesに与えた場合と、htmlspecialcharsに与えた場合で、出力結果が異なることはありません*4

ただし、正規ではない(不正な)Shift_JISEUC-JP文字列を与えた場合には、両者の出力結果は異なることがあります。htmlentitiesは、不正な部分はISO-8859-1とみなすようで、0xA0〜0xFFのバイトは実体参照に置き換えられます。つまり、htmlentitiesにより、不正な文字を使ったXSS攻撃を防げる場合があります。

<?php
$s = htmlentities("\xA1", ENT_QUOTES, "EUC-JP");
echo $s; // &iexcl; が出力される

しかしこの例では、EUC-JPでの0xA1を¡(&iexcl;)とみなす根拠は(恐らく)ないわけで、この動作は言ってみれば「バグ」「明文化されていない仕様」みたいなものでしょう。つまりWebAP開発者は、この動作に頼るべきではないと思います。

また、置き換えられるのは、0xA0〜0xFFの範囲だけです。ブラウザ依存ですが、Shift_JISでは0x81〜0x9F、EUC-JPでは0x8E〜0x8Fなど、この範囲に含まれない不正な文字を使ったXSS攻撃は可能です。

<?php
$s = htmlentities("\x81", ENT_QUOTES, "Shift_JIS");
echo bin2hex($s); // HEXダンプして出力 → 81

UTF-8の文字列をhtmlentitiesに与えた場合も、Shift_JISEUC-JPの場合と同様です。詳細は省きますが、不正な文字を使ったXSS攻撃は可能です。

htmlentitiesの意味

それでは、htmlentitiesの目的はなんでしょうか? 確かなことは判りませんが、この関数は、元々はISO-8859-1の世界の住人向けに作られたものだと思います(PHP自体がそうでしょうが)。

そのような人たちが、ISO-8859-1の文字列を、US-ASCIIやUTF-8などのHTML上に表現する場合には、この関数は意味を持つでしょう。しかし、日本語の世界の住人が使う必要に迫られることは稀だと思います。少なくとも、htmlentitiesにShift_JISEUC-JPの文字列を与えるのはナンセンスです。

まとめ

日本語のWebサイトの開発者を前提にすると、以下のようなことが言えると思います。

  1. htmlentitiesを使うべき場面はまずない。
  2. htmlspecialcharsの第二引数には、ENT_QUOTESを指定した方が良い。
  3. htmlspecialcharsの第三引数は、今のところ指定するメリットは無い。
  4. 不正な文字を使ったXSS攻撃は、別途の対処が必要。

3について補足すると、意味的には指定したいところです(何の効果もありませんが)。将来的にPHP言語仕様やHTML仕様に変更が加えられた場合に、指定しておいた方が良い場面が出てくるかもしれません。

最後に、PHP6では内部文字列をユニコードで持つようになると聞いています。やっと他の言語(JavaASPなど)と同じようになる訳です。

*1:Bypassing script filters with variable-width encodingsを参照。

*2:ISO-8859-1の0xA0〜0xFFや、UTF-8の0xC2 0xA0〜0xC3 0xBF

*3:正確にはマッピングテーブル次第という面はあります。

*4:正確に言うと、htmlentitiesではShift_JISの半角カナをISO-8859-1だと認識して、実体参照化するバグ(制約?)があるようです。Shift_JISの半角カナでは出力結果が違うようです。