PHP 5.5.0 リリースおめでとうございます。
タイミング的に PHP 5.5 の話だと読み間違えた方 (もしくは「こいつ間違って PHP 5.3 って書いてやがるプギャー」と思った方) におかれましては、このテキーラはサービスだから、まず飲んで落ち着いてほしい。
はい。ということで、驚くべきことに、いまから、真顔で、 PHP 5.3 の変更点の話をします。でも PHP 5.5 が出るにあたってコードを見直す方もいるでしょうし、なんというかついでに気に掛けていただければと。
何の話か
PHP 5.3 の「下位互換性のない変更点」として、マニュアルに以下のように示されている件についての話です。
引数を解釈する内部API が、PHP 5.3.x に同梱されている全ての拡張機能に 適用されるようになりました。つまり、互換性のないパラメーターが渡された場合、 この引数を解釈するAPIは NULL を関数に返させます。これにはいくつか例外が あります。たとえば get_class() 関数はエラーが起きた場合に 以前と同様 FALSE を返します。
ということで、 PHP のネイティブ関数 (PHP 本体やバンドルされた拡張機能によって実装された PHP 関数を、ここではこのように呼称します) の引数解釈時にエラーとなった場合、 一部の関数を除いて NULL を返すようになりましたよーという話ですね。
ので、 PHP 5.3 以降を使っている私たちは「基本的にはどの関数も引数解釈時のエラーは NULL になるぞ」という前提を頭に入れた上で正しいコーディングをしていなければいけないわけですね (つまり、「この関数は boolean しか返さないぞ! って関数マニュアルに書いてあった!」という状況においても、引数解釈のエラー時についての言及がないのであれば、「そうは言っても NULL も返すんでしょう?」という配慮をしておく)。
……という判断がもう既にできている方、もしくは PHP 5.3 への移行時に、この変更がなされていても問題が発生しないことを確認済みの方は、おそらくこのエントリを読む必要はありません。
そうでない方はこのエントリを読んだ上で、どうぞ PHP 5.5 が出たこの機会に、今までのコードを見直したり、日々のコーディングで上述の件を気をつけるようにしていただけるといいのかなと思いますよ。
どういうことか
たとえばこういうコードがあったとして、:
<?php echo phpversion().PHP_EOL; var_dump(strcmp('...', array())); var_dump(strpos(array(), 'needle'));
PHP 5.2 での出力は以下のようになりますが、:
5.2.17 int(-19) bool(false)
PHP 5.3 以降での出力は以下のようになります:
5.3.21 NULL NULL
なんでかっていうと、まあ先ほどから書いているからわかりますね。 PHP 5.3 から引数解釈時のエラー時は NULL を返すようになったからですね。
で、 PHP 5.2 の結果はさっぱりですが、実行時に出力されている以下のエラーを手がかりにすることができます:
PHP Notice: Array to string conversion in /path/to/script.php on line 5 PHP Notice: Array to string conversion in /path/to/script.php on line 6
「配列から文字列への型変換がおこなわれましたよー」というところですね。配列型を文字列型へ変換すると、 Array という文字列になりますが、この変換処理が内部的におこなわれているのでしょうか。以下のスクリプトを試しに実行してみましょう:
<?php echo phpversion().PHP_EOL; var_dump(strcmp('Array', array())); var_dump(strpos(array(), 'Array'));
PHP 5.2 での出力は以下のようになりました:
5.2.17 int(0) int(0)
strcmp() は両者の引数が等しいので 0 を返し、 strpos() も、第二引数に指定した文字列を第一引数に与えた文字列の 0 バイト目で見つけることができたので、 0 を返しています。内部的に文字列型への変換処理が実行されているとみて間違いなさそうですね。
ちなみに、 PHP 5.3 では先の例と同様、両関数共に NULL を返します:
5.3.21 NULL NULL
ということで、 PHP 5.3 では内部的におこなわれていた型変換がおこなわれなくなり、意図せずに Array という文字列に対して関数の処理が実行されてしまうようなことがなくなったということですね。いいことですね。
で、何が問題なの?
この変更による問題を考えるきっかけになったのは、 2013 年 3 月に投稿された以下のブログエントリです。
- Regalado (In) Security: Unauthorized Access: Bypassing PHP strcmp()
- http://danuxx.blogspot.com/2013/03/unauthorized-access-bypassing-php-strcmp.html
このエントリでは、入力値とワンタイムパスワードの一致チェックとして記述されている strcmp($password, $_POST["ps"]) == 0 を突破する方法について述べられていました。
- strcmp() は引数エラー時に NULL を返す
- NULL は == (緩やかな一致チェック) によって 0 とみなされる
- 従って、攻撃者は ?ps[]=a などのパラメータを指定し、 strcmp() の引数に配列が渡されるようにすることで、認証を突破することができる
ただ、「1. strcmp() は引数エラー時に NULL を返す」に関しては、先述の通り、スクリプトの実行環境が PHP 5.2 である場合は成り立たず、その結果として「2.」および「3.」も生じなくなります。このエントリで述べられている突破方法は、実行環境が PHP 5.3 以降でのみ通用する手法だということになります。
つまり、 strcmp() の返り値を型チェックなしで比較しているコードは、 PHP 5.3 以降、引数エラーの場合も 0 (一致) とみなされるようになってしまった ということですね。
まあ strcmp() およびその類似関数群については対処は簡単で、 strcmp(...) == 0 や if (!strcmp(...)) のような書き方をやめて、 strcmp(...) === 0 と書けばいいだけです。いにしえの PHP スクリプトを PHP 5.3 以降の環境で動かしているような状況ではこれと同じ問題が潜んでいるかもしれませんが、ソースコードを /str[a-z]*cmp/i な感じで検索すればすぐに問題となる場所は見つかるでしょうし、まあ、頑張ってくださいという感じですね。
本当に問題にしたいのは、これが strcmp() に限らないというところです。
どの関数が変更されたか?
「PHP 5.3.x に同梱されている全ての拡張機能」とされているように、あらゆる関数がこの変更の影響を受けていることになります。
この変更の影響を受けた関数を一覧しようと思って調査したのですが、数が多い上に目視での確認作業が入ってしまうので無理でした。「いくつか例外」があるようですが、これについても調べられていません。
とりあえず調べかけのメモ (漏れや誤りがある可能性が充分にあります) を以下に置いておきますのでガッツのある方は調査を引き継いでみてください。
どのような影響がありえたか?
エラー時に NULL を返すようになるということは、 boolean に変換した際に FALSE となるような以下の値を期待しているものの、 === による厳格な一致チェックを用いていないか、 !== による厳格な不一致チェックをおこなっているものの、 NULL が返り値となることを考慮していないケースで問題となる可能性が高いです。
- 0
- "" (空文字列)
- false
- 空配列
パッと思い浮かぶコード例だと、たとえば以下のようなものはありがちな感じでしょうか:
<?php // int strpos ( string $haystack , mixed $needle [, int $offset = 0 ] ) var_dump(false !== strpos(array(), 'needle')); // result: PHP 5.2 : false, PHP 5.3+: true
strpos() は、第二引数にて指定した文字列が第一引数で指定した文字列のどの位置に現れるかを返す関数ですが、文字列が見つからなかったことを示す false でないことを確かめることで、特定の文字列を含むか否かのチェック、要は文字列の検索にもよく用いられる関数です。
このような書き方がどのくらいよく用いられているかというと、 Symfony とか Zend Framework とか で見かけますね。というか自分でもよくやりますね。
この関数の返り値について、マニュアルでは以下のように説明されています。
needle が見つかった位置を、 haystack 文字列の先頭 (offset の値とは無関係) からの相対位置で返します。 文字列の開始位置は 0 であり、1 ではないことに注意しましょう。
needle が見つからない場合は FALSE を返します。
警告: この関数は論理値 FALSE を返す可能性がありますが、FALSE として評価される値を返す可能性もあります。 詳細については 論理値の セクションを参照してください。この関数の返り値を調べるには ===演算子 を 使用してください。
「この関数は論理値 FALSE を返す可能性がありますが、FALSE として評価される値を返す可能性もあります」。つまり、第二引数で指定した文字を、第一引数では 0 バイト目から含む可能性もあって、それが返ってくる可能性もあるから型チェックつきで比較してね! ってことですね。
そこで、結果が 0 であることを許容しようとして、 false のケースのみを除外したのが上述のコード例です。しかし、検索対象文字列として配列が指定されてしまったために、返り値は NULL となってしまいました。その上、 0 を許容するために false でないことを !== の厳格な比較によって判定していますから、 false !== null は PHP 5.3 以降、 true となってしまっていたことになります。
——で、このことが、現実のアプリケーションにおいてどの程度のインパクトを与えているか、というと、……局所的に見ただけではわからない、というのが正直なところです。配列を指定することによってチェックをくぐり抜けたとしても、その値の扱われ方次第では脅威にはなり得ないかもしれませんし、その後で正規化などの処理が実行されることで脅威となるかもしれません。 strcmp() のようにわかりやすいケースでもない限り、「PHP 5.3 以降、ネイティブ関数が引数エラー時に NULL を返すようになったことを考慮していないコードは、そのコードの意図に反する挙動を示すかもしれないぞ!」ということまでしか言えません。気持ち悪いですね。
本当はここで、「ほら、こんな実例があったぞ!」みたいなことでもビシーッと示せればよかったんですが、問題を見つけるには処理全体を眺めなければなくて大変で、まあ一言でいうとギブアップしました。なにかいい調査方法があったら教えてください。
私たちが気をつけなければいけないこと
あなたが攻撃者なら、 ?foo[]=bar とか配列形式のパラメータを送信してみるといいことあるかもね! というぐらいです。ソースコードがない状態でこれを利用した攻撃を成立させるのは相当な運が必要な気もしますが。
で、プログラマとして気をつけなければならないこととしては、以下みたいなところでしょうかね。
- どのようなネイティブ関数でも、 NULL を返すケースがありうるということを念頭に置く
- 入力値検証時は、検証用の関数に渡す前に型チェックや型変換を実施しておく (最近のフレームワークにくっついてるバリデータなんかはこの辺ちゃんとやるようになってきている気がしています)
- 入力値は正規化を実施してから検証をおこなう ( IDS01-J. 文字列は検査するまえに標準化する (Java セキュアコーディングスタンダード CERT/Oracle 版) )
まとめてみると、まあ当たり障りのない内容に落ち着きましたね。気をつけようがないというのが正しいかもしれませんが。少なくともこれから書くコードや、今後コードをレビューする際には以上を念頭に置いてみるとよいのではないでしょうか。