PHPでのSQLインジェクション対策 - エスケープ・クォート編: pg_escape_****
第四企画 坂井 潔
pg_escape_****によるエスケープ
PHPからPostgreSQLを操作するためのネイティブな関数としてpg_****関数群があります。データベースへの接続方法はプレースホルダ編を参照していただくことにして、ここではpg_escape_****を使ってエスケープ・クォートをする例を説明します。
以下のpg_escape_****や、その他の関数はすべて、Shift_JISではなく、UTF-8、EUC-JPなどをPHPの内部エンコーディングとして使うことを前提に記述しています。PostgreSQLとPHPのエンコーディングが別々だったり、Shift_JISなどを使用した場合、関数が思わぬ挙動をすることがあるので、ご注意ください。
PHPで用意されているpg_escape_string関数とpg_escape_bytea関数はそれぞれ文字列型、bytea型のデータを、PostgreSQLのSQLに埋め込むようにエスケープしてくれる関数です。数値用のエスケープ関数はありません。この理由に関しては後述するとして、まずはそれぞれの関数の使用例をみてみることにしましょう。
pg_escape_string
PHPはバージョン4.2.0より、PostgreSQLは7.2以降でpg_escape_string関数が使用できます。値を数値としてSQL内に埋め込む場合やpg_escape_bytea関数を使用する場合を除き、全ての文字列のエスケープにpg_escape_stringを使うことができます。この関数を使って値をエスケープ、クォートし、SQLに埋め込む例を見てみましょう。
$res = pg_query( $dbconn, 'SELECT userid, username, profile FROM users' . ' WHERE username = ' . '\'' . pg_escape_string($dbconn, $_REQUEST['username']) . '\'' ); $all = pg_fetch_all($res);
$res = pg_query( $dbconn, 'UPDATE users' . ' SET profile = ' . '\'' . pg_escape_string($dbconn, $_REQUEST['profile']) . '\'' . ' WHERE userid = ' . $_SESSION['me']['userid'] );
最初は単純なSELECT文の例です。まずはじめに気をつけなければならないのは、pg_escape_string関数は、エスケープをする関数であり、クォートはしてくれない、ということです。つまりpg_escape_stringから戻ってきた文字列は、必ずシングルクォートで囲んでからSQLに埋め込むようにしてください。シングルクォートで囲む書式を以下に幾つか例としてあげますが、どれも等価で、どれを使ってもかまいません。
$s = 'SELECT * FROM t WHERE c = \'' . pg_escape_string($dbconn, $c) . '\''; $s = "SELECT * FROM t WHERE c = '" . pg_escape_string($dbconn, $c) . "'"; $escaped = pg_escape_string($dbconn, $c); $s = "SELECT * FROM t WHERE c = '{$escaped}'";
UPDATE文の例では、$_REQUEST['profile']は通常の文字列エスケープが施してあるので、注意点はSELECT文の場合と同様です。異なるのは、$_SESSION['me']['userid']に関してエスケープ・クォートせず、そのまま文字列結合演算子('.')でSQLに埋め込んでいる点です。それは、この例において$_SESSION['me']['userid']の値が「数値として正当に埋め込むことのできる値が設定されている」ことを前提として扱っているからです。もしも値がブラウザなど外部から渡ってくる可能性がある場合には、事前にその値が数値として正当かどうかをチェックすることが必須になってきます。数値としてのチェックの具体的は方法に関しては、後述の「値を数値としてチェックする」を参照してください。
また、pg_escape_stringの第1引数の$dbconn(PostgreSQLの接続リソース)は、PHPのバージョンが5.2.0より前のものでは指定できません。指定されていない場合、この関数で使用される接続リソースは、pg_connect等で直近に作成されたものとなります。
standard_conforming_stringsによる挙動の違い
pg_escape_string関数の挙動はstandard_conforming_stringsの設定によって自動的に切り替わります。つまり、この設定がonの場合はシングルクォートのみがシングルクォートでエスケープ(' → '')され、offの場合はバックスラッシュとシングルクォートがエスケープされます(\ → \\、' → '')。 システム全体のポリシーとして、前もって決めておくべきことは、\n・\t・\uxxxxといったC形式エスケープでの文字列定数を、そのままの単なる文字列として扱うか、改行・タブ・16進数のUnicode文字値のように特殊文字として扱うのか、です。
PostgreSQLのバージョン9.0以前では、このデフォルト値はoffですが、9.1以降にはデフォルト値がonになることもあるので、筆者としては(この値が変更できるバージョン8.2以降であれば)むしろ積極的にstandard_conforming_stringsにonを設定し、改行・タブ・16進数のUnicode文字値のようなものは、バイナリとしてSQL内に記述することをお勧めします。
pg_query( $dbconn, 'SELECT \'' . pg_escape_string('1行目') . '\n' . pg_escape_string('2行目') . '\'' );
↓
pg_query($dbconn, 'SET standard_conforming_strings = TRUE'); pg_query( $dbconn, 'SELECT \'' . pg_escape_string('1行目 2行目') . '\'' ); /* または */ pg_query( $dbconn, "SELECT '" . pg_escape_string("1行目\n2行目") . "'" );
ただし、既に運用しているシステムで、文字列\n・\t等を特殊文字として広範囲で使用していたり、その他のなんらかの理由で、簡単には変更できないような場合もあるかもしれません。そのような時には、E'…' という形式でクォートしてやることで、これがSQL非標準(標準SQLの拡張)エスケープだと明示してやることも可能です。
pg_query($dbconn, 'SET standard_conforming_strings = FALSE'); pg_query( $dbconn, 'SELECT E\'' . pg_escape_string('1行目') . '\n' . pg_escape_string('2行目') . '\'' ); /* または */ pg_query( $dbconn, "SELECT E'" . pg_escape_string('1行目') . '\n' . pg_escape_string('2行目') . "'" );
バージョンが8.1.x以前のPostgreSQLを使用していたり、あるいはシステム上どうしてもE'…'形式のクォートにできないという場合は、standard_conforming_stringsにoffを設定してさえいれば、PostgreSQLは文字列\n・\t等を特殊文字として見なしてくれます。ただし8.2以降のバージョンでは、以下のような警告がログに出力され続けているかもしれません。
WARNING: nonstandard use of escape in a string literal LINE 1: SELECT '1行目\n2行目'; ^ HINT: Use the escape string syntax for escapes, e.g., E'\r\n'.
PostgreSQLのバージョンごとでのstandard_conforming_stringsの設定やエスケープの挙動の違いは以下のようになります。
PGのバージョン | standard_conforming_strings 'off' | standard_conforming_strings 'on' |
---|---|---|
〜 8.1.x | SELECT '\\'; → \ (警告なし) デフォルトの設定。不可変 |
設定できません。 |
8.2.x 〜 9.0.x | SELECT '\\'; → \ (警告あり) デフォルトの設定 |
SELECT '\\'; → \\ |
SELECT E'\\' → \ (警告なし) | ||
9.1.x 〜 | SELECT '\\'; → \ (警告あり) | SELECT '\\'; → \\ デフォルトの設定 |
SELECT E'\\' → \ (警告なし) |
繰り返しになりますが、PostgreSQLのバージョンはなるべく8.2以降にアップグレードし、standard_conforming_stringsにはonを設定、そして改行などは特殊文字でなくバイナリでSQLを記述することをお勧めします。standard_conforming_stringsの挙動に関しては、「SQL非標準のエスケープとstandard_conforming_strings」も参照してください。
bytea型の扱い
PostgreSQLではバイナリデータを扱う方法として、ラージオブジェクトの仕組みや、bytea型というデータ型が用意されています(バイナリデータをPostgreSQLには格納せず、使用しているOSのファイルシステムを使うという選択肢も勿論あります)。
bytea型の値はpg_escape_bytea関数でエスケープ・クォートする、というのがPostgreSQLの古くからの方法ですが、バージョン9.0以降では、このようなescape書式に加え、hex書式が追加になっています。これに伴いデフォルトの設定もhexに変更になっている上、エスケープ後のサイズもpg_escape_byteaを使ったescape書式に比べずいぶんコンパクトで効率的なようです。
hex書式
ですので、まずはこのhex書式の例を説明したいと思います。
if(is_uploaded_file(@$_FILES['userimg']['tmp_name'])) { $file = $_FILES['userimg']['tmp_name']; $imgtype = exif_imagetype($file); $res = pg_query( $dbconn, 'UPDATE users' . ' SET userimg = ' . '\'' . pg_escape_string( $dbconn, '\x' . bin2hex(file_get_contents($file)) ) . '\'' . ', userimgtype = ' . ($imgtype ? $imgtype : 'NULL') . ' WHERE userid = ' . $_SESSION['me']['userid'] ); }
file_get_contents関数で取得したファイルの内容(バイナリ)を、bin2hex関数で16進表現に変換します。その先頭に、これがhex書式であるということを明示する、'\x'という文字列を付加します。実をいうと、バージョン9.0および9.1のPostgreSQLでは、入力に関してのみescape書式とhex書式のどちらを使っても良いことになっています。ですので、hex書式を明示するためには、最初に'\x'という文字列を付加する必要があるのです。
その文字列に今度はpg_escape_string関数をかけます。本当は、bin2hex関数を使った時点で全てのデータは0〜9の数字とa〜fの文字に変換されるので、この部分はエスケープしする必要が(現時点では)ありません。ただし、文字列'\x'をエスケープすべきかどうかは、前述したstandard_conforming_stringsの設定で左右されますし、将来的にはエスケープ方法が何か別のものになるかもしれません。ですので、保険的な意味合いでもpg_escape_stringを使っておくことにしましょう。
bytea型のカラムに格納したバイナリデータは、pack関数でバイナリに戻します。
if(!empty($_REQUEST['userid']) && ctype_digit($_REQUEST['userid'])) { $res = pg_query( $dbconn, 'SELECT userimg, userimgtype FROM users' . ' WHERE userid = ' . $_REQUEST['userid'] ); $imgtype = pg_fetch_result($res, 'userimgtype'); if($imgtype) { header('Content-type: ' . image_type_to_mime_type($imgtype)); } echo pack('H*', substr(pg_fetch_result($res, 'userimg'), 2)); }
pg_fetch_result関数で取得したデータには、初頭に'\x'がついていますので、substr関数でまずその2バイトを取り除き、pack('H*', …)とすることで、バイナリに変換します。これでもとのバイナリが取得できる、という仕組みです。
escape書式 (pg_escape_byteaとpg_unescape_bytea)
PostgreSQLのバージョンが8.4.x以前の場合は、byteaにはescape方式しかありません。その場合は、バイナリデータをpg_escape_bytea関数でエスケープしてbytea型のカラムに格納します。また、bytea型のカラムから取得したデータはpg_unescape_bytea関数でアンエスケープすることで、もとのバイナリデータが取得できます。
PHPはバージョン4.2.0からpg_escape_bytea関数が、4.3.0からpg_unescape_bytea関数が使用できます。
if(is_uploaded_file(@$_FILES['userimg']['tmp_name'])) { $file = $_FILES['userimg']['tmp_name']; $imgtype = exif_imagetype($file); $res = pg_query( $dbconn, 'UPDATE users' . ' SET userimg = ' . '\'' . pg_escape_bytea($dbconn, file_get_contents($file)) . '\'' . ', userimgtype = ' . ($imgtype ? $imgtype : 'NULL') . ' WHERE userid = ' . $_SESSION['me']['userid'] ); }
if(!empty($_REQUEST['userid']) && ctype_digit($_REQUEST['userid'])) { $res = pg_query( $dbconn, 'SELECT userimg, userimgtype FROM users' . ' WHERE userid = ' . $_REQUEST['userid'] ); $imgtype = pg_fetch_result($res, 'userimgtype'); if($imgtype) { header('Content-type: ' . image_type_to_mime_type($imgtype)); } echo pg_unescape_bytea(pg_fetch_result($res, 'userimg')); }
hex書式の方がescape書式よりも効率的なので、あまりお勧めしませんが、PostgreSQL9.0以降でも、どうしてもpg_unescape_bytea関数でアンエスケープしたい場合には、PostgreSQLのbytea_output設定パラメータにescapeを設定してください。PHPスクリプト内で記述する場合は、
pg_query($dbconn, 'SET bytea_output = \'escape\'');
となります。あるいはpostgresql.confで
bytea_output = 'escape'
と書くこともできます。
bytea_outputの設定の違いによって、バイナリデータを変換する方法を簡単にまとめると以下のようになります。
bytea_output | PHP ⇒ PG | PG ⇒ PHP |
---|---|---|
escape (~8.4のデフォルト) | pg_escape_bytea | pg_unescape_bytea |
hex (9.0~のデフォルト) | bin2hex | pack('H*') |
数値をSQLに埋め込む
SQLに数値を埋め込むには大きく分けて、2つの考え方があります。1つめは、設定する値が数値で対象のカラムも数値型であっても、pg_escape_string関数を使用してエスケープ・クォートしてしまおう、という考え方です。この方法では、万が一数値にキャストできない文字列が埋め込まれた場合でも(文字エンコーディングさえ正しければ)仮にinvalid input syntaxなどのSQLエラーは発生したとしても、SQLインジェクションは発生しません。ただしこの場合、値は、カラムのデータ型へ暗黙のキャストが行われるため、数値精度の劣化や性能低下を引き起こす可能性があります。
もう1つは、数値を、文字列リテラルとしてエスケープ・クォートするのではなく、SQL内に数値そのもの(数値リテラル)として埋め込む、という考え方です。この方法では、対象のカラムのデータ型を意識してさえいれば、暗黙のキャストが発生することはありません。ここでは、こちらの考え方について説明を進めていきます。
PostgreSQLでの数値の例
値が数値の場合、一般の文字列などとは異なり、その値に空白文字列などが含まれることはありません。 数値定数 にも書かれているように、数値は、整数(1文字以上の数字の連続)、または小数(1文字以上連続した数字の、前後あるいは中間に、1つだけ小数点を含むもの)に、もし指数を表現するのであればその後、指数記号eとプラスあるいはマイナス(プラス時は省略可能)そして1文字以上の数字を付加する、という書式で表現されます。つまりPostgreSQLおいて数値は、十進数の数字(0~9)、小数点(.)、指数記号(e)、プラス記号(+)、マイナス記号(-)の文字だけが連続しているものとして表現されます。
PostgreSQLのドキュメントにもあるように、数値として見なされるのは以下のような例です。
42 3.5 4. .001 5e2 1.925e-3
実質的にはこれらの数値の前に+または-が(演算子としてですが)認められるので、例えば「-3.5」といった値も広義の意味では数値と言ってしまってもよいでしょう。そして数値として認識される文字列は、含まれる文字種が限定されるため、一般の文字列のようにクォートする必要がありません。ですから数値には、pg_escape_numericといった関数が用意されていないのです。逆に言えば、数値に関しては、それが数値として正当かどうかをSQLに埋め込む前にチェックしてやる必要がある、ということでもあります。
値を数値としてチェックする
ここではSELECT文のOFFSETに埋め込む値をブラウザから受け取るケースを想定し、渡された値を数値としてチェック、そしてSQLに埋め込むという例を見てみましょう。
if( empty($_REQUEST['offset']) || !ctype_digit($_REQUEST['offset']) || strnatcmp($_REQUEST['offset'], '9223372036854775807') > 0 ) { $offset = '0'; } else { $offset = $_REQUEST['offset']; } $res = pg_query( $dbconn, 'SELECT *' . ' FROM users' . ' ORDER BY userid' . ' OFFSET ' . $offset . ' LIMIT 20' ); $all = pg_fetch_all($res);
前提として、$_REQUEST['offset']としてブラウザから渡ってくる値は、配列か、あるいは文字列しかありえません。ですのでこのif文内の、empty関数の戻り値がtrueになるのは、$_REQUEST['offset']自体が渡ってきていないか、要素数が0の配列か、'0'、''の文字列です。いずれの場合もSELECT文のOFFSETに0を設定しても問題なさそうです。
次に、ctype_digit関数で、引数が「数字のみで構成された文字列」かどうかを判定します。※1。OFFSETは正の整数でなくてはならないので、本来あってはならない状態、つまり、文字列内に数字以外の文字が含まれていたり、配列だった場合には、ctype_digitはfalseが返されます。 この場合にもOFFSETは0にしてしまうことにしましょう。
※1 ctype_digit関数はPHPのバージョンが5.1.0より前では、引数が空文字列('')の場合でもtrueを返していました。この例ではempty関数でチェックしているので問題ありませんが、5.1.0より前のバージョンでctype_digit関数を使用するときは、空文字列の扱いに気をつけましょう。
また、PostgreSQLのOFFSETはbigint型です。bigintの最大値は9223372036854775807ですので、これを超えた値をOFFSETに指定すると
ERROR: bigint out of range
というエラーが発生してしまいますので、それを回避するためは、$_REQUEST['offset']がbigintの最大値以下だというチェックが必要になります。そのためには単純に、
if($_REQUEST['offset'] <= 9223372036854775807)) { /* OK? */ } /* または */ if($_REQUEST['offset'] <= '9223372036854775807')) { /* OK? */ }
としたくなりますが、これでは意図したように動作してくれません。
整数値を文字列($_REQUEST['offset'])と比較したり、比較に数値形式の文字が含まれる場合、PHPでは文字列が数値に変換され、数値としての比較を行います。また文字列を数値に変換する場合、それがPHPのintegerの範囲を超える場合には、floatとして評価されます。例えば、bigintをわずかに超えた、'9223372036854775808'をfloatに変換すると9.2233720368548E+18となり、実をいうとこれは'9223372036854775807'をfloatに変換した値と同じものになってしまうのです! floatの範囲になる数値の文字列を比較演算子で比べた場合にはどのような結果をもたらすかは、以下のスクリプトを実行してみるとはっきりするでしょう。
$ php -r 'var_dump("9223372036854775807" == "9223372036854775808");' $ php -r 'var_dump(9223372036854775808 === 9223372036854775809);'
すなわち、PHPのfloatの範囲になる可能性のある値を(私たちが意図した通りの)数値として比較することは、比較演算子ではできないということになります。そこで今回はstrnatcmp関数を使用して、数値文字列を「自然順」で比較するようにしました。上記のケースでは、strnatcmpの戻り値が、$_REQUEST['offset']がbigintの最大値より小さい場合は-1、同じ場合は0、大きな場合は1になります。ですので、-1、0の場合は$_REQUEST['offset']が正当である、そして、1(> 0)の場合は、bigintの最大値を超えるので、OFFSETの値としては使用できない、ということになるのです。
上記のようなチェックをクリアし、対象となるSQLに埋め込んでも大丈夫な数値として値が確認できた場合には、単純に文字列結合演算子('.')で連結すればよいのです。
カラムのデータ型ごとの数値のチェック
PostgreSQLでの数値データ型は、最小値・最大値、あるいは精度(格納サイズ)の違いを無視すれば、大きく4つ、あるいは3つに分類できます。
- integer系(smallint, integer, bigint)
- decimal系(decimal, numeric)
- float系(real, double precision)
- serial系(serial, bigserial)
このうち、serial、bigserialは、一旦カラムに格納してしまえば、あとはinteger、bigintと同じ挙動になるので、ほぼinteger系と考えてしまってもよいでしょう。integer系には(特に正の整数であることが条件であれば)ctype_digit関数が有効でしょうし、decimal系、float系にはis_numeric関数が有効です※2。また最小値・最大値のチェックには、integer系はstrnatcmp関数が有効ですし、decimal系、float系であればむしろ比較演算子でのチェックが有効かもしれません。いずれにせよ、それぞれのチェックはシステムの仕様が要求するものによって、臨機応変に対応してください。
※2 is_numeric関数を使う場合に気をつけなればならないのは、16進表記(0xFF)です。この16進表記は、is_numeric関数ではtrueになりますが、PostgreSQLではこれを、そのままの表記では数値として認めていません。事前にstripos(****, 'x')などで判定し、エラーにしてしまうか、整数値に変換してから埋め込んでください。
pg_escape_****でエスケープ・クォートする時のまとめ
- PHPの内部エンコーディングと、PostgreSQLの文字セットはできるだけUTF-8、少なくともEUC-JPを使いましょう。
- pg_escape_string, pg_escape_bytea関数はエスケープ関数であり、クォートはしません。戻り値をシングルクォートで囲んでからSQLに埋め込みましょう。
- standard_conforming_stringsはon、bytea_outputはhexの設定がおすすめです。バージョンができるだけ新しいPostgreSQLを使いましょう。
- 文字列のチェックもそうですが、特に数値のチェックは、システムの仕様はもちろんのこと、カラムのデータ型に依っても大きく変わってきます。SQLに埋め込む前に行うチェックが、必要十分なものになるよう注意しましょう。