2021年12月20日月曜日

PHPにはエスケープ関数が何種類もあるけど、できればエスケープしない方法が良い理由

このエントリは、PHP Advent Calendar 2021 の20日目のエントリです。19日目は @takoba さんによる PHPプロジェクトのComposerパッケージをRenovateで定期アップデートする でした。

SQLインジェクションやクロスサイトスクリプティング(XSS)の対策を行う際には「エスケープ処理」をしましょうと言われますが、その割にPHP以外の言語ではあまりエスケープ処理の関数が用意されていなかったりします。それに比べてPHPはエスケープ処理の関数が非常に豊富です。これだけ見ても、PHPはなんてセキュアなんだ! と早とちりする人がいるかもしれませんが、しかし、他言語でエスケープ処理関数があまりないのはちゃんと理由があると思うのです。

本稿では、PHPのエスケープ処理用の関数を紹介しながら、その利用目的と、その関数を使わないで済ませる方法を説明します。


SQL用のエスケープ関数

セキュリティとエスケープといえば、真っ先に思いつくのがSQL文字列のエスケープです。関数名にescapeが含まれるものとして下記があります。これらのエイリアスもありますが、割愛します。

これらのうち、pg_escape_identifierは識別子のエスケープをする関数です。SQLの識別子の問題については、下記の連作を御覧ください。

結論として、SQL識別子のエスケープは、phpMyAdminのようなツールを開発する場合は必要になるが、一般のアプリケーション開発であれば識別子のエスケープをしなくてもすむようにした方がよい、というものです。

また、pg_escape_literalは、エスケープするだけでなく文字列を引用符で囲ってリテラルの形にしてくれるものです。PDOだと以下のメソッドが該当します。

単にエスケープだけするのに比べて、引用符で囲ってリテラルを生成する関数・メソッド(pg_escape_literalやPDO::quote)を使ったほうが安全です。これらquote系の関数は、対象が数値の場合も適切に処理してくれることが期待できるからです(ただし、伝統的にPDOは数値の扱いがイマイチですが)。

さて、SQLインジェクション対策にはエスケープが重要とはよく言われることですが、数値が対象の場合はエスケープしない(してはいけない)ことや、データベース・ソフトウェア毎にエスケープのやり方が異なるなど、現実には難易度の高い処理です。このあたりの事情についてはIPAの安全なSQLの呼び出し方をぜひご一読ください。

結論としては、リテラル中の文字列をエスケープするのではなく、プレースホルダを用いてSQLを呼び出すべきであり、さらに言えば、今どきのモダンなアプリケーション・フレームワークを使う場合は、フレームワーク付属のO/Rマッパーを使うので、現実のアプリケーション開発でSQLのエスケープ関数を利用するケースは多くはないと思います。


HTMLエスケープ

HTMLエスケープ、すなわち文字を文字参照に変換する関数には以下があります。

通常クロスサイトスクリプティング対策にはhtmlspecialchars関数を用いますが、こちらについてもフレームワークを使う場合はテンプレートエンジンの機能で自動エスケープさせるのが吉でしょう。

また、PHPはJavaScriptの文字列リテラルのエスケープについては提供していません。json_encodeでできないこともないですが、JavaScriptの動的生成は非常に難易度の高い処理なので、拙著2版では、カスタムデータ属性に値を書いて、それをJavaScriptから参照する方法を推奨しています。これにより、JavaScriptのエスケープではなくHTMLのエスケープで統一できます。


OSコマンド(シェル)

PHPは、system関数などOSコマンド呼び出しのために、シェル形式のエスケープ関数を提供しています。これは他の言語ではあまり見当たらず、比較的珍しい機能だと思います。

これらのうち、escapeshellcmdについては仕様にまつわる脆弱性があり修正も不可能ということで、使用禁止となっています。

警告
escapeshellcmd() はコマンド文字列全体に適用しなければなりません。 また、そうしたところで、まだ任意の数の引数を渡すことによる攻撃を許してしまいます。 単一の引数をエスケープするには、かわりに escapeshellarg() を使わねばなりません。
https://www.php.net/manual/ja/function.escapeshellcmd.php より引用

この脆弱性は、下記ブログ記事で私が報告したものです。

この脆弱性を見つけたきっかけは、拙著の初版を書いている際に、OSコマンドインジェクション対策としてエスケープ処理で本当に大丈夫だろうかと心配になり色々試している中で見つけたものです。心配になった理由は、シェルのエスケープ処理の難易度が高いためです。

そして、この心配はPHPMailerの脆弱性CVE-2016-10033という形で現実のものになりました。

さて、シェル形式のエスケープ処理関数は比較的珍しいと書きましたが、他の言語ではどうしているのでしょうか。実はもっと良い方法、すなわちシェルを経由しないコマンド呼び出しが提供されています。以下の記事で解説しています。

そして、PHP 7.4にて、ようやくシェル経由でないOSコマンド呼び出しが提供されました。

このため、今後はOSコマンドインジェクション対策としては、エスケープ処理ではなく、proc_openによる「シェル経由でないOSコマンド呼び出し」を用いるべきだと思います。この場合はescapeshellarg等によるエスケープ処理は必要ありません。


正規表現

PHPは正規表現用のエスケープ関数も提供しています。

これはどのような時に用いるかと言うと、正規表現パータン中に外部入力を含める場合です。そんなことあるのかと思ってしまいますが、これが原因でRCE(リモートコード実行)可能な脆弱性が混入した例があります。

なので、外部入力を正規表現パターンに含める場合はpreg_quoteを使いましょう…とは私は思いません。そんな危険な行為そのものを避けるべきだと思います。現に、上記で紹介したphpMyAdminの脆弱性(CVE-2013-3238)も、当該箇所で正規表現を使わない形で改修しています。詳細は上記ブログ記事を参照ください。

ということで、preg_quoteもよほどのことがない限り使わない関数だと私は思います。


URL

URL中に記号文字やマルチバイト文字を含める場合はパーセントエンコード(URLエンコード)しますが、これも一種のエスケープ処理と考えることができます。PHPで用意されているパーセントエンコードの関数には以下があります。

概ね似たような処理を行う関数が4種類もあるのがPHPらしいですが、通常はrawurlencodeを用いればよいでしょう。urlencodeの方は、空白を%20ではなくプラス記号にエンコードします。こちらはHTMLフォームのapplication/x-www-form-urlencoded形式ということでしょうが、その差が問題になるケースはあまりないと思います。

URL中で記号をエスケープしないとまずい理由は、クエリ文字列では = や & の記号が区切り文字として特別な意味を持つからです。たとえば、a=値という形式で、値が「b&c=d」だとすると、全体ではa=b&c=dとなって、値がbのみで千切れてしまいます。

このように、URLのエスケープ(パーセントエンコード)自体は必要な処理ですが、目的がクエリ文字列の組み立てである場合は、rawurlencode等よりも便利な機能があります。それは、http_build_query です。この関数は配列を引数として、クエリ文字列形式の文字列を返します。この過程でパーセントエンコードもしてくれます。特に、項目数が多い場合に便利ですが、単一の場合でもhttp_build_queryを使うことをお勧めします。一般的に、ミクロな処理の関数を組み合わせて使うよりも、マクロな機能の関数を用いた方がプログラムの意図が分かりやすくなり、バグ、ひいては脆弱性が入る余地が少なくなるからです。


LDAP

古来から知られたLDAPインジェクションという脆弱性があります。私自身は、LDAPを使った検索で「*」を指定したら全件マッチになった例は脆弱性診断で見つけたことがあります。また、LDAPのクエリの式を変更するという、文字通りのインジェクションもありえます。

LDAPのクエリに外部入力を含めることは十分考えられるため、エスケープが必要になる場合があります。PHPでは5.6から下記の関数でLDAPのエスケープを提供しています。


目的不明なエスケープ関数

以下の3つの関数はPHP4時代からある由緒正しいものですが、利用シーンがよく分かりません。

これらのうち、addcslashesはリファレンスに「C 言語と同様にスラッシュで文字列をクォートする」とありますが、スラッシュ「/」ではなくバックスラッシュ「\」ですよね。PHP言語でC言語のソースコードを生成する時に使うのでしょうか。

もっとわけが分からないのは、addslashesとquotemetaです。

addslashes 

エスケープすべき文字の前にバックスラッシュを付けて返します。 エスケープすべき文字とは、以下のとおりです。

  • シングルクォート (')
  • ダブルクォート (")
  • バックスラッシュ (\)
  • NUL (null バイト)

https://www.php.net/manual/ja/function.addslashes.php より引用

なんとなく、MySQLのエスケープルールを思い起こしますが、わざわざ以下のように「SQLインジェクション対策に使うな」と念押しがしてあります。

addslashes() 関数は、 SQLインジェクション を防止しようとして誤った使い方がされることがあります。 この関数を使うのではなく、データベース特有のエスケープ関数 および/もしくは プリペアドステートメントを使うようにしてください。

quotemetaの説明は以下のとおりで、正規表現のエスケープを連想しますが、

quotemeta 

文字列 str について、

. \ + * ? [ ^ ] ( $ )

の前にバックスラッシュ文字 (\) でクォートして返します

https://www.php.net/manual/ja/function.quotemeta.php より引用

正規表現用とするには、ハイフン「-」が抜けていますし、正規表現用にはpreg_quoteがあるので、そちらを使うべきです(そもそも正規表現パターンに外部入力を含めるなということはありますが)。また、Perlにも同名の関数がありますが、こちらは記号類をすべてエスケープするので仕様が異なります。ということで、quotemetaの使い道は謎です。ご存じの方は教えてください。


まとめ

PHPに多数用意されているエスケープ用関数について紹介しました。

エスケープすべき局面でエスケープを怠る、あるいはエスケープ方法が不適切だと脆弱性になります。そして、エスケープ処理はしばしば難しいのです。エスケープをなめてはいけない。そして、本稿を書くにあたってケッサクな例を見かけました。以下は富士通のInterstage Application Server Smart Repository運用ガイドからの引用ですが、

SDK(JNDI)の場合

 JNDIは注意が必要です。

 LDAP、JNDI、Java言語それぞれで、\(エンマーク(バックスラッシュ))文字を特殊文字と扱うためです。いくつか例を示します。

 \を含むcn属性を指定するとします。

a\b

 LDAPの規約により\をエスケープする必要があります。

cn=a\\b

 JNDIの仕様により、この名前を指定するために、それぞれの\をエスケープする必要があります。

cn=a\\\\b

 Java言語の仕様により、この名前を文字列リテラルとして指定するために、それぞれの\をエスケープする必要があります。

String name1 = "cn=a\\\\\\\\b";

 同様に、,(カンマ)、"(ダブルクォーテーション)の場合はそれぞれ次のようになります。

String name2 = "cn=a\\\\,b";
String name3 = "cn=a\\\\\"b";

 最初の例をJNDIで使用する場合は、次のように記述します。

String name = "cn=a\\\\\\\\b,o=Fujitsu\\\\, Inc.,c=jp";

多重のエスケープが必要なために、バックスラッシュを8個重ねる必要が生じています。

実は、私も類似の記事を過去に書いたことがあります。以下の記事ではバックスラッシュを12個重ねています。

ということで、まとめは以下の通りです。

  • エスケープすべき局面でエスケープを忘れると脆弱性になる(前提)
  • エスケープ処理は意外にややこしい(現実)
  • エスケープしなくてもよい書き方(SQLのプレースホルダ、シェル経由しないOSコマンド呼び出し等)があればそちらを採用しよう(お勧め)


フォロワー

ブログ アーカイブ