2015年2月23日月曜日

PHPのmb_ereg関数群は不正な文字エンコーディングをチェックしない

PHPのbasename関数には、マルチバイトに対応していないという誤解(実際にはロケールの設定をすればマルチバイトでも使える)があったり、不正な文字エンコーディングをチェックしないという課題があったりで、イマイチだなーと思っている方も多いと思います。
そういう方々が、preg_replace(u修飾子つき)やmb_ereg_replaceを用いて代替関数を作成している解説も見かけますが、それではこれら正規表現関数は不正な文字エンコーディングをチェックしているのだろうかという疑問が生じます。
ざっと調べたところ、以下の様な状況のようです。
  • preg_replace : 不正な文字エンコーディングをチェックしている
  • mb_ereg_replcae : 不正な文字エンコーディングをチェックしていない
ここでは、mb_ereg_replaceが不正な文字エンコーディングをチェックしない状況と、その影響について報告します。

不正な文字エンコーディングとは

マルチバイトの文字を表現するエンコーディング(Shift_JIS、EUC-JP、UTF-8など)には、バイト列の並びのルールが決まっています。例えば、Shift_JISの2バイト目としてありえるバイト値とあり得ないバイト値があります。UTF-8の場合は、先頭バイトで文字のバイト数が決まり、2バイト目以降に使えるバイトは 0x80 ~ 0xBF と決まっています(参考)。
これらの決まりに従わないバイト列は、「不正な文字エンコーディング」ということになります。また、UTF-8には「非最短形式」というものがあり、禁止されているので、これも不正な文字エンコーディングの一種です。

mb_ereg_replaceは不正な文字エンコーディングをどう扱うか?

ここでは、UTF-8の場合を例として、mb_ereg_replaceが不正な文字エンコーディングのデータをどのように処理するかを見てみます。
まず、不正なデータとして以下を例に取ります。
$a = "\xFC../..";  // 6バイト
このデータは以下の意味でUTF-8として不正です。
  • 0xFCは、かつてUTF-8の6バイト形式として定義されていたが、現在は5バイト以上の形式は禁止されている
  • 2バイト目以降が 0x80~0xBF の範囲にない
PoCは以下の通りです。
<?php
  mb_regex_encoding('UTF-8');
  $a = "\xFC../..";
  $s = mb_ereg_replace('[\./]', '', $a);  // . と / をすべて取り除く
  echo bin2hex($s) . "\n";
結果は以下の通り。
fc2e2e2f2e2e
先頭の0xFCの影響を受けて、2バイト目以降の . や / は取り除かれていません。ここから以下のことが分かります。
  • mb_ereg_replaceはUTF-8の6バイト形式を許容している
  • mb_ereg_replaceはUTF-8の2バイト目以降のバイト値の範囲をチェックしていない
この結果は私には衝撃的だったのですが、ディレクトリトラバーサル脆弱となる現実的なシナリオは作れませんでした。腕自慢の方は挑戦してみてください。

脆弱となる例

ディレクトリトラバーサルの例は作れませんでしたので、XSSならどうだろうと思い、ちょっと人工的ですが、不正な文字エンコーディングによるXSS脆弱性の例を考えてみました。以下のスクリプトはmb_ereg_replaceを使った「手作りの」HTMLエスケープ関数を使っています。
<?php
  header('Content-Type: text/html; charset=UTF-8');
  function mb_htmlescape($s) {
    mb_regex_encoding('UTF-8');
    $s = mb_ereg_replace('&', '&amp;', $s);
    $s = mb_ereg_replace('<', '&lt;', $s);
    $s = mb_ereg_replace('>', '&gt;', $s);
    $s = mb_ereg_replace('"', '&quot;', $s);
    return $s;
  }
?>
<html><body>
<?php echo mb_htmlescape($_GET[x]); ?>
</body></html>
このスクリプトに対して、x=<script>alert(1);</script> というクエリ文字列を与えると以下のように正しくエスケープ処理が行われています。


しかし、以下のクエリ文字列だと、JavaScriptが起動されてしまいます。
x=%C2<script+%C2>alert(1);//%C2</script+%C2>

%C2はUTF-8の2バイト形式の1バイト目になるバイト値です。これを■で表現すると、入力文字列は下記の通りです。
■<script ■>alert(1);//■</script ■>
そして、この文字列は、先の手作りエスケープ関数を素通りしてそのままブラウザに送られます。
ブラウザ側では、この■<等が「不正なUTF-8文字」と認識して、■と < という別々の文字として扱われます。この「不正な文字に対する扱いの差異」が脆弱性の原因です。

対策

以下の対策を推奨します。
  • エスケープ関数等セキュリティ目的の処理は極力自作しない(htmlspecialchars関数では上記の問題は起きない)
  • 各入力値についてmb_check_encoding関数により文字エンコーディングの妥当性チェックを行う

まとめ

PHPのhtmlspecialcharsはかつていけてなかった(参考)とか、basename関数は今もいけてないなどの理由でこれらの関数を自作したくなる衝動にかられる場合がありますが、中途半端に自作するとかえって危険になる場合があります。PHPの主要な関数群は色々ディスられながら改良され、安全になってきた歴史がありますので、よほど明確な理由がない限りはPHPで提供されているものを使うほうがよいと考えます。
また、PHPが提供する関数群には文字エンコーディングのチェックを厳密に行うもの(mb_check_encoding、htmlspecialchars等)と、チェックをあまりしないもの(mb_strlen、basename、mb_ereg等)がありますので、入力値のバリデーション時にmb_check_encodingで文字エンコーディングの妥当性を確認しておくとよいでしょう。

2015年2月13日金曜日

PHPのbasename関数は不正な文字エンコーディングをチェックしない

昨日のエントリにて、PHPのbasename関数はマルチバイト文字を扱えることを説明しましたが、このブログの読者であれば、きっとbasename関数は不正な文字エンコーディングについてどの程度チェックするのかという疑問が生じたことでしょう(きっぱり)。実はbasename自体は、不正な文字エンコーディングをチェックせず、垂れ流してしまいます。その理由をbasenameのソースコードで確認してみましょう。以下は、basename関数の実装の一部です。
// ext/standard/string.c
// php_basenmae()
while (cnt > 0) {
  inc_len = (*c == '\0' ? 1: php_mblen(c, cnt));

  switch (inc_len) {
    case -2:
    case -1:
      inc_len = 1;
      php_ignore_value(php_mblen(NULL, 0));
      break;
    case 0:
      goto quit_loop;
php_mblen関数はmblen(3)のラッパーです。mblen関数は文字列の先頭文字のバイト数を返す関数で、先頭の文字が不正なエンコーディングの場合 -1 を返します。上記のソースでは、mblenが-1を返した場合は、inc_len=1として正常な1バイト文字と見なして処理を継続しています。
一方以下は、シェル呼び出しのエスケープを行うescapeshellarg関数の実装ですが…
// ext/standard/exec.c
// php_escape_shell_arg()
for (x = 0; x < l; x++) {
  int mb_len = php_mblen(str + x, (l - x));

  /* skip non-valid multibyte characters */
  if (mb_len < 0) {
    continue;
  } else if (mb_len > 1) {
    memcpy(cmd + y, str + x, mb_len);
    y += mb_len;
    x += mb_len - 1;
    continue;
  }
  switch (str[x]) {
    // 文字に応じた処理
コメントにあるように、不正な多バイト文字はスキップする、すなわち除去(フィルタリング)されます。

同じmblen関数を使っていても、basename関数とescapeshellarg関数では、不正な文字エンコーディングに対する対処方法が違っています。ともかく、basename関数は、不正な文字エンコーディングをエラーとせず、結果の中に含めてしまいます。

不正な文字エンコーディングの影響の考察(Windowsの場合)

basename関数が不正な文字エンコーディングのチェックをしていないことによるセキュリティ上の影響はないでしょうか。具体的に確認するために、まずWindowsの場合について検討します。すなわち、ファイル名が Shift_JIS でエンコーディングされているとします。
ディレクトリトラバーサル攻撃の攻撃パターンとしては、絶対パスによるものと相対パスによるものがありますが、絶対パスの場合ファイル名の冒頭に / や \ が来る必要があり、これは「不正な文字エンコーディング」にはなりえません。相対パスの方は、 ../ などシングルバイトの文字が連続して続く必要がありますが、/や\が単独の場合は単に除去され、その前にマルチバイト文字の先行バイトがある場合でも、前述の理由から先行バイトは除去されません(..■/ の形になる)。.その後 / までが除去される可能性が高いですが、仮に除去されない状況でも、..■/ と余計な文字がはさまっているため、攻撃パターンを形成しないと思われます。

不正な文字エンコーディングの影響の考察(Linuxの場合)

次にLinuxの場合について考えます。文字エンコーディングは UTF-8 とします。この場合、basename関数はUTF-8の冗長表現を通してしまいます。
これを検証するためのスクリプトを以下に示します。\xC0\xAF は / をUTF-8の2バイト表現にしたものです。
<?php
  setlocale(LC_CTYPE, 'ja_JP.UTF-8');
  echo bin2hex(basename("..\xC0\xAFaaa")), PHP_EOL;
出力は下記となります。c0afがそのまま出力されていることがわかります。
2e2ec0af616161
UTF-8の冗長表現が許可されるというと、NimdaワームやTomcatの脆弱性CVE-2008-2938を思い出す人も多いと思います。「それは問題ではないか」と思うところですが、現実には問題になるケースはほとんどないと考えられます。

その理由は下記のとおりです。
  • Linuxで使われるファイルシステムでは、\xC0\xAF等はそのままのバイト列としてファイル名に使われ、ディレクトリ区切子とは認識されない
そこで次の可能性は、UTF-8の冗長表現表現としてbasenameをパスした文字列が、その後文字エンコーディング変換されてシングルバイトの / に変換されることですが、
  • そもそもbasenameの後に文字エンコーディング変換をすることはよろしくない(参考
  • PHPで文字エンコーディング変換に使用される mb_convert_encodingとiconvはどちらもUTF-8の冗長表現をエラーにするかフィルタリングするので、攻撃文字列は形成されない
ということで、basename関数が冗長なUTF-8エンコーディングを許容しても、実害が出るケースはほとんどないと考えられます。実害があるとすると、独自実装の脆弱性のある文字エンコーディング変換機能を利用している場合ですが、その場合でも文字エンコーディング変換後にbasename関数を通すという正しい手順を踏んでいれば、問題は顕在化しません。

緩和策

basename関数は不正な文字エンコーディングを許容することが分かりましたが、これによる実害はほとんどなさそうです。ただし、外部から与えられたファイル名で新規にファイルを作成する場合は、変なファイル名のファイルができてしまいます。
いずれにせよ、以下をアプリケーションの仕様として決めておくとよいでしょう(再掲)。
  • ファイル名に用いる文字の種類
  • ファイル名を表現する文字エンコーディング
  • ファイル名の長さの最小値・最大値
そして、以下を推奨します。
  • 文字エンコーディングの変換はbasenameを通す前に行うこと
  • basename関数を呼ぶ前にlocaleを設定すること
  • ファイル名の仕様を決める
  • ファイル名が(文字エンコーディングを含め)仕様を満たすかどうかバリデーションにより確認する

まとめ

PHPのbasename関数が不正な文字エンコーディングを許容してしまうことを説明しました。この問題は一応bug#68773として報告済みですが、報告から1ヶ月以上たってもアサインもされていませんので、少なくともすぐに修正される可能性は薄そうです。幸い実害もあまりなさそうですが、念のためバリデーションにより文字エンコーディングのチェックをしておくと安心です。
PHPの文字列は単なるバイト列ですので、一般論として、アプリケーションの開始時に文字エンコーディングのチェックをしておくことにより、不正な文字エンコーディングの文字を弾いておくことをお勧めします。アプリケーションの前提条件を満たしていない入力を予め除外しておくことでアプリケーションの安定動作のために寄与します。
また、basenameの現在の実装は少々いただけないと考えます。せっかくmblenが不正な文字エンコーディングをチェックして -1 を返しているのに、そのエラーを「なかったことに」しているからです。一方、シェルのエスケープを行うescapeshellargの方は不正な文字エンコーディングをフィルタリングしているわけで、同じPHPの中で一貫性のない挙動というのも(PHPらしいといえばそれまでですが)よくないように感じました。

2015年2月12日木曜日

PHPのbasename関数でマルチバイトのファイル名を用いる場合の注意

まずは以下のサンプルをご覧ください。サーバーはWindowsで、内部・外部の文字エンコーディングはUTF-8です。UTF-8のファイル名を外部から受け取り、Windowsなのでファイル名をShift_JISに変換してファイルを読み込んでいます。basename関数を通すことにより、ディレクトリトラバーサル対策を施しています。
<?php
  header('Content-Type: text/plain; charset=UTF-8');

  $file_utf8 = basename($_GET['file']);
  $file_sjis = mb_convert_encoding($file_utf8, 'cp932', 'UTF-8');
  $path = './data/' . $file_sjis;
  var_dump($path);
  readfile($path);

しかし、ディレクトリトラバーサル対策は十分でなく、このスクリプトには脆弱性があります。下図は、ディレクトリトラバーサル攻撃により、このスクリプトの中身を読み出しているところです。


以下、このスクリプトの問題点、さらにはbasename関数を用いる際の注意点について説明します。

basename関数はマルチバイト対応していないという誤解

ネットの記事を見ていますと、basename関数はマルチバイト対応していないという主張をよく見かけます。例えば、下記の記事。
basename関数はパスからディレクトリ情報を削除してベース名(「test.txt」など)を取得するための関数ですが、PHP5(使用バージョン:PHP 5.3.1)ではパスに日本語が含まれていると失敗します。

▼失敗するbasename関数
<?php
$a = "/dir/テスト.txt";
echo basename($a);
?>
ファイル名などに日本語が含まれるパスでbasename関数が失敗するバグより引用
このスクリプトをWindowsサーバー上で動かすと、確かに下図のようにファイル名が化けます。下図ではファイル名の16進数表記を参考のためつけています。「テ」の先頭1バイト0x83が欠落しています。



localeに注意

しかし、このスクリプトはbasename関数の使い方に問題があります。basenameのマニュアルには以下の注意書きがあります。
注意:
basename() はロケールに依存します。 マルチバイト文字を含むパスで正しい結果を得るには、それと一致するロケールを setlocale() で設定しておかなければなりません。
これに従い、先のスクリプトを修正してみます。
<?php
$a = "/dir/テスト.txt";
setlocale(LC_CTYPE, 'Japanese_Japan.932'); // 追加
echo basename($a);
こうすると、下図のように正しい結果が得られます。マルチバイト環境でbasename関数を利用するにはlocaleの設定が必要であることが分かります。


冒頭の脆弱なスクリプトへの攻撃法

ここで、冒頭の脆弱なサンプルがなぜ問題かを説明します。このスクリプトにもlocaleの指定がありませんが、それが根本原因ではありません。
まず、攻撃に用いる文字列を示します。以下のクエリ文字列により攻撃が可能です。
..%C2%A5vulbasename.php
%C2%A5はUnicodeの円記号(U+00A5)のUTF-8表記をパーセントエンコードしたものです。これをデコードすると以下の通りです。
..¥vulbasename.php
「¥」は前述のとおり円記号U+00A5です。さらに、これをcp932に変換することになりますが、この際に「¥」U+00A5がバックスラッシュ「\」0x5Cに変換されます。
..\vulbasename.php
このため、組み立てられるパス名は以下の通りとなります。これは典型的なディレクトリトラバーサル攻撃の文字列ですね。
./data/..\vulbasename.php

文字エンコーディング変換するタイミングに注意

basename関数を通しているにもかかわらず攻撃が成立してしまう理由は、以下の通りです。
  • 円記号U+00A5は、basenameの処理対象の文字ではない
  • basenameの処理の後に、文字エンコーディング変換によりU+00A5が0x5Cに変化する
正しい手順は下記のとおりです。
  • まず文字エンコーディングを変換する
  • その後にbasename関数を通す
スクリプトとしては下記のとおりです。
$file_utf8 = $_GET['file'];
$file_sjis = mb_convert_encoding($file_utf8, 'cp932', 'UTF-8'); // 文字エンコーディング変換
setlocale(LC_CTYPE, 'Japanese_Japan.932'); // locale設定
$file = basename($file_sjis);
$path = './data/' . $file;
このスクリプトに対して先の攻撃をかけても、以下のようにスクリプトの中身は表示されません。


basenameはファイル名として妥当な文字種のチェックをしない

basename関数のソースを読むとすぐ分かりますが、basename関数がチェックするのはスラッシュ(全プラットフォーム共通)、バックスラッシュとコロン(Windows等)のみです。特にWindowsの場合ファイル名に使える文字に制約がありますが、basenameはそのチェックはしません。長さのチェックもしません。
このため、ファイル名を外部から受け取る場合、ファイル名の仕様として以下を決めておくとよいでしょう。
  • ファイル名に用いる文字の種類
  • ファイル名を表現する文字エンコーディング
  • ファイル名の長さの最小値・最大値
特に、外部から受け取ったファイル名でファイルを新規作成する場合は、受け取ったファイル名が仕様を満たすことをバリデーションで確認する必要があります。既存のファイルをオープンするだけであれば、バリデーションが必須かどうかは悩ましいところですが、一般論として入力値が前提条件(仕様)を満たすことのチェックとしてバリデーションはしておくべきだと思います。

まとめ

PHPのbasename関数を用いる上での注意点を説明しました。まとめると以下のようになります。
  • 文字エンコーディングの変換はbasenameを通す前に行うこと
  • basename関数を呼ぶ前にlocaleを設定すること
  • ファイル名の仕様を決める
  • ファイル名が仕様を満たすかどうかバリデーションにより確認する

2015年2月7日土曜日

GHOST脆弱性を用いてPHPをクラッシュできることを確認した

GHOST脆弱性について、コード実行の影響を受けるソフトウェアとしてEximが知られていますが、PHPにもgethostbynameという関数があり、libcのgethostbyname関数をパラメータ未チェックのまま呼んでいます。そこで、PHPのgethostbynameを用いることでPHPをクラッシュできる場合があるのではないかと考えました。

試行錯誤的に調べた結果、以下のスクリプトでPHPをクラッシュできることを確認しています。CentOS6(32bit/64bitとも)、Ubuntu12.04LTS(32bit/64bitとも)のパッケージとして導入したPHPにて確認しましたが、phpallで確認した限りPHP 4.0.2以降のすべてのバージョンのPHPで再現するようです。なぜかPHP 4.0.0と4.0.1では再現しませんでした。
<?php
    gethostbyname(str_repeat('0', 1027));
    gethostbyname(str_repeat('0', 1028));
CentOS6.5(32ビット)での実行の様子を下図に示します。
$ php gethostbyename-vul.php
*** glibc detected *** php: realloc(): invalid next size: 0x08b0b118 ***
======= Backtrace: =========
/lib/libc.so.6[0x92de31]
/lib/libc.so.6[0x9330d1]
/lib/libc.so.6(realloc+0xdc)[0x93326c]
/lib/libc.so.6(__nss_hostname_digits_dots+0x373)[0x9b5d13]
/lib/libc.so.6(gethostbyname+0x9a)[0x9bab6a]
php[0x8153b01]
php[0x8260729]
php(execute+0x1ce)[0x8236f4e]
php(zend_execute_scripts+0x66)[0x820f0c6]
php(php_execute_script+0x1e6)[0x81b5b76]
php[0x829feeb]
/lib/libc.so.6(__libc_start_main+0xe6)[0x8d3d26]
php[0x80622d1]
======= Memory map: ========
00101000-00129000 r-xp 00000000 fd:00 1053082    /usr/lib/libsmime3.so
00129000-0012b000 r--p 00027000 fd:00 1053082    /usr/lib/libsmime3.so
【中略】
00bee000-00bef000 rw-p 00007000 fd:00 1062164    /usr/lib/php/modules/json.so
アボートしました (コアダンプ)
$
上記はコンソール上での実行ですが、Webからの呼び出しでもPHPがクラッシュします。
このサンプルではgethostbynameを2回呼んでいますが、メモリの割り当て状況によっては1回の呼び出しでクラッシュさせることもできるでしょう。上記はあくまでPoCです。

既存のアプリケーションに対して外部からの攻撃によりPHPをクラッシュさせる、さらには任意のコードを実行させるのは困難と予想しますが、影響が皆無というわけではないでしょうから、以下を確認しておくと安全です。
  • GHOST脆弱性に対するパッチを適用していれば問題ない
  • PHPのgethostbyname関数を呼び出していなければ問題ない
  • gethostbyname関数を呼び出していても、引数を外部から制御できなければまず問題ない
  • gethostbyname関数を呼び出し、かつ引数が外部から制御できるが、バリデーションにより1024バイト以上の引数を禁止していれば問題ない
  • PHP 5.6.6、PHP 5.5.22(現在はRC1)からはgethostbynameの引数が255バイトまでに制限されるので問題ない
すなわち、gethostbynameの引数を外部から制御できるアプリケーションがある場合は、GHOST脆弱性に対するパッチを適用する(強く推奨)か、どうしてもすぐにパッチを出来ない場合は、gethostbynameの引数をバリデーションにより255バイト以下等に制限することを推奨します。あるいは、PHP5.6.6、PHP5.5.22が公開された後にこれらにバージョンアップすることでも解決します。

2015年2月3日火曜日

EximのGHOST脆弱性の影響とバリデーションの関係

追記(2015/2/6)

大垣さんから訂正依頼のコメントを頂いておりますので合わせてお読みください。徳丸としては特に訂正の必要は感じませんでしたので、本文はそのままにしています。そう思う理由はコメントとして追記いたしました。 (追記終わり)

大垣さんのブログエントリ「GHOSTを使って攻撃できるケース」を読んだところ、以下のようなことが書いてありました。
1. ユーザー入力のIPアドレス(ネットワーク層のIPアドレスではない)に攻撃用データを送る。
2. バリデーション無しで攻撃用の不正なIPアドレスをgethostbyname()に渡される。
3. ヒープオーバーフローでヒープ領域のメモリ管理用の空きサイズを改竄する。

【中略】

どんなソフトウェアが危ないのか?

  • ユーザー入力のIPアドレスをバリデーションしないでgethostbyname()を使用している。
  • インタラクティブな動作を行っている。(攻撃者からの入力に対してレスポンスがある)
  • ソフトウェアが持つ実行機能が利用できる。
【中略】

どうすれば守れたのか?

EximはメールサーバーなのでIPアドレスはネットワーク層からだけでなく、ユーザー入力としても処理しています。IPアドレスとしてあり得ないデータをアプリでエラーにしていれば攻撃はできませんでした。入力データとしてあり得ないデータをライブラリに直接渡すとリスクが伴います。入力バリデーションを行っていれば問題となりませんでした。
  • 入力バリデーションを行い、おかしな入力は拒否する
glibcの脆弱性の有無に関わらず、これだけしていれば任意コマンドの実行という最悪の事態は防げました
この記事には以下の問題があると感じました。
  • 攻撃経路となる入力値はIPアドレスではなくホスト名である
  • 攻撃を防げるとするバリデーションの仕様が明記されていない
  • Eximは本当にバリデーションをしていないのか
以下、説明します。

攻撃経路となる入力値はIPアドレスではなくホスト名である

Qualysの発表したEximに対する攻撃手法によると、攻撃はSMTPのHELOコマンドを経由して、数字とドットのみの「ホスト名」をgethostbyname()に送ることが行われます。GHOST脆弱性の性格から、攻撃に用いる文字は数字とドットのみですが、HELOコマンドが受け取るのはホスト名ですから、IPアドレスとしてバリデーションするわけにはいきません。また、gethostbyname()が受け取る引数もbynameとあるようにホスト名です。
大垣さんは、攻撃文字列が数字のみなのでこれをIPv4形式のIPアドレスと誤認されたようですが、これは間違いということになります。

攻撃を防げるとするバリデーションの仕様が明記されていない

前項と関連しますが、大垣さんは、攻撃が防げるとするバリデーションの要件を明示していません。おそらく、IPv4としてのバリデーションを想定しておられたのでしょうが、これは間違いですので、前提条件が崩れたことになります。
それでは、「大垣さんだったらホスト名に対してどんなバリデーションをするだろうか」と考えてみましたが、私が勝手に考えても失礼にあたると思いますので、大垣さんの著書「Webアプリセキュリティ対策入門 ~あなたのサイトは大丈夫?」から、似て非なる例としてメールアドレスのバリデーション関数を下記に引用します。
function validate_email($str, $check_dns=true, $mode=V_EXIT) {
  // preg_matchもバイナリセーフ
  $error = !preg_match ('/^(([a-zA-Z0-9])+([a-zA-Z0-9\._-])*@([a-zA-Z0-9_-])+([a-zA-Z0-9\._-]+)+)$/', $str);
  list(,$domain) = split('@',$str);
  // checkdnsrr()はWindowsでは実装されていません。
  if (!$error && !strstr(PHP_OS,'WIN') && !checkdnsrr($domain,'MX')) {
    $error=false;
  }
  if ($mode == V_RETURN) {
    return $error;
  }
  if ($error) {
    trigger_error('不正なメール形式を検出しました。');
  }
}
preg_matchの中の赤字の部分がドメイン名のチェックです。正規表現がRFC準拠でないことや、ドメインパートにアンダースコアを許容していることや、量化子「+」がネストしていることが気になる人もいるでしょうが、本題ではないので流します。
この関数は、メールアドレスを正規表現でチェックした後、PHPのcheckdnsrr関数を用いて、ドメイン名に対するMXレコードが存在することをチェックしています。これは、大垣さんの主張であるgethostbyname()を呼ぶ前にバリデーションを行うようにという処理とよく似ていますし、ホスト名やドメイン名のチェックを外部APIを呼び出して行う点もよく似ていますので、ここでは上記の正規表現が、「大垣流のドメイン名(ホスト名)バリデーション」と想定して議論を行います。
Qualysのアドバイザリによると、GHOST攻撃に用いる文字列は数字とドットだけからなる長い(1Kバイトを超える(後述))文字列ですが、上記の正規表現はこの攻撃文字列を許容します。すなわち、上記のバリデーションではGHOST攻撃を防げません
したがって、「入力値をバリデーションすれば攻撃を防げる」というものではなく、当然のことながら、バリデーションの仕方によって防御の効果は変わってきます

Eximは本当にバリデーションをしていないのか

大垣さんは、「バリデーション無しで攻撃用の不正なIPアドレスをgethostbyname()に渡される」と、Eximはgethostbyname()に渡す文字列をバリデーションしていないと断定していますが、本当にそうでしょうか。
Eximのソースコードおよびデバッグログを用いて確認したところ、Eximはgethostbyname()を呼ぶ前に、smtp_in.c内のcheck_helo()関数でホスト名をチェックしています。
static BOOL
check_helo(uschar *s)
{
  // 中略
  if (*s == '[')
    /* 中略 IPv6形式のIPアドレスの処理 */
  else if (*s != 0)
    {
    yield = TRUE;
    while (*s != 0)
      {
      if (!isalnum(*s) && *s != '.' && *s != '-' &&
          Ustrchr(helo_allow_chars, *s) == NULL)
        {
        yield = FALSE;
        break;
        }
      s++;
      }
    }
  }
// 後略
赤字で示したように、HELOコマンドのパラメータ(ホスト名)として、英数字、ドット、ハイフン、helo_allow_chars配列の文字(デフォルトは空だが設定により変更可能)のみを許可しています。
上記は、細かい違いはあるものの、大垣さんのvalidate_email()関数のドメインパートのバリデーションロジックと概ね同じです。
このように、Eximはホスト名をバリデーションすることなくgethostbyname()に渡しているとする大垣さんの記述は間違いです。おそらく、前述のように、gethostbyname()に渡すパラメータをホスト名ではなくIPアドレスと勘違いされていたので、「IPアドレスとしてのバリデーションをしていない」と推測されたものと思いますが、ソフトウェアのソースコードや動作を確認することなく「Eximはバリデーションしていない」と断定したことは軽率な行為であると考えます。

どうすればよかったか

Qualysのアドバイザリでは、攻撃に用いる文字列(gethostbynameの引数)の要件として下記の記述があります。
It must be long enough to overflow the buffer. For example, the non-reentrant gethostbyname*() functions initially allocate their buffer with a call to malloc(1024) (the "1-KB" requirement).
Eximが呼び出しているのは、まさに the non-reentrant gethostbyname*() functions ですので、攻撃には1KB以上の長い文字列が必要ということになります。

一方、ホスト名の長さについては、RFC1123には以下の記述があります。
Host software MUST handle host names of up to 63 characters and SHOULD handle host names of up to 255 characters.
試訳
ホストソフトウェアは63文字までのホスト名を扱わなければならず(MUST)、255文字までのホスト名を扱うべき(SHOULD)である。
すなわち、ホスト名の上限は255文字としていればRFC上問題はないため、積極的に255文字を超えるホスト名をエラーにしていれば、GHOST脆弱性の影響は受けないことになります。

とは言うものの、RFC1123の要件としては、256文字以上のホスト名を扱ってもとくに問題はないように思えます。すなわち、ホスト名の上限の仕様には以下の選択肢があることになります。
  • 63文字(MUST)
  • 255文字(SHOULD)
  • 256文字以上の有限値(任意)
  • 無制限(任意、Eximが該当)
どれを選択するかはアプリケーション要件です。長さを短くすると利便性は減ります(短所)が、未知の攻撃の際に *たまたま* 防御できる可能性がでてくる(長所)ということです。そして、Eximはホスト名の長さ制限を設けていなかったためホスト名の自由度は高い(長所)ものの、他の諸々の要因とあいまってGHOST脆弱性の受けてしまった(短所)ということです。

現実問題として、255文字を超えるホスト名を扱うことはまずないでしょうから、長さの制限を設けておけばよかったのになぁとは思います。

まとめ

EximがGHOST脆弱性の影響を受ける問題とバリデーションとの関係について説明しました。Eximは、まったくバリデーションをしていなかったわけではなく、ホスト名の文字種制限(バリデーション)はしていましたが、長さの制限はしていません。そして、長さを適当に(255文字以下、あるいは1023文字以下)制限していれば、GHOST脆弱性の影響は受けませんでした。

では、バリデーションはどのように考えればよいでしょうか。従来から言っていることですが以下を推奨します。
  • バリデーションの基準はアプリケーションの仕様である
  • 仕様を満たさない入力値は再入力を促すナビゲーションを行う
  • アプリケーション仕様として、すべての入力パラメータの以下項目を定義しておく
    • 文字種
    • 文字列長の最小・最大値
    • 数値の場合は最小値・最大値
    • メールアドレス等は書式
文字列長に関しては、制限をとくに設けていないアプリケーションも多いと思いますが、実用性を損なわない範囲で上限値を定めて、その上限まで正常に動作することと、上限値を超えた場合にエラーになることをテスト項目に加えるとよいでしょう。
バリデーションのセキュリティ上の効果については以下のように考えます。
  • バリデーションの基準はアプリケーション仕様(再掲)
  • Eximの場合のように、バリデーションの仕方によっては脆弱性が顕在化しなかったという例は結構ある(参考: Drupalの例
  • しかし、バリデーションは確実な対策ではないので、脆弱性対策をおろそかにしてはいけない
  • そのため開発者の心構えとしては、バリデーションによる防御効果はないものとして脆弱性対策をすること
  • バリデーションはしておこう。それがセーフティネットになる場合もある

2015年1月22日木曜日

SQLインジェクション対策もれの責任を開発会社に問う判決

1月13日に、北大の町村教授による興味深いブログ記事が発表されました。
この記事によると、SQLインジェクション脆弱性が原因でクレジットカード情報が漏洩した事件につき、ショップ側が開発会社を相手取り損害賠償請求の裁判を起こし、ショップ側が勝訴したとのことです。

判決はこのURLで読むことができます。以下、判例時報2221号に掲載された判決文も参照しながらエントリを書くことにします。

事実および裁判所の判断

まずは、事実および東京地裁の判断を説明します。

登場人物は下記の通りです。
  • X株式会社: インテリア商材の通信販売を営む(原告)
  • Y株式会社: システム開発の会社(被告)

概要
X社が運営するECサイトに対して、外部からの不正アクセスにより、最大7316件のクレジットカード情報が漏洩した。X社は謝罪、対応、調査等の費用、売上減少による損害等に関して、Y社に対して、委託契約の債務不履行にもとづき1億円余りの損害賠償を請求、東京地裁に起訴した。結果、原告が勝訴し、東京地裁は約2262万円の損害賠償金を支払うよう被告に命じた(確定)。

時系列まとめ:
日時事実
2009年1月30日X社とY社が業務委託基本契約書を締結
2009年2月4日X社がY社にウェブサイト向け商品受注システムを発注(889万円余)
2009年4月15日ウェブサイト稼働開始。この時点ではクレジットカード情報はサーバーに保存せず
2010年1月26日X社は顧客のクレジットカード情報をX社の基幹システムに転送する旨の仕様変更をY社に発注(31万5千円)
2010年1月29日同引き渡し、稼働(この時点からカード情報がサイトDBに保存される)
2011年4月20日カード会社から原告にカード情報漏洩の可能性を伝える
2011年4月21日ECサイトのサービスを停止
2011年4月30日セキュリティを強化してサービス再開。クレジットカード決済は停止
2011年5月26日情報漏洩の旨を告知(アーカイブ
2011年8月23日原告は別会社に委託した新サイトに移転
2011年10月14日原告が東京地裁に告訴
2011年12月22日原告がマザーズ上場
2014年1月23日判決(概ね原告の勝訴、確定)

ポイントは下記の通りです。
  • X社(原告)はセキュリティ対策について特に指示はしていなかった
  • 損害賠償について個別契約に定める契約金額の範囲内とする損害賠償責任制限があった
  • 当初システムはカード決済を外部委託し直接カード情報を扱っていなかった
  • X社が「カード会社毎の決済金額を知りたい」とY社に依頼をして、その結果カード情報をいったんDBに保存する仕様となった(2010年1月29日)
  • X社からの問い合わせに対してY社は、カード情報を保持しない方式に変更することが可能で、そのほうが安全となり、費用は20万円程度である旨を伝えた(2010年9月27日)が、その後X社は改良の指示をしなかった
  • 以下の脆弱性その他が認められた
    • システム管理機能のIDとパスワードが admin/password であった
    • 個人情報が記載されたお問い合わせログファイルの閲覧が可能(ディレクトリリスティングと意図しないファイル公開)
    • SQLインジェクション
    • クロスサイトスクリプティング
    • ログにカード情報が保存されていた
    • DBに保存されたカード情報にはセキュリティコードも含まれていた

これに対して、東京地裁の判決は下記の通りです。
  • クレジットカード情報が漏洩した原因は複数考えられるが、脆弱性やアクセスログ、不正利用の状況からSQLインジェクション攻撃によるものと断定
  • セキュリティ対策についてX社からの指示はなかったが、Y社は必要なセキュリティ対策を講じる義務(債務)があり、それを怠った債務不履行がある
  • Y社は、SQLインジェクションはカード情報とは無関係の箇所にあったので、この脆弱性が原因ではないと主張したが、裁判所はこの主張を退けた
  • 損害賠償責任制限について
    • 損害賠償責任制限自体については認める
    • 契約書に明記はないが、故意あるいは重過失に起因する損害については責任制限の範囲外とする
    • 仕様書に記載はないがSQLインジェクション対策を怠ったことは重過失である
    • よって今回の事案は損害賠償責任制限には該当しない
  • 原告からの損害賠償請求のうち、おわびのQUOカード代や梱包発送費などの損害は全額認められたが、売上減の機会損失は6041万4833円の要求に対して、400万円のみが認められ、システム委託契約費用約2074万円に対しては、他社システムに移行後の利用料等(約27万円)のみが認められた
  • Y社がカード情報をDBに保存しない方式をX社に提案したにも関わらずX社がそれを採用しなかった件をX社の過失と認め、過失相殺3割が認定された
  • 瑕疵担保期間(1年)を超えていたが、瑕疵担保期間はあくまで無償補修の期間を定めたもので、損害賠償請求権の期間制限を定めたものではないので、損害賠償請求は有効(2015/1/22 22:30追記)
  • 結果、3131万9568円の損害を認定し、その3割を控除して、2262万3697円の損害賠償をY社に命じた

SQLインジェクション対策がY社の債務である理由を判決文より引用します。
そこで検討するに,証拠(甲14,25,29)によれば,経済産業省は,平成18年2月20日,「個人情報保護法に基づく個人データの安全管理措置の徹底に係る注意喚起」と題する文書において,SQLインジェクション攻撃によってデータベース内の大量の個人データが流出する事案が相次いで発生していることから,独立行政法人情報処理推進機構(以下「IPA」という。)が紹介するSQLインジェクション対策の措置を重点的に実施することを求める旨の注意喚起をしていたこと,IPAは,平成19年4月,「大企業・中堅企業の情報システムのセキュリティ対策~脅威と対策」と題する文書において,ウェブアプリケーションに対する代表的な攻撃手法としてSQLインジェクション攻撃を挙げ,SQL文の組み立てにバインド機構を使用し,又はSQL文を構成する全ての変数に対しエスケープ処理を行うこと等により,SQLインジェクション対策をすることが必要である旨を明示していたことが認められ,これらの事実に照らすと,被告は,平成21年2月4日の本件システム発注契約締結時点において,本件データベースから顧客の個人情報が漏洩することを防止するために,SQLインジェクション対策として,バインド機構の使用又はエスケープ処理を施したプログラムを提供すべき債務を負っていたということができる。
感想は以下の通り
  • 発注者(原告)および受注者(被告)ともにグダグダの状況であった
  • 原告は発注者としての責務を果たしておらず、(ほぼ)すべての責任が被告にあるとの判断は、被告に厳しすぎると思う
  • とはいえ、被告もなんら「専門家としての責務」を果たしておらず、裁判所はこの点を重視した
  • 経産省およびIPAからの注意喚起が「専門家として当然はたすべき責務」の基準と判断された点に注目したい
  • 管理機能のID/パスワードが admin/password であった箇所を読んで、しばらく余韻にひたっていた
    ※ただし、被告はシステム引き渡し後に原告がパスワードを変更すると想定していたと主張

考察とまとめ

従来筆者は、経産省の「モデル取引・契約書」などを根拠として、「仕様書に明記されないWebアプリケーションの脆弱性に対する責任は発注者にある」との見解を持っておりましたが、同時に「判例があるわけではないので要注意」としていました(例: PHPカンファレンス2009の講演資料)。今回の東京地裁の判断は、開発会社の「専門家としての暗黙の責務」として、セキュリティ対策の責務(契約上の債務)があると認定した点で画期的なものと言えます。判例時報の記事によると、この判決は「確定」とありますが、下級審判決ですので、今後の事案でどのような判決となるかは分かりません。
また、SQLインジェクション脆弱性が(要求仕様に明記されていないにもかからわず)受注者の重過失であると認定した点にも注目する必要があります。今後は、開発会社は自衛のため、せめて「安全なウェブサイトの作り方」に載っている脆弱性くらいは、顧客から要求がなくても、対策しておくべきでしょう。
発注者の立場に話を戻すと、今回紹介したような判例があったとしても、脆弱性対策を発注仕様に盛り込むべきです。そもそも脆弱性がなく、侵入もされないことが望ましいことは言うまでもないからです。
また、原告は、20万円の追加対策を指示しなかったことを過失と認定され、過失相殺として損害賠償金額を3割減額されました。20万円ケチったために、1,000万円近く損したことになります。
このことから、開発会社側は、採用される見込みが薄くても、自衛策としてセキュリティ対策の提案はどんどんした方が良いということになります。提案した上で発注側が拒否すれば、責任を発注側に転嫁できる(可能性がある)からです。さらに、よくある損害賠償の制限条項が「重過失による場合は無効」と判断されましたので、法務的な対策もあわせて考える必要があるでしょう。

先にも書いたように、今回の判決は「開発会社には厳しいもの」と受け取りましたが、それは私は開発の現場に長くいたからであり、世間一般の常識からすれば、「専門家なのだからそれくらいやって当たり前」ということではないでしょうか。
契約論的な責任の所在は別として、SQLインジェクション対策は今や「やって当たり前」なのですから、開発サイドとしては、要求仕様にあってもなくても淡々とSQLインジェクションが混入しない作り方を実践すべきであると考えます。

追記(2015/1/22 22:30)

はてブコメントに瑕疵担保期間に関する言及がありましたので追記します。
東京地裁の判断は、瑕疵担保期間はあくまで無償補修の期間を定めたもので、損害賠償請求権の期間制限を定めたものではないので、損害賠償請求は有効としました。以下、判決文の該当箇所を引用します。
本件基本契約は,「乙は,委託業務の完了の後その成果物に瑕疵が発見されたとき,乙の責任において無償で速やかに補修のうえ納入を行うものとする。」(26条1項),「乙の保証期間は,特に定めるものを除き委託業務の完了の後1年間とする。ただし,乙の責に帰すべきものでない場合はこの限りではない。」(26条2項)と定めている。
以上の規定からすれば,本件基本契約26条2項は,被告による無償補修を定めた本件基本契約26条1項を前提とした規定であり,被告が無償補修する義務を負う期間を原則として委託業務の完了後1年間とすることを定めたものと解することができ,原告の被告に対する損害賠償請求権の期間制限を定めたものと解することはできない
これに対し,被告は,本件基本契約に基づく個別契約は請負契約としての性質を有し,請負契約では債務不履行責任の特則として瑕疵担保責任の規定が適用される以上,原告の本件請求も瑕疵担保責任の規定に従った請求というべきであるから,本件請求についても本件基本契約26条2項が適用される旨主張する。しかし,本件基本契約26条2項は,その文言上被告による無償補修期間を定めたものと解釈できることは前記説示のとおりであり,本件個別契約の性質が請負契約に当たるか,原告の請求が瑕疵担保責任に基づく請求といえるかといった点は,上記解釈に影響を与えるものではないから,被告の上記主張は採用できない。

2014年12月22日月曜日

『例えば、PHPを避ける』以降PHPはどれだけ安全になったか

この記事はPHPアドベントカレンダー2014の22日目の記事です 。

2002年3月に公開されたIPAの人気コンテンツ「セキュアプログラミング講座」が2007年6月に大幅に更新されました。そして、その一節がPHPerたちを激しく刺激することになります。
(1) プログラミング言語の選択
1) 例えば、PHPを避ける
短時日で素早くサイトを立ち上げることのみに着目するのであれば、PHPは悪い処理系ではない。しかし、これまで多くの脆弱性を生んできた経緯があり、改善が進んでいるとはいえまだ十分堅固とは言えない。
セキュアプログラミング講座(アーカイブ)より引用
「PHPを避ける」とまで言われてしまったわけで、当然ながらネット界隈では炎上を起こし、現在はもう少しマイルドな表現に変わっています(参照)。

本稿では、当時のPHPの状況を振り返る手段として、この後PHPのセキュリティ機能がどのように変化してきたかを説明したいと思います。以下の二点について触れます。
  • PHPの安全でない機能の削除
  • PHPの安全性を高める機能の追加
一方、PHPの単純な脆弱性改修については原則として触れないことにします。
それでは、はじめましょう。

1.htmlspecialchars 文字エンコーディングチェックの改善(PHP5.2.5 2007/11/8)

2007年6月当時のhtmlspecialcharsは第3引数で指定した文字エンコーディングについて、ほとんど何もチェックしていない状態でしたが、PHP5.2.5になって、一部文字エンコーディングのチェックが追加されました。このあたりの詳しい状況は、私のエントリhtmlspecialcharsは不正な文字エンコーディングをどこまでチェックするかを参照ください。PHP5.2.5での対応は、なんとも中途半端なもので、「しないよりはマシだが抜けもあった(参照)」という、なんかPHPの悪いイメージに沿った対応でありました。
しかし、私のエントリが引き金となり、こちらで紹介したような議論が巻き起こり、最終的にはmoriyoshiさんによるとてもきっちりした文字エンコーディングのチェックがなされるようになりました(PHP-5.2.12 2009/12/17 )。
また、PHP-5.3までのhtmlspecialcharsの第3引数のデフォルト値はISO-8859-1(Latin-1)でしたので、文字エンコーディング指定を省略した場合は結局なにもチェックしないのと同じだったのですが、PHP-5.4(2012/3/1)でなんとこれが突然UTF-8に変更されます。これにより、「日本語等マルチバイト環境では第3引数を適切に指定しないと文字が表示されない(参照)」という荒業により、一挙に第3引数の指定が普及したものと思われます。
なんか、昔のPHPのゆるーい感じから、PHP-5.4のこの変更はスパルタンな感じがするほどであります。htmlspecialcharsに関する変遷を下記にまとめます。
  • PHP4.1.0  (2001/12/10) htmlspecialcharsに第3引数追加。ほとんど何もしていないに等しい文字エンコーディングチェック
  • PHP-5.2.5 (2007/11/8) 文字エンコーディングのチェックを強化…したけど抜けがたくさん
  • PHP-5.2.12 (2009/12/17) moriyoshiの神対応による厳格なチェックに
  • PHP-5.4.0 (2012/3/1) 第3引数のデフォルトが UTF-8 に変更

2. register_globalsが非推奨に(PHP-5.3.0 2009/6/30)

PHPの安全でない機能の筆頭格であったregister_globalsは、PHP-4.2.0(2002/4/22)ではデフォルトはオフになったものの、php.iniに指定すれば普通に使える状態でした。PHP-5.3に至り非推奨、すなわち使うと警告エラーとなり、PHP-5.4.0(2012/3/1)にて機能自体が削除されました。

register_globalsの危険な例を紹介します。
session_start();
if (isset($_SESSION['user'])) {
  $islogin = TRUE;
}
ご覧のように、セッション変数userにログインユーザ名を保存することで、ログイン中か否かを保持しています。
ここで、ログイン状態でなくても、クエリ文字列に islogin=1 と指定することでログイン状態になることができます。register_globalの機能により、変数 $islogin の初期値が "1" になるからです。

しかし、この脆弱性は、そもそもログイン状態を保持する変数 $islogin を初期化していないことが原因です。なので、「一見問題ないスクリプト」がregister_globalsのせいで脆弱になる例はないかと探してみました。
仮にスーパーグローバル変数$_SESSIONがregister_globalsによって外部から変更できるとすさまじく危険ですが、それはできないように保護されています。しかし、$_SESSIONが出来る前に使われていた session_register() 関数を使う仕組みだと、セッション変数を外部から設定できる場合があります。そのような例を示します。
session_start();
session_register('user'); // $user をセッション変数として宣言
if (! isset($user)) {
  die('ログインしていません');
}
// 以下ログイン中として処理
コメントにあるように、session_register('user'); は、$userがセッション変数である($_SESSION['user']に相当)と宣言するものです。
しかし、register_globalsが有効だと、新規のセッションの場合に限り、クエリ文字列 user=yamada 等とすることで、セッション変数 $user が外部から変更されてしまいます。PHP-4.1の頃だと、色々大変だったのでしょうね。

また、register_globalsではありませんが、parse_strという関数でregister_globals同等のことを実現しようとすると、$_SESSIONの上書きができてしまいます。詳しくはこちらを参照ください。
register_globalsについてまとめると以下のようになります。
  • PHP-4.2.0 (2002/4/22) register_globalsがデフォルトで off になる
  • PHP-5.3.0 (2009/6/30) register_globalsを有効にすると警告エラーになる
  • PHP-5.4.0 (2012/3/1) register_globalsが廃止される

3. マジッククォートが非推奨に(PHP-5.3.0 2009/6/30)

昔のPHPには、入力値を自動的にSQLエスケープするという機能(マジッククォート)がありましたが、PHP-5.3で非推奨になり、PHP-5.4で廃止されました。
マジッククォートに関しては、PHPの公式マニュアルに妥当な説明があるので参照してください。
上に付け加えることはあまりありませんが、敢えて言えば、
  • マジッククォートは入力時にエスケープ処理を自動的行う仕組みだが、エスケープ処理は文字列を使う時に都度すべきという考え方が一般化した
  • マジッククォートはMySQLに特化したエスケープ方式であり、かつMySQLのオプションや文字エンコーディングを考慮しない不完全なエスケープだった
ということで、廃止になったのは妥当な判断だと考えます。
  • PHP-5.3.0 (2009/6/30) マジッククォートを有効にすると警告エラーになる
  • PHP-5.4.0 (2012/3/1) マジッククォートが廃止される

4.暗号学的に安全な擬似乱数生成器のサポート(PHP-5.3.0 2009/6/30)

昔はPHPに暗号学的に安全な乱数生成関数がサポートされておらず、以下の関数でトークンやらセッションIDなどが生成されるという状況でした。
  • uniqid()
  • rand()
  • mt_rand()
これらは暗号学的に安全な乱数生成器ではないので、セキュリティ用途に使ってはいけません。中でも、uniqid()は乱数ですらなく基本的には時刻を元にしたIDですが、これを追加オプションなしでセッションID生成に使っている実装を見たことがあります。

PHP-5.3.0から、openssl_random_pseudo_bytes という、とても長い名前の関数により安全な擬似乱数が生成できるようになりました。但し、使用にあたっては以下の条件があります。
  • PHP-5.3.0以降であること かつ
  • OpenSSLが導入されていること
どちらか一方でも上記を満たさない場合は、安全な乱数のソースとして/dev/urandom等を使うことになります。

5.セッションID生成の安全性強化(PHP-5.3.2 2010/3/4)

PHPのセッションID生成は、元々暗号的な根拠を持っておらず、PHP-5.3.2で一応の改善があったものの、計算の複雑性を増すことで推測を少し難しくしたというレベルであり、暗号学的な根拠があるものではありませんでした。拙著を書くときにも扱いに苦労した記憶があります。結果として、拙著P163に以下のように書きました。
(PHPのセッションID生成は)図4-51で示したありがちなセッションIDの生成方法に該当します。ロジックの複雑性が高いため解読方法が判明しているわけではありませんが、理論的には安全性が保証されていない設計ということになります。
上記には「解読方法が判明しているわけではありませんが」とありますが、これを調べた人がいて、hnwさんが素晴らしい翻訳で紹介してくださっています。
現実的なリスクは低いものの、ちょっと心配ですよね。このため、(執筆当時は上記論文は知らなかったものの)私は以下のように推奨しました。
しかし、php.iniの設定を追加することで、安全な乱数を元にセッションIDを生成するよう改善できます。そのためには、php.iniに以下を設定します。
[Session]
;; entropy_file は Windowsでは設定不要
session.entropy_file = /dev/urandom
session.entropy_length = 32
そして、PHP-5.4.0からは上記設定が標準のデフォルト値となりました。まさか私の本を読んで、ということはないと思いますが、結果として取り入れてくださると嬉しいですよね。
  • PHP-5.3.2 (2010/3/4) セッションIDの生成方法を複雑化したが不完全
  • PHP-5.4.0 (2012/3/1) セッションIDのシードに安全な乱数を使うように改善

6.ヌルバイト攻撃の防御機能の追加(PHP-5.3.4 2010/12/9)

よく知らているように、PHPの機能・関数にはバイナリセーフのものとそうでないものが混在しているため、ヌルバイト攻撃という攻撃手法が成立していました。
典型的には、ディレクトリトラバーサル攻撃の際に、プログラム側で拡張子を付加してい場合でも任意の拡張子ファイルのアクセスができるようにする方法です。具体的には、../../.../../../etc/passwd%00というファイル名を指定することで、%00(ヌルバイト)が文字列の終端になり、それ以降の拡張子(.txt等)が無視されるというものです(bloggerの制限のため%を全角で書いていますが、実際には半角です)。
PHP-5.3.4では、言語構造及び関数の一部で、ファイル名にヌルバイトがあるとエラーになるように改良されました。詳しくは以下の素晴らしいエントリを参照ください。
ヌルバイト攻撃ができなくなっても、ディレクトリトラバーサル攻撃やファイルインクルード攻撃の対策は引き続き必要ですが、万一対策が漏れていた際の保険的な対策になります。

7.PDOのDB接続時の文字エンコーディング指定が可能に(PHP-5.3.6 2011/3/17)

昔は、PDOを用いる際にDB接続の文字エンコーディングを簡単には指定できないため、SQLインジェクション脆弱性の可能性がありました。具体的には、ぼくがPDOを採用しなかったわけ(Shift_JISによるSQLインジェクション)で指摘したように、Shift_JISでMySQLに接続している場合に、エスケープ処理やプレースホルダを使っていても、SQLインジェクション脆弱性が混入する場合がありました。
PHP-5.3.6以降にて、DB接続時に下図のように文字エンコーディングを指定できるようになりまた。
 $dbh = new PDO('mysql:host=DBHOST;dbname=test;charset=utf8', USERNAME, PASSWORD);
詳しくは以下のエントリを参照ください。
なお、Windows版のPHP-5.3.6には初歩的なバグがあり、PHP-5.3.7で修正されました。
ところが、このPHP-5.3.7には重大な脆弱性が混入してしまいました。次項に続きます。

8. crypt関数の重大な脆弱性混入とXFAILの運用(PHP-5.3.8 2011/8/23)

PHP-5.3.7にはcrypt関数に重大な脆弱性があり、MD5ハッシュを選択した際に肝心のハッシュ値が出力されないという問題が混入してしまいました。バグの詳細と混入の経緯については以下の記事を参照してください。
バグ混入の背景は以下の記事が参考になります。
上記の記事にもありますが、PHPの開発では、バグが採択された場合まずテストを書くことから始めるため、未対応のバグについて、大量のFAILが残ったままになり、対処が必要なFAILが埋もれて見落としてしまったようです。
これに対して、「将来修正するけど今はFAILを容認している」バグについては、FAILとは別に、XFAIL(eXpected FAIL)とすることで、見落としをなくそうということになりました。実際にはXFAILの機能自体は当時から既に組み込んであったようですが、XFAILの運用をきちんとやろうという意味でしょう。
実は、PHP-5.3.7の前にも、PHP-5.2.7にも重大なやらかしがあって、PHP-5.2.7は欠番になっています。そのため、PHP-5.x.7は地雷?ということで、PHP-5.4.7やPHP-5.5.7が出る際にも「また何かあるのではないかと期待不安」があったのですが、さすがにそういうことはありませんでした。PHP-5.3.7の失敗以降、大きなヤラカシはなくなったのではないでしょうか? これは良かったと思いますし、PHP-5.3.7のcryptのバグも幸い大きな実害にはつながらなかったようです。

9. header関数のバグ修正(PHP-5.4.0 2012/3/1)

PHPのヘッダ関数は、5.1.2(2006/1/12)でHTTPヘッダインジェクション対策として以下の修正がありました。
この関数は一度に複数のヘッダを送信できないようになりました。 これは、ヘッダインジェクション攻撃への対策です。
header関数マニュアルより引用
しかしこの修正に抜けがあり、改行を構成する2種類の文字キャリッジリターン(0x0D)とラインフィード(0x0A)のうちラインフィードの方しかチェックしておらず、キャリッジリターンのみでHTTPヘッダインジェクション攻撃ができてしまう状態でした。
その旨を私の本にも書いていたところ、PHP-5.4.0にて廣川類さんが修正してくださいました。
PHPスクリプトに対してHTTPヘッダインジェクション攻撃を掛ける経路としては、header関数のほか、setcookieとsetrawcookieを使う可能性が考えられますが、この修正でいずれの関数でもHTTPヘッダインジェクションはできなくなったはずです。

10. 安全なパスワード保存が簡単にできるようになった(PHP-5.5.0 2013/6/20)

password_hash関数の新設により安全なパスワード保存が楽に行えるようになりました。以下のように使用します。
$password = ...
$hash = password_hash($password, PASSWORD_DEFAULT);
生成されるハッシュ値の例は下記のとおりです。パスワードが同じでもソルトが毎回変わるので、呼び出しの度に異なるハッシュ値になります。
$2y$10$KDeRvOFZVPtVVm/Qo8DhA.XZ85u9mPmIqj3CHXCD2QZxeop617Wy2
上記の呼び出し例では指定していませんが、ストレッチングの強度を指定することもできます。デフォルトは10(ストレッチ回数ではありません)です。
パスワードの悪用が問題になっている昨今の状況を考えると、password_hashの採用はぜひ検討したいものです。

11. Session Adoption Bugの修正(PHP-5.5.2 2013/8/15)

PHPには従来からセッションIDとして勝手な値(例: PHPSESSID=ABC)をつけてもそれを受け入れてしまうという問題(Session Adoption)が指摘されていました。PHP開発陣はSession Adoptionはない方がいいが重大な問題ではないと認識していたようで長らく放置されていましたが、PHP-5.5.2にて修正されました。ただし、デフォルトは従来通りで、php.iniにて session.use_strict_mode = On を指定する(strict sessions)と、PHP側で用意したセッションIDのみを使うようになります。
詳しくは以下のエントリを参照ください。
また、Session Adoptionに関連して、セッションIDの固定化攻撃にどう対処するかについては、以下を御覧ください。

まとめ

IPAから『例えば、PHPを避ける』と書かれた2007年6月以降、PHPの安全性がどの程度強化されたかを調べました。思い返すと色々あったなぁとは思うものの、PHP-5.3.7のcryptの以降は、(PHP-5.5.2のStrict Sessionsはご愛嬌として)大きな「やらかし」は発生していないように思います。
また、PHP-5.3以降では、明確に過去の悪い習慣と決別する姿勢を示しているように思えます。昔のイメージだけで「PHPは危険」と言われてしまうのは、ちとかわいそうな気がします。
ということで、最近のPHPの安全性はかなり向上していると言えますが、むしろ、現在のPHPのセキュリティ運用上の問題は、バージョンアップが頻繁なために追随することが難しいことではないでしょうか? これを避けるためには、CentOS等のLinuxディストリビューションのパッケージとしてPHPを導入して、適切にパッチ適用する方法などが考えられます。

フォロワー