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コマンド呼び出し等)があればそちらを採用しよう(お勧め)


2020年12月19日土曜日

PHPビルドの楽しみ、あるいはポケモンとしてのPHPについて

この記事はPHP Advent Calendar 2020の19日目です。18日目は@You-sakuさんのPHPでYoutubeのAPIを利用したいでした。


私は以前、PHPのすべてのバージョンを使う環境として、phpallphpcgiallmodphpallを紹介してきました。これらは、PHPのすべてのバージョンをそれぞれコマンドライン、CGI、mod_phpの形式で動作させるものです。
現在では、PHPのバージョンを切り替えて動作させる仕組みはphpenvphpbrew等何種類もありますし、php-buildという、PHPをビルドするというそのものずばりの仕組みも広く使われています。
しかし、短いPHPスクリプトをPHPの全バージョンで試すという私の使用法からは、phpall(およびその派生としてphpcgiall、modphpall)はメリットがあり、私は今もPHPのビルドを続けています。

そう、私は今も、PHPのすべてのバージョンのバイナリをCLI、CGI、Apacheモジュールの形式で持っているのです。そして、元々は目的があって始めたことなのですが、いまや、「PHPのすべてのバージョンをコンプする」こと自体が目的化してしまっています。この感覚、何かに似ているなと思い返したら…そうです。ポケモンを集めるような感覚なのです。いまや、

徳丸にとってPHPはポケモン


と言っても過言ではありません。
PHPがポケモンだという主張は認めていただくとして、PHPにもポケモン同様にレアキャラがあるでしょうか…

あります。ビルドが困難であればあるほど、レアなポケモン(に相当するPHP)と言ってよいでしょう。
経験上、以下の条件を満たすPHPがレアな(ビルドが困難な)ものと言えます。
  • 古いバージョンのPHP(PHP4、PHP5.0、5.1、PHP5.2等)
  • 新しい環境で動く(Ubuntu 20.04等)
  • できるだけ多くの拡張モジュールを備える
  • 32ビット版よりも64ビット版
以下、その理由を簡単に説明しましょう。

古いバージョンのPHP

次の項と関連しますが、古いPHPはビルドすること自体が困難です。その一端は@hnwさんの以下の記事で読むことができます。


古いPHPがビルドが困難な理由は以下の通りです。
  • Cコンパイラの新バージョンでは文法チェックが厳しくなった結果、コンパイルエラーになりコンパイルできない
  • PHPが利用するライブラリの非互換なバージョンアップ
これらの回避策としては以下が考えられます。
  • 古いコンパイラ(gcc)を使う
  • コンパイルエラーを緩和するオプションを設定する
  • 古いライブラリを使う
  • PHPにパッチをあてる
最終的にはPHPにパッチをあてざるを得ない場合もありますが、これはできれば避けたいところです。新しいgccでコンパイルエラーになる箇所を見ていると「これ、単にバグじゃないか」と思うものが多い(それ以外ではライブラリの非互換)のですが、他の方法で回避できるのであれば回避したい…ということで、古いgccを試したりしましたが、今は以下のgccのオプションで回避して、最低限PHPソースにパッチをあてています。
-std=gnu89
-fgnu89-inline
ライブラリも結構ハマるところでして、以下は複数のバージョンを使っています。
  • zlib(1.1.4、1.2.11)
  • openssl(0.9.8、1.1.1)
  • curl(7.15.0、7.16.0、7.68.0、7.72.0)
  • libxml2(2.7.8、2.9.8、2.9.10)
意外にハマるのがcurl(libcurl)でして、非互換なバージョンアップのため、PHPのバージョンによってcurlのバージョンも変える必要がありました。

新しい環境で動く

前項の裏返しになりますが、新しい環境(OS)であるほど、古いPHPをビルドすることが困難になります。特にライブラリの非互換は避けようのないところで、OS(Linuxディストリビューション)にバンドルされているライブラリでうまくビルドできない場合は、古いライブラリを探してきてビルドすることから始めなければなりません。ライブラリ毎にビルド方法に癖があったりして苦労する場合もありますが、そこがまた楽しいのです。

できるだけ多くの拡張モジュールを備える

前項と関連しますが、できるだけ多くの拡張モジュールを指定した方がビルドの難易度が上がります。OSにバンドルされていないライブラリや、バンドルはされているが非互換のため古いライブラリを別途導入しないケースはなおさらです。

32ビット版よりも64ビット版

意外なことを書くと思われるかもしれませんが、古いPHP(4.1、4.2あたり)はAMD64でのビルドが困難です。ビルド自体はできるのですが、正常に動作しません。この時代はまだAMD64の現物がなかった(AMD64の出荷は2003年4月)からと思われます。PHP 4.0はビルドでき動作もするので、調整すればPHP 4.1や4.2も動作すると思うのですが、私はまだ成功していません。

phpallをUbuntu16.04(32ビット)からUbuntu20.04(64ビット)に移行

さて、著者はphpallの環境を当初Ubuntu12.04(32ビット)に構築し、その後Ubuntu16.04(32ビット)に移行していましたが、PHP 8.0α版が出た際にUbuntu20.04(64ビット)に移行しました。
そのきっかけとなったのは、PHP 8.0から導入されたJITを試そうとして時のことです。どうもx86(32ビット)では、JITは使えないようなのです。下図はphpinfoからの該当部分です。


JITのRFCではx86もサポートされていると書かれていましたが、本番投入は見送られたのでしょうか。私は、phpallを64ビット環境に移す決断をしました。以下はUbuntu20.04(64ビット)でのphpinfoで、JITが有効化できていることがわかります。


残る課題

楽しみとしてのPHPビルドについて紹介しましたが、現状課題が残っています。前述のように、64ビット環境でのPHP4.1と4.2のビルドには成功していないからです。ここは私の技術の至らなさではあるのですが、将来の楽しみにとっておこうと思います。やり方をご存知の方がいれば教えていただけるとありがたいです。

2020年11月14日土曜日

auじぶん銀行のフィッシングSMSが届いた

3日前に、auじぶん銀行の巧妙な不正出金についてYouTube動画を公開しました。みんな見てねー。

auじぶん銀行アプリに対する不正出金の驚くべき手口

そうしたところ、先程私のiPhoneに以下のようなSMSが届きました。

ふーん、au自分銀行のときは宅配事業者を装ったSMSということでしたが、これはどうなんでしょうね。開いてみると…


詐欺サイトの警告が出ていますが、構わずに開いてみると…


きたきたきたー。これですよ。auじぶん銀行のフィッシング(SMSの場合はスミッシングと言いますが)サイトのようですよー。「閉じる」をタップすると…


うーむ、これがauじぶん銀行の本物のフィッシングサイトですよ。これかー。僕が作った偽サイトよりもきちんと作ってありますねー(笑い)。
ちなみに、本物のauじぶん銀行サイトはこちら。


よく似ていますね。お客様番号とログインパスワードの欄は共通ですが、偽物の方は暗証番号と誕生日を要求しています。私がデモ用に作った偽サイトは、さらにカタカナの氏名を要求していますが、氏名はサイトにログインすると容易にわかるので省略しているのでしょうね。

せっかくの「本物の偽サイト」ですので、少し実験してみましょう。お客様番号等をでたらめな文字列にして、ログインボタンをタップしてみます。


「サーバーに接続中です。このまましばらくお待ち下さい」というダイアログが表示され、しばらくたった後、「入力に誤りがあります。」というダイアログが表示されます。
これは、攻撃者が、入力内容(お客様番号とログインパスワード)を用いて本物サイトにログインを試み失敗したためと考えられます。このように、裏で本物のサイトと中継する形式のフィッシングを中継型フィッシングと呼びます。

ログインパスワードを得るためだけであれば、中継型にする必要はないのですが、オンラインバンキングの多くはパスワードだけでは送金や出金はできず、追加の認証を求めるわけで、その追加の認証を突破する目的で行われます。
ちなみに、au自分銀行の場合は、スマホアプリかカード番号裏の乱数表が用いられますが、こちらのリリースで公表されている手口の場合は、以下を突破していると考えられます。
  • キャッシュカード暗証番号
  • キャッシュカードの裏面にある確認番号表の数字
  • お客さま携帯電話宛てに発行されたワンタイムパスワード
実際にどうなっているかは、本物の会員番号とパスワードを入力すればわかるのですが、さすがにそれはできないので、ここまででやめました。

おそらくは、冒頭に紹介した動画の手口かと思いますので、興味のある方は動画の方を御覧ください。

auじぶん銀行アプリに対する不正出金の驚くべき手口

フィッシングは恐ろしい手口であり、こちらの報道(アーカイブ)では、「警察庁によると、6、7月に同様の被害が全国で32件(約1060万円)確認されている」とあります。くれぐれもご注意下さい。また、中継型フィッシングを想定すると、二段階認証も突破される可能性が高いので、サイト運営者側もそれを想定した対策が求められます。


追記(2020年11月15日11:00)

今朝当該サイトにアクセスしてみたところ、DNSの設定が削除され、当該IPアドレスにもアクセスできない状態になっていました。関係者の皆様ありがとうございました。

2020年5月30日土曜日

PHPの脆弱性CVE-2018-17082はApacheの脆弱性CVE-2015-3183を修正したら発現するようになったというお話

最近自宅引きこもりで時間ができたので、YouTube動画を投稿するようになりました。みんな見てねー。

徳丸浩のウェブセキュリティ講座

そんなことで、次の動画は、お気に入りのPHPの脆弱性 CVE-2018-17082 を取り上げようと思ったんですよ。表向きXSSで出ているけど、金床さんのツッコミにもあるように、実はHTTP Request Smuggling(HRS)だというやつです。でね、下準備であらためて調べていると、なんかよく分からない挙動がワラワラと出てくる。なんじゃ、こりゃ。CVE-2018-17082 全然分からない。僕は気分で CVE-2018-17082 を扱っている…

で、雑に整理すると、以下のような感じなんです。
  • 古い環境だとCVE-2018-17082は発現しない(2015年以前)
  • 少し古い環境だとCVE-2018-17082は発現する
  • 新しい環境だとCVE-2018-17082は発現しない(2019年以降 …これは当たり前)
これ自体はよくあることです。古いバージョンには脆弱性がなく、途中のバージョンで作り込まれたってことね。でもね、CVE-2018-17082はPHP 5.0.0にもあるんですよ。PHP 5.0.0が出たのは2004年ですからね。これだと辻褄が合わない。

試しに、自宅の環境のmodphpallを使ってPHP 5.0.0でCVE-2018-17082を試すとちゃんと発現するわけですよ。PHPのバージョンは関係ない。

で、次に、自宅の環境のCentOS/Debian/Ubuntuの各バージョン(パッチがまったくあたってないものとすべてのパッチがあたっているもの…LinuxAllという大げさな名前にしているのはくれぐれも内緒だ)で試すと、先の現象になるわけです。あまり古いと脆弱性のあるPHPバージョンなのに発現しない。CentOS6と7なんかひどくて、パッチのあたってない環境だと発現しないのに、すべてのパッチがあたっていると逆に発現する。
でも、ここにヒントがあると思ったわけですね。なぜって、CentOSはそもそもCVE-2018-17082のパッチは出してないわけですよ。ならば、PHP以外に影響があるに違いない。ひょっとすると、Apacheのバージョンが影響しているのかも。考えてみれば、そうだよね。だってCVE-2018-17082は、PHPのApache2handlerの脆弱性だから、相方のApacheの実装に影響を受ける可能性は十分にある。

なので、今度はApache 2.2と2.4のすべてのバージョンを導入した試験環境(1.3や2.0がないのにApacheAllという大げさな名前なのは内緒)で試したわけですよ。さいわい、リファレンス用としてPHP 5.3.3が入れてあるので、脆弱性が動くはずです。
でためしたら、やはり古いApacheだとCVE-2018-17082は発現しなくて、以下のバージョンだと発現する。
  • Apache 2.2.31以降
  • Apache 2.4.16以降
で、Apache 2.2.31で修正された脆弱性はなんだろう…と思って調べると、以下ですよ(これだけ)。

low: HTTP request smuggling attack against chunked request parser (CVE-2015-3183)

思わず息を呑みました(誇張じゃないよ)。出た、HRS。これだ。これに違いない。
ちなみに、CVE-2015-3183に対する修正はこちらでして、コミットメッセージに「Limit accepted chunk-size to 2^63-1 and be strict about chunk-ext」とあるように、巨大なチャンクサイズへの対応でしょうね。
でもね、皮肉な話じゃないですか。Apacheのあまり攻撃現実性がない脆弱性に対する修正の影響で、潜在的なPHPの脆弱性が顕在化して、こっちの方がはるかに攻撃の現実性があるわけですよ。別にApacheが悪い訳ではないですけどね。皮肉なもんだなーと思ったわけです。

ということで、全てが綺麗に説明がついたので、安心して動画制作に戻ろうと思います。
このように、私の動画は見えないところに手間をかけて制作していますんで、みんな見てくださいねー、よろしく!

徳丸浩のウェブセキュリティ講座

2020年3月27日金曜日

PHP 7.2以降におけるPDO::PARAM_INTの仕様変更

サマリ

PHP 7.2以降、PDOの内部実装が変更された。動的プレースホルダ(エミュレーションOFF)にてバインド時にPDO::PARAM_INTを指定した場合、PHP 7.1までは文字列型としてバインドされていたが、PHP 7.2以降では整数型としてバインドされる。
この変更により、従来PDOが内包していた「暗黙の型変換」は解消される一方、integerへの暗黙のキャストにより、整数の最大値を超えた場合に不具合が発生する可能性がある。

この記事を読むのに必要な前提知識

この記事は、以前の記事(下記)の続編のような形になっています。

PDOのサンプルで数値をバインドする際にintにキャストしている理由

この記事では、PDOを用いたサンプルスクリプトでbindValue時にinteger型へのキャストを明示している理由を説明しています。パラメータを文字列として渡した場合、PDO::PARAM_INTにより整数型を指定しても、SQL文生成時に文字列リテラルとしてバインドされるため、「暗黙の型変換」により意図しない動作がおこり得ます。それを避けるために、値をintegerにキャストすることにより、SQL文には数値リテラルとして生成され、暗黙の型変換による不具合を避けることができます。一方、integer型へのキャストすると、整数の範囲を超えた場合にオーバーフローが起こる危険性があり、一長一短ではあります。

PHP 7.2での変更内容

PHP 7.2以降では、bindParamあるいはbindValue時にPDO::PARAM_INTを指定した場合、値はPHPのint型にキャストされます。ここで注意すべきことは、MySQLのINTではなく、PHPのinteger型にキャストされるため、32ビット環境では32ビット符号付き整数、64ビット環境では64ビット符号付き整数にキャストされ、その範囲を超えた場合はintegerの最小値あるいは最大値に変更されることです。

検証

検証のために、先のブログ記事と同じデータを用意します(再掲)。
CREATE TABLE xdecimal (id DECIMAL(20));   -- DECIMAL(20)は10進20桁の数値型
INSERT INTO xdecimal VALUES (18015376320243459);
INSERT INTO xdecimal VALUES (18015376320243460);
INSERT INTO xdecimal VALUES (18015376320243461);
PHPサンプルは下記のとおりですが、暗黙の型変換を起こすために、WHERE句で + 0を追加しています。
<?php
  $db = new PDO("mysql:host=127.0.0.1;dbname=test;charset=utf8", DBUSER, DBPASSWD);
  $ps = $db->prepare("SELECT id FROM xdecimal WHERE id = :id + 0");
  $id = '18015376320243461';
  $ps->bindValue(':id', $id, PDO::PARAM_INT); // intへのキャストはしない
  $ps->execute();
  $row = $ps->fetch();
  echo "$id->${row[0]}\n";
PHP 7.1.33で実行結果(32ビット環境、64ビット環境とも)
【実行結果】18015376320243461 -> 18015376320243459
【実行されるSQL文】SELECT id FROM xdecimal WHERE id = '18015376320243461' + 0

PHP 7.2.0での実行結果(64ビット環境)
【実行結果】18015376320243461 -> 18015376320243461
【実行されるSQL文】SELECT id FROM xdecimal WHERE id = 18015376320243461 + 0

PHP 7.2.0での実行結果(32ビット環境)
【実行結果】18015376320243461 ->
【実行されるSQL文】SELECT id FROM xdecimal WHERE id = 2147483647 + 0

端的な違いは、リテラルがシングルクォートで囲まれているか否か、すなわち、文字列リテラルか数値リテラルかです。PHP 7.2以降ではの変更、バインド時にPDO::PARAM_INTを指定した場合にバインド値が数値リテラルとして生成され、これはSQLの文法として本来の姿になったと考えます。これによる現実的なメリットは、文字列リテラルを数値に変換する際の「暗黙の型変換」が避けられ、正確な数値比較や演算が行えるようになったことです。

この変更による悪影響

PDO::PARAM_INT指定時に、値がいったんPHPのintegerにキャストされることから、integerの上下限を超えた値を指定した場合、強制的にPHP_INT_MAXまたはPHP_INT_MINに値が変更されます。具体例は、前述の「PHP 7.2.0での実行結果(32ビット環境)」を参照ください。integerの取りうる値の範囲は環境依存ですが、現在一般的な環境では以下のようになる場合が多いと思います。

32ビット環境: -2147483648 ~ 2147483647
64ビット環境: -9223372036854775808 ~ 9223372036854775807

PDOはどのように使えばよいか

今どき32ビット版のPHPを本番環境で使っている(かつPHP 7.2以降が動いている)環境はあまりないかもしれませんが、64ビット版であっても、数値の範囲について注意が必要です。PHP_INT_MAXを超える可能性がある値について、PDOはDecimal型のような型を指定する方法がないため、以下のように明示的なキャストを指定する方法などが考えられます。
$ps = $db->prepare("SELECT id FROM xdecimal WHERE id = CAST(:id AS DECIMAL(20))"); // 明示的な型変換
$ps->bindValue(':id', $id, PDO::PARAM_STR); // いったん文字列型でバインドして、CASTで明示的に型変換

まとめ

PHP 7.2におけるPDOの実装変更について紹介しました。この変更はドキュメント化されていないようで、かつ、PHP Internalsメーリングリストでも話題になっていないようです。もしドキュメント等を知っている方がおられたら教えてください。
この変更は非互換かつドキュメント化されていないものであるので、ひょっとすると手違いなどで変更された可能性もあると思っています。

2019年12月9日月曜日

SSRF対策としてAmazonから発表されたIMDSv2の効果と限界

サマリ

Capital OneからのSSRF攻撃による大規模な情報漏えい等をうけて、Amazonはインスタンスメタデータに対する保護策としてInstance Metadata Service (IMDSv2) を発表した。本稿では、IMDSv2が生まれた背景、使い方、効果、限界を説明した上で、SSRF対策におけるIMDSv2の位置づけについて説明する。

SSRFとは

SSRFは、下図のように「外部から直接アクセスできないエンドポイント」に対して、公開サーバーなどを踏み台としてアクセスする攻撃方法です。SSRF(Server Side Request Forgery)の詳細については過去記事「SSRF(Server Side Request Forgery)徹底入門」を参照ください。
最終的な攻撃目標は多様ですが、近年問題になっているのが、クラウドサービスのインスタンス・メタデータを取得するAPIのエンドポイントです。有名なものがAmazon EC2の169.254.169.254(IMDS)ですが、類似の機能をクラウドサービス各社が提供しています。
先の記事でも紹介したSSRF脆弱なサンプルを以下に示します。これは、はてなブックマークのようなソーシャルブックマークアプリの「プレビュー機能」を想定しています。
<?php
  require_once('./htmlpurifier/library/HTMLPurifier.includes.php');
  $purifier = new HTMLPurifier();

  $ch = curl_init();
  $url = $_GET['url'];
  curl_setopt($ch, CURLOPT_URL, $url);
  curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
  $html = curl_exec($ch);
  echo $purifier->purify($html);
このスクリプトをAmazon EC2上においてSSRF攻撃すると、下図のようにIAMのクレデンシャルが表示されます。


 SSRF攻撃が一般の方にも話題になったのはCapital Oneからの1億人超の個人情報流出事件で、詳しくは以下の記事にまとめられています。
 また、SSRF攻撃の標準的な対策は、ネットワーク的な対策で、EC2の場合は以下のようなiptablesによる防御が従来から推奨されていました。
sudo iptables --insert FORWARD 1 --in-interface docker+ --destination 169.254.169.254/32 --jump DROP

Amazon ECS コンテナインスタンスの IAM ロール - Amazon Elastic Container Service より引用
ここまでが長い前置き(前提知識の確認)です。

EC2インスタンスメタデータサービスv2(IMDSv2)とは

この状況に対して、Amazonが批判されたり、Amazonの責任ではないという反論があったりしていましたが、Amazonは今年の11月20日オフィシャルブログにて、Instance Metadata Service v2(IMDSv2) を発表しました。以下は、クラスメソッドの臼田さんのブログ記事から要点の引用です。
  • v2へのアクセスには事前に取得したTokenを必須とする
    • TokenはPUTで取得する必要がある
    • Tokenリクエスト時に有効期限(秒)を設定できる
    • Tokenはヘッダに入れてリクエストする必要がある
  • v1を無効化できる(デフォルトでは併用可能)
  • メタデータサービス自体を無効化できる
IMDSv2によるメタデータ取得は以下のようになります。まずは、トークンの取得
$ curl -X PUT "http://169.254.169.254/latest/api/token" -H "X-aws-ec2-metadata-token-ttl-seconds: 60"
AQAAAKunSEcqfWQgz1E-ryJ3fdWDoOkbn8Nn4h2C6qN6nP56npog8Q==$
赤字のBASE64っぽいものがトークンです。TTLを60秒としているので、この値は既に無効です。PUTメソッドとX-aws-ec2-metadata-token-ttl-secondsヘッダを要求することで、攻撃難易度を上げています。
続いて、トークンを利用したメタデータの取得です。X-aws-ec2-metadata-tokenヘッダにより、先程取得したトークンを指定します。
$ curl -H "X-aws-ec2-metadata-token: AQAAAKunSEcqfWQgz1E-ryJ3fdWDoOkbn8Nn4h2C6qN6nP56npog8Q==" http://169.254.169.254/latest/meta-data/iam/info/
{
  "Code" : "Success",
  "LastUpdated" : "2019-12-08T02:32:19Z",
  "InstanceProfileArn" : "arn:aws:iam::999999999999:instance-profile/test-role",
  "InstanceProfileId" : "ZZZZZZZZZZZZZZZZZZZZZ"
}$
これだけだと、IMDSv1が有効になっているのでSSRF攻撃は緩和されません。IMDSv1を無効化するには、AWSCLIから以下のように --http-tokens を required に設定します。
$ aws ec2 modify-instance-metadata-options --instance-id i-FFFFFFFFFFFFFFF --http-tokens required --http-endpoint enabled
{
    "InstanceId": "i-FFFFFFFFFFFFFFFFF",
    "InstanceMetadataOptions": {
        "State": "pending",
        "HttpTokens": "required",
        "HttpPutResponseHopLimit": 1,
        "HttpEndpoint": "enabled"
    }
}
$
この状態で先の攻撃をすると、以下のように攻撃は防御されます。


SSRF攻撃の文脈でPUTメソッドやカスタムHTTPリクエストヘッダを指定することは難しそうなので、「これだけでSSRF対策は十分ではないか」と思う人もいそうですが、実は攻撃は可能です。

Gopherプロトコルとは

以前からSSRF界隈ではGopherプロトコルの活用が話題となっていて、はせがわようすけさんが分かりやすいスライドで紹介されています。
このスライドの12ページからがGopherを用いた攻撃手法についての説明です…が、このスライドは今年の9月18日の講演のものですので、当時存在しなかったIMDSv2についての言及はありません。このため、はせがわさんのスライドを引き継ぐ形で、GopherによるIMDSv2への攻撃を紹介します。

まず、Gopher自体の紹介はこちらの記事などを参照していただくとして、ここではcurlとnetcatによりgopherプロトコルを簡単に試してみます。

まずはcurlコマンドにより以下のURLをアクセスしてみます。
$ curl gopher://localhost:8888/_Hello%0d%0aHiroshi%20Tokumaru%0d%0a
curlコマンド実行前にnetcatで8888ポートを待ち受けていると、以下のような表示になります。
$ nc -l 8888
Hello
Hiroshi Tokumaru

Response           ← この行はResponse 改行 Ctrl-d を手入力したもの
$
この際の呼び出し側は下記となります。
$ curl gopher://localhost:8888/_Hello%0d%0aHiroshi%20Tokumaru%0d%0a
Response
$
このように、Gopherプロトコルを使うと、任意リクエストをURLで指定でき、そのレスポンスを受け取れることから、HTTPやSMTPその他のプロトコルをエミュレートできることになります。

Gopherプロトコルを用いたIMDSv2に対する攻撃

先程のサンプルプログラムをEC2のIMDSv1を無効化した環境に設置した状態で、Gopherプロトコルを用いて攻撃してみましょう。まずはPUTメソッドによるトークン取り出しです。表示が見やすいようにHTMLソースの形で表示しています。アドレスバーには gopher://169.254.169.254:80/_PUT というURLがちらっと見えていますね。


このトークン(有効時間は60秒…もっと長くすることも可能)を用いて、メタデータを表示させた結果が下記です。

このように、IMDSv1を無効化してIMDSv2のみ有効としても、Gopherプロトコルを用いてSSRF攻撃ができました。

リダイレクトを許可している場合

今までの「脆弱なスクリプト」は、与えられたURLに対してスキーム(プロトコル)もホストのIPアドレスもチェックしていなかったので、これらのチェックを追加してみましょう。これだけだと防御できて当然なので、cURLのオプションとしてCURLOPT_FOLLOWLOCATIONをtrueにします。これは、リダイレクトをcURL内部で自動的に追跡するという意味です。
<?php
  require_once('./htmlpurifier/library/HTMLPurifier.includes.php');
  $purifier = new HTMLPurifier();

  $ch = curl_init();
  $url = $_GET['url'];
  $urlinfo = parse_url($url);  // URLのパース
  $scheme = $urlinfo['scheme'];
  $host = $urlinfo['host'];
  $ip = gethostbyname($host);
  if ($ip === "169.254.169.254") {  // IPアドレスのチェック
    die("Invalid host");
  } elseif ($scheme !== 'http' && $scheme !== 'https') { // スキームのチェック
    die("Invalid scheme");
  }
  curl_setopt($ch, CURLOPT_URL, $url);
  curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);  // リダイレクトを自動追跡
  curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

  $html = curl_exec($ch);
  echo $purifier->purify($html);
このスクリプトに対して、リダイレクトを用いた攻撃をします。具体的には下記のスクリプト(リダイレクタ)のURLをサンプルスクリプトに指定します。
<?php
  header('Location: gopher://169.254.169.254:80/_PUT%20/latest/api/token...以下悪用防止のため略
実行結果は以下となり、トークンを取得できていることがわかります。


同様に、このトークンを使用してEC2インスタンスのメタデータを取得することができます。

※ はせがわさんのスライドでは、この攻撃にはスクリプト側で明示的に任意プロトコルへのリダイレクトが許可されている必要があるように読めます(P23)が、私が実験により確認した範囲では、任意プロトコルの明示的な許可は必要ないようです。

対策

上記攻撃には以下の対策候補が考えられます。
  • curlで扱うプロトコルをHTTPおよびHTTPSに限定(常に指定を推奨)
curl_setopt($ch, CURLOPT_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS);
  • リダイレクトの追跡を禁止する(curlのデフォルトに戻す、あるいは以下を設定)
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, false);  // リダイレクト追跡しない
  • CURLINFO_PRIMARY_IPにより「実際にアクセスしたIPアドレス」を求め、169.254.169.254(等ブラックリストのIPアドレス)であれば表示をやめる
$primary_ip = curl_getinfo($ch, CURLINFO_PRIMARY_IP);
  • URLからホスト名に対応するIPアドレスとスキームを確認する(先のスクリプトでは実施済み)

結局どうすればよいか

IMDSv2はそもそもAmazonからもインスタンスメタデータに対する defense in depth (多層防御)と紹介されており、根本的な解決策ではありません。なので、他の根本的な解決策を実施した上で、予防的な対策(緩和策)として用いるべきです。
では、根本的な解決策はなにかというと、インスタンスメタデータの保護という点では、先に紹介したiptables等を用いたネットワーク的な対策が確実です。あるいは、以下により、HTTPによるインスタンスメタデータ参照そのものを禁止することも有効です。
$aws ec2 modify-instance-metadata-options --instance-id i-FFFFFFFFFFFFFFFF --http-endpoint disabled
{
    "InstanceId": "i-FFFFFFFFFFFFFFFFF",
    "InstanceMetadataOptions": {
        "State": "pending",
        "HttpTokens": "required",
        "HttpPutResponseHopLimit": 1,
        "HttpEndpoint": "disabled"
    }
}
ただし、上記はインスタンスメタデータに対する保護であり、SSRF攻撃全般を防御できるわけではないため、他に守るべきエンドポイントがある場合には他の対策を併用する必要があります。

まとめ

Instance Metadata Service v2 (IMDSv2) について紹介しました。IMDSv2を用いることにより、SSRF攻撃をかなり緩和されることが期待できるものの、根本的な解決策ではなく緩和策の一つとして用いるべきと考えます。これは、Amazon自体がIMDSv2をdefense in depth(多層防御)とうたっていることからも伺えます。
また、SSRF攻撃の対策は難易度が高いため、可能であればSSRF攻撃の影響を受けない仕様(例えば外部から受け取ったURLにアクセスしない)の検討を推奨します。

2019年12月5日木曜日

シェルを経由しないOSコマンド呼び出しがPHP7.4で実装された

この記事はPHP Advent Calendar 2019の5日目の記事です。

はじめに

私は6年前に、PHP Advent Calendar 2013として「PHPだってシェル経由でないコマンド呼び出し機能が欲しい」という記事を書きました。その中で、OSコマンドインジェクション対策の根本的かつ安全な対策は「シェルを経由しないコマンド呼び出し」であることを指摘した上で、末尾に以下のように書きました。
PHPコミッタのみなさま、PHP5.6の新機能として、シェルを経由しないコマンド呼び出しの機能を追加できませんか?
現実には当時からPCNTL関数にてシェルを経由しないコマンド呼び出しはできたのですが、当関数の使用が難しいことと、CLI版あるいはCGI版(FastCGIは可)のPHPでないとサポートされていないなどの制限があり、popenやproc_openなど使いやすいコマンド呼び出し関数において、シェル呼び出しのないコマンド実行機能が欲しいところでした。

この「私の願い」はPHP 5.6では実現しませんでしたが、PHP 7.4においてproc_open関数の拡張として実現しました。実に6年越しの実現ということになります。

proc_openの従来の問題点

proc_openに限りませんが、PHPの従来のコマンド実行機能(PCNTL関数は例外)の問題として、「常にシェル経由でコマンドを呼び出す」ことがあります。これを確認するための簡単なサンプルを示します。以下は、ps -fコマンドをproc_open関数で呼び出しています。
<?php
$cmd = "ps -f";
$process = proc_open($cmd, [], $pipes);
if (is_resource($process)) {
    $return_value = proc_close($process);
    echo "command returned $return_value\n";
}
呼び出し例は下記となります。赤字で示しているように、シェル(/bin/sh)経由でpsコマンドが実行されています。
UID        PID  PPID  C STIME TTY          TIME CMD
ockeghem 16921 16920  0 16:31 pts/0    00:00:00 -bash
ockeghem 18858 16921  0 17:18 pts/0    00:00:00 php-7.4.0 proc_open4.php
ockeghem 18859 18858  0 17:18 pts/0    00:00:00 sh -c ps -f
ockeghem 18860 18859  0 17:18 pts/0    00:00:00 ps -f
command returned 0
このため、コマンドラインにセミコロンなどにより追加のコマンドを実行できる可能性があり、OSコマンドインジェクション脆弱性の原因になっていました。ここで、psのオプションとして、-fの代わりに、「-f; echo Hello」を指定してみましょう。
<?php
$cmd = "ps -f; echo Hello";
$process = proc_open($cmd, [], $pipes);
// 以下省略
呼び出し例は下記となります。sh -c のパラメータとして; echo Helloが追加されていることと、echoコマンドの実行結果としてHelloが表示されていることがわかります。これがOSコマンドインジェクションの原理です。
UID        PID  PPID  C STIME TTY          TIME CMD
ockeghem 16921 16920  0 16:31 pts/0    00:00:00 -bash
ockeghem 18932 16921  0 17:30 pts/0    00:00:00 php-7.4.0 proc_open4.php
ockeghem 18933 18932  0 17:30 pts/0    00:00:00 sh -c ps -f; echo Hello
ockeghem 18934 18933  0 17:30 pts/0    00:00:00 ps -f
Hello
command returned 0
この対策として、コマンドラインのパラメータをエスケープ処理する方法もありますが、エスケープ処理自体が複雑になる可能性があり、実際にPHPのescapeshellcmd関数には脆弱性(こちらを参照)があるため使用を避けるべき状態でした。

proc_openのPHP 7.4での新しい呼び出し方

この状況に対して、PHP 7.4では、proc_openの第1引数を配列として指定することにより、コマンドとパラメータを明確に分離するとともに、シェルを経由しないコマンド実行ができるようになりました(パチパチパチ)。
先のスクリプトをこの形式で書き換えてみましょう。
<?php
$cmd = ["ps", "-f"];
$process = proc_open($cmd, [], $pipes);
if (is_resource($process)) {
    $return_value = proc_close($process);
    echo "command returned $return_value\n";
}
実行例は以下となります。シェルを経由せずに直接コマンドが実行されていることがわかります。
UID        PID  PPID  C STIME TTY          TIME CMD
ockeghem 16921 16920  0 16:31 pts/0    00:00:00 -bash
ockeghem 18895 16921  0 17:27 pts/0    00:00:00 php-7.4.0 proc_open4.php
ockeghem 18896 18895  0 17:27 pts/0    00:00:00 ps -f
command returned 0
続いて、先程同様に、-f オプションの代わりに -f; echo Hello を指定してみましょう。
<?php
$cmd = ["ps", "-f; echo Hello"];
$process = proc_open($cmd, [], $pipes);
// 以下略
実行結果は以下のとおりです。psコマンドにオプションとして「-f; echo Hello」が渡されたため、「unsupported SysV option」というエラーになっていますが、OSコマンドインジェクションにはならないことがわかります。
error: unsupported SysV option

Usage:
 ps [options]

 Try 'ps --help <simple all="" list="" misc="" output="" threads="">'
  or 'ps --help <s a="" l="" m="" o="" t="">'
 for additional help text.

For more details see ps(1).
command returned 1
この呼出方法(proc_openの第一引数を配列で指定)の場合、シェルを経由しないでコマンドを呼び出すことから、原理的にOSコマンドインジェクション脆弱性を避けることができます。今後PHP 7.4以降にて外部コマンドを呼び出す場合は常に、proc_open関数にて第1引数を配列で指定し、かつ配列の先頭要素(コマンド名)は固定とすることで、OSコマンドインジェクションを避けつつ簡便かつ安全な実装が可能になります。

まとめ

PHP 7.4にて新たに追加されたproc_openの新しい呼び出し方を紹介しました。私個人としても、6年越しの要望がかなえられた結果となり、よいクリスマスを迎えられそうです。

フォロワー

ブログ アーカイブ