なぜPHPアプリにセキュリティホールが多いのか?

第43回PHP 5.3のcrypt関数の問題

PHP 5.3.7のcrypt関数に重大なセキュリティ上の問題が発見されました。この問題は大きく報道されているのでご存知の方も多いと思います。同時にPHP本体のセキュリティ状態についても不安に思った方も多いと思います。

PHP 5.3.7のcrypt関数のバグは、攻撃が成功した場合のダメージは非常に大きく、攻撃が可能な場合は必ず成功します。しかし、攻撃経路があるシステムは限定的でしょう。

crypt関数のバグ

最近のPHPで行われたcrypt関数のバグ修正は3つあります。

PHP 5.3.7で修正したバグ ─ その1

PHP 5.3.7ではcrypt_blowfishを利用した場合、8ビット文字(マルチバイト文字エンコーディングなど)を利用した場合に脆弱になってしまう問題を修正しました。

この脆弱性は2011/6/20にレポートされており、crypt_blowfishのコードを含む製品に影響がありました。PHPの場合、PHP 5.3.0からcrypt_blowfishのコードをバンドルしており、5.3.6未満に影響がありました。

脆弱性があるcrypt_blowfishは8番目のビットがセットされた一文字に続く1から3文字を無視していました。問題の原因は符号あり・符合なし文字列の取り扱いでした。レポートによると調査したパスワードのうち3/16は8番目のビットがセットされた文字を利用しているとしています。パスワード中の文字を最大3文字無視するので、極端なケースですが4文字のパスワードなら1文字のパスワードを利用しているのと変わらなくなる場合がありました。恐らくこの問題はPHP 5.3.7RC1がリリースされている頃にPHPプロジェクトに連絡されたと思われます。

攻撃方法

何らかの方法でパスワードデータベースを取得し、総当たり攻撃でパスワードを検出する。

PHP 5.3.7で修正したバグ ─ その2

PHP 5.3.7では長すぎるsaltによるスタックオーバーフローが修正されています。

ext/standard/crypt.c に加えられた変更
        salt[2] = '\0';
 #endif
        salt_in_len = strlen(salt);
+   } else {
+       salt_in_len = MIN(PHP_MAX_SALT_LEN, salt_in_len);
    }

slat_in_lenは

    salt[salt_in_len] = '\0';

とmemcpyでコピーしたsaltパラメータの文字列の終端位置の指定に利用されています。手元のLinux+PHP 5.3.6で試したところ、単純に長いsaltではクラッシュしませんでした。特定のフォーマットが必要なのか、システムのcryptを利用するプラットフォームで発生するのかも知れません。

攻撃方法

攻撃用のPHPスクリプトを実行し、任意のコードを実行させる。

PHP 5.3.7に混入したバグ

セキュリティ強化の一環として、strcatやstrcpyといった安全ではないとされる関数をより安全であるとされるstrlcat、strlcpyに書き換える作業の際にバグが埋め込まれました。このバグにより、crypt_md5を利用している場合、どのようなパスワードを設定していても無効になってしまう重大なセキュリティ上の問題が発生しました。

このバグレポートを見ると分かりますが、肝心のパスワードハッシュが出力されないのでどんなパスワードを入力しても認証できてしまうことになります。

PHP 5.3.7と5.3.8のext/standard/php_crypt_r.c の差分
    /* Now make the output string */
    memcpy(passwd, MD5_MAGIC, MD5_MAGIC_LEN);
    strlcpy(passwd + MD5_MAGIC_LEN, sp, sl + 1);
-   strlcat(passwd, "$", 1);
+   strcat(passwd, "$");
 
    PHP_MD5Final(final, &ctx);

strlcatは第三パラメータでバッファ「全体」の大きさを指定しますが、問題のコードでは第三パラメータが「連結する文字列の長さ」を指定するstrncatを利用している場合に指定すべき⁠1⁠が指定されています。つまり関数の使い方がまったく間違っていたことがこのバグの原因です。仕様の違いを十分考慮しなかったためsaltの部分で文字列がNULL文字(C言語の文字列終端文字)で切れてしまい、肝心のパスワードハッシュが無効になってしまいました。

攻撃方法

脆弱なcrypt関数でCRYPT_MD5を利用しているシステムにログインする。パスワードは何でもログインできる。

PHP 5.3.7で修正されたcrypt_blowfishの脆弱性

PHP 5.3.7で入り込み、PHP 5.3.8で修正されたcrypt_md5の脆弱性は、攻撃が可能な場合は非常に危険な脆弱性でしたが、非常に分かりやすい脆弱性でした。一方、crypt_blowfish自体の脆弱性は少々分かりづらい脆弱性です。

レガシーPHPのcrypt関数

PHP 5.3からPHPのcrypt関数の仕様は大きく変わりました。PHP 5.3までのPHPのcrypt関数は単純にシステムが提供するcryptライブラリを呼び出していました。

次のコードはPHP 5.2.17のcrypt関数が実際にライブラリのcryptを呼び出している部分です。

#if defined(HAVE_CRYPT_R) && (defined(_REENTRANT) || defined(_THREAD_SAFE))
    {
#if defined(CRYPT_R_STRUCT_CRYPT_DATA)
        struct crypt_data buffer;
        memset(&buffer, 0, sizeof(buffer));
#elif defined(CRYPT_R_CRYPTD)
        CRYPTD buffer;
#else
#error Data struct used by crypt_r() is unknown. Please report.
#endif

        RETURN_STRING(crypt_r(str, salt, &buffer), 1);
    }
#else
    RETURN_STRING(crypt(str, salt), 1);
#endif

コードからも分かるようにC言語のマクロ(#で始まるプリプロセッサで処理される部分)でシステムが持っているcryptの機能によって呼び出される関数が異なることが分かります。PHPをC言語のソースからコンパイルする場合にコンパイル時のオプションを指定する./configureスクリプトにはcrypt関数の動作に関するオプションはありません。しかし、システムが提供しているcryptの機能はどのようなものか自動的に判別するようになっていました。

例えば、salt(同じパスワードでも異なるハッシュとなるように味付けする文字列)をサポートする場合は適切なsaltを自動生成するコードは次のようになっています。

    if(!*salt) {
#if PHP_MD5_CRYPT
        strcpy(salt, "$1$");
        php_to64(&salt[3], PHP_CRYPT_RAND, 4);
        php_to64(&salt[7], PHP_CRYPT_RAND, 4);
        strcpy(&salt[11], "$");
#elif PHP_STD_DES_CRYPT
        php_to64(&salt[0], PHP_CRYPT_RAND, 2);
        salt[2] = '\0';
#endif
    }

MD5 CryptやDES Cryptをサポートするシステムの場合はsaltが自動生成されます。このコードからも分かるように、PHP 5.3より古いレガシーPHPのcrypt関数はポータブルではなく、システムに依存する関数でした。

PHP 5.3のcrypt関数

PHP 5.3のcrypt関数から、システムがcryptライブラリを提供しない場合、PHPの実装が利用されるようになりました。PHP 5.2以前のソースではcrypt.cしか提供されませんが、PHP 5.3.8のソースではcrypt関数関連のソースファイルは以下のファイルが提供されています。

  • crypt.c         crypt_blowfish.h  crypt_freesec.h  
    crypt_sha512.c crypt_blowfish.c crypt_freesec.c
    crypt_sha256.c

crypt.cの実装も大きく変わり、cryptライブラリを呼び出す処理は以下のようになりました。

PHP 5.3.8のext/standard/crypt.cより抜粋
#if PHP_USE_PHP_CRYPT_R
    {
        struct php_crypt_extended_data buffer;

        if (salt[0]=='$' && salt[1]=='1' && salt[2]=='$') {
            char output[MD5_HASH_MAX_LEN];

            RETURN_STRING(php_md5_crypt_r(str, salt, output), 1);
        } else if (salt[0]=='$' && salt[1]=='6' && salt[2]=='$') {
            const char sha512_salt_prefix[] = "$6$";
            const char sha512_rounds_prefix[] = "rounds=";
            char *output;
            int needed = (sizeof(sha512_salt_prefix) - 1
                        + sizeof(sha512_rounds_prefix) + 9 + 1
                        + strlen(salt) + 1 + 43 + 1);
            output = emalloc(needed * sizeof(char *));
            salt[salt_in_len] = '\0';

            crypt_res = php_sha512_crypt_r(str, salt, output, needed);
(中略)
        } else {
            memset(&buffer, 0, sizeof(buffer));
            _crypt_extended_init_r();

            crypt_res = _crypt_extended_r(str, salt, &buffer);
            if (!crypt_res) {
                if (salt[0]=='*' && salt[1]=='0') {
                    RETURN_STRING("*1", 1);
                } else {
                    RETURN_STRING("*0", 1);
                }
            } else {
                RETURN_STRING(crypt_res, 1);
            }
        }
    }
#else

# if defined(HAVE_CRYPT_R) && (defined(_REENTRANT) || defined(_THREAD_SAFE))
    {
#  if defined(CRYPT_R_STRUCT_CRYPT_DATA)
        struct crypt_data buffer;
        memset(&buffer, 0, sizeof(buffer));
#  elif defined(CRYPT_R_CRYPTD)
        CRYPTD buffer;
#  else
#    error Data struct used by crypt_r() is unknown. Please report.
#  endif
        crypt_res = crypt_r(str, salt, &buffer);
        if (!crypt_res) {
                if (salt[0]=='*' && salt[1]=='0') {
                    RETURN_STRING("*1", 1);
                } else {
                    RETURN_STRING("*0", 1);
                }
        } else {
            RETURN_STRING(crypt_res, 1);
        }
    }
# endif

このコードから分かるようにPHP 5.3のcrypt関数でもシステムが提供するcryptライブラリ(実際にはリエントラントなcrypt_r)が呼び出されていることが分かります。PHP 5.3になってcrypt関数のポータビリティはかなり向上しましたが、それでもポータブルな関数とは言えません。

Linuxの場合

筆者が普段利用しているMomonga Linux 7で./configureスクリプトを実行した場合、crypt関係がどのように設定されているのか紹介します。

configureスクリプトの実行結果から抜粋
checking for crypt in -lcrypt... (cached) yes
checking for standard DES crypt... (cached) yes
checking for extended DES crypt... (cached) no
checking for MD5 crypt... (cached) yes
checking for Blowfish crypt... (cached) no
checking for SHA512 crypt... (cached) no
checking for SHA256 crypt... (cached) no

DESとMD5がサポートされていることが分かります。実際にどのような設定になったかC言語のマクロで確認します。

main/php_config.hより抜粋
#define HAVE_CRYPT 1

/* Define to 1 if you have the <crypt.h> header file. */
#define HAVE_CRYPT_H 1

/* Define to 1 if you have the `crypt_r' function. */
/* #undef HAVE_CRYPT_R */

/* Whether the system supports BlowFish salt */
#define PHP_BLOWFISH_CRYPT 1

/* Whether the system supports extended DES salt */
#define PHP_EXT_DES_CRYPT 1

/* Whether the system supports extended DES salt */
#define PHP_EXT_DES_CRYPT 1

/* Whether the system supports SHA256 salt */
#define PHP_SHA256_CRYPT 1

/* Whether the system supports SHA512 salt */
#define PHP_SHA512_CRYPT 1

/* Whether the system supports standard DES salt */
#define PHP_STD_DES_CRYPT 1

/* Whether PHP has to use its own crypt_r for blowfish, des and ext des */
#define PHP_USE_PHP_CRYPT_R 1

ソースコードだけ見ると、システムがcrypt_rを提供している場合、システムが提供しているcrypt_rを利用しそうに見えますが違います。色々定義されていますが最後の

/* Whether PHP has to use its own crypt_r for blowfish, des and ext des */
#define PHP_USE_PHP_CRYPT_R 1

が決定的です。この定義があると先に紹介したext/standard/crypt.cのコードから分かるように、PHPが実装したcryptライブラリが「自動的」に利用されます[1]⁠。

この例はMomonga Linuxの例ですが、現在利用されているほぼすべてのLinuxで同じ結果になると思われます。configureスクリプトの中で

if test "$ac_cv_crypt_blowfish" = "no" || test "$ac_cv_crypt_des" = "no" || test "$ac_cv_crypt_ext_des" = "no" || test "x$php_crypt_r" = "x0"; then

と定義されているため、Blowfish, DES, Extended DESがない場合、リエントラントなcrypt_rがない場合やcrypt_rで利用する構造体がサポートされていない場合、構造体のアライメントがサポートされていない場合はPHPのcrypt実装が利用されます。

PHPプロジェクトのCRYPT_BLOWFISHの解説ページ

PHPプロジェクトはcrypt関数のバグについて解説ページを用意しています。

筆者による意訳を載せておきます。

PHP 5.3.7+で実装された変更は、後方互換性よりセキュリティと正確性を重視しています。しかし、ユーザ(PHPアプリをインストールする管理者)が既存のハッシュに$2x$プレフィックスを利用することにより、セキュリティ上問題があるパスワード($2a$、$2y$を利用するもの)が変更されるまでの後方互換性を維持できるようになっています。

5.3.7より古いPHPでは、$2a$プレフィックスを持つパスワードは非ASCII文字を含むパスワードで不適切にシステム依存する動作をしていました。あるシステム環境(ほとんどの場合はPowerPCやARMであり、CPUアーキテクチャに関わらずBSD系UNIXやSolarisの一部)では正しく処理されていました。大部分のシステム環境(ほとんどのLinuxやその他 ─ 訳注:Windowsなど)では、常にではありませんがほとんど正しく処理されていない(バグのため、動作していなかった)うえ、セキュリティが良くない状態で動作していました。

PHP 5.3.7では、$2a$プレフィックスを持つ場合でもほとんど正しく動作するようになりました。先に紹介したセキュリティ的に弱い古いハッシュを持つ対策(訳注:$2x$プレフィックスのこと)も追加されました。$2x$プレフィックスを持つ場合は、古いハッシュが持つセキュリティリスクを許容しつつ動作するよう、バグを含んだ動作をするようになっています。

$2y$プレフィックスを持つ場合、管理者が望むなら新しいハッシュが設定された場合、互換性問題に対する対策なしに正しい動作をします。実用的には$2a$、$2y$どちらのプレフィックスを利用しても変わりありません。後方互換性対策のコードが(正しいUTF-8でさえない)外部からの意図的な攻撃だと分かる、おかしなパスワードがバグを含む5.3.7以前のハッシュにマッチさせようとするからです。

まとめ:8bit目が設定されていない文字エンコーディングを利用しているパスワードの場合、何の問題もありません。すべてのプレフィックスはまったく同様に動作します。パスワードで8bit目を利用する文字エンコーディングを利用している場合で、後方互換性よりセキュリティと正しさを優先した場合は何もする必要はありません。新しいPHPにアップグレードして新しい動作を$2a$プレフィクスを使えばよいです。

しかし、管理者がセキュリティより後方互換性を重視する場合で、一部の環境で見られている問題に対処したい場合は既存のパスワードデータベースの$2a$プレプレフィクスを$2x$に変更することができます。ほかの方法としては、PHPアプリケーションのコードで$2a$プレフィックスを持つパスワードに対して、新しくパスワードを設定する際に$2y$プレフィックスを付与すれば似たような動作を実現できます(この場合$2x$への自動変換は実行されません⁠⁠。

これまでの解説で何故アライメント(CPU)やプラットフォーム(OS)が脆弱性に影響するのか理解できると思います。PHPのconfigureスクリプトでPHPかシステムのどちらのcryptライブラリを利用するか決定するコードが、アライメントやシステムが提供するライブラリに依存しているからです。

Linux系OSでは随分前から認証にはPAM(Pluggable Authentication Module)が利用されていたため、歴史的にcrypt関数にはあまり注意が払われていません。このため、Linux環境のcryptは古いDESとMD5しかサポートしておらず、自動的にPHPのcrypt実装が使われるようになっています。

BSD系のOSやSoralisではcryptのサポートが充実しています。このため、configureスクリプトを実行するとBSD系のOSや、Solarisの前はBSD系のOSを利用していたSUN(現在のOracle)ではシステムのcryptが利用される場合が多いようです。PHPの実装を使用しないのでこれらの環境では5.3.7のバグに影響されないのは当然です。ちなみにBSD系のOSであるMac OS Xですが、筆者のOS X 10.5で試したところPHPの実装が使われるようでした。

crypt関数には色々問題があったのですべては書ききれませんが、PHPプロジェクトの文書と筆者が書いた記事では分りづらいのでポイントをまとめると

  • PHP 5.3.0以前はcrypt関数はシステム依存な関数であり、スレッドセーフではない場合も多くあった

  • PHP 5.3.0から5.3.6までは多くのプラットフォームでcrypt関数はまともに動作していなかった

  • PHP 5.3.7以前のPHPでもASCII文字(8ビット目が0)だけを利用していれば問題は発生しない

  • 正常なUTF-8エンコーディングでは脆弱にならない(未検証)

つまり「crypt関数なんて使えない関数だったから使ってないだろうし、使っていたとしても文字エンコーディングをバリデーションするかASCII文字だけに限定していれば問題ない」ということになります。

Crypt BlowfishやMD5 Cryptのバグは攻撃できるのか?

筆者はcrypt関数はシステム依存の関数であったので使うべきでない関数として分類していました。ほとんどのLinuxシステムでMD5 Cryptしか利用できない(DESは問題外)のは非常に問題です。crypt関数を使ったオープンソースアプリケーションはそれほどないだろう、と予想していましたが、調べてみるとかなりあるようです。

Google Code Searchで「" crypt(" lang:^php$」とキーワードに指定して検索してみると、執筆時点で2,400件もヒットしました。その多くがcrypt関数を特殊な用途に使っているADODBとphpassでした。ADODBは暗号化キーを生成するためにcrypt関数を使用しており、明らかに本来の用途以外の目的で利用していました。しかし、筆者も利用をお薦めしているphpassライブラリはPHPのcrypt関数を本来の用途である認証に利用できるようになっています。

phpassを利用している主なPHPアプリケーション
  • WordPress 2.5+
  • Drupal 7+
  • SquirrelMail

PHP 5.3からは必ずCRYPT_BLOWFISHもCRYPT_EXT_DESも組み込まれることが保証されており、phpassはCRYPT_BLOWFISHのほうが優先順位が高く設定されています。WordPressとDrupalは非常に大きなインストールベースを持っています。しかし、phpassのMD5 CryptはPHPで実装されているのでMD5 Cryptのバグの影響は受けません。Crypt blowfishのバグの影響はプラットフォームによって変わります。BSD系OSの場合は影響を受けない場合が多く、Linux/Windows系の場合はほぼすべての環境で影響を受けるでしょう。

Google Code Searchで検索しヒットしたコードの上位200くらいを見た限りでは、影響を受けるアプリケーションもそれなりにあると考えてよいでしょう。

脆弱性の影響評価は一般に

  • 影響度 = 攻撃が成功した場合のダメージ × 攻撃が成功する確率 × 攻撃経路が存在する割合

と考えます。攻撃経路の存在は非常に重要で、脆弱性があり高確率で攻撃できダメージが大きくても、誰もその機能を利用していないので攻撃経路が存在しない場合は影響はないと言えます[2]⁠。

Crypt MD5でパスワードが無効になるバグの「攻撃が成功した場合のダメージ × 攻撃が成功する確率」はとても高いですが、ざっと見た限りでは広く利用されているアプリケーションへの影響(⁠⁠攻撃経路が存在する確率⁠⁠)は大きいとは言えないようです。 Crypt Blowfishの問題は「攻撃経路が存在する確率」はかなり高いようですが、実際に攻撃するにはパスワードデータベースを盗む必要があるので「攻撃が成功した場合のダメージ × 攻撃が成功する確率」は低いと言えるでしょう。

自分の利用しているシステムへの影響を評価する場合、⁠攻撃経路が存在する確率」が非常に重要です。攻撃経路となる機能を利用していなければシステムが脆弱性の影響を受けないからです。

十分に知識を持っていると思われる経験者でも、セキュリティ脆弱性の評価を誤ることはよくあります。評価した時点で脆弱でなくてもアプリケーションやサービスの仕様変更で脆弱になってしまうこともよくあります。このため、PCIDSSなどのセキュリティ標準ではすべてのセキュリティパッチを速やかに適用することを求めています。

なぜMD5 Cryptのバグが混入したか?

MD5 CryptのバグとCrypt Blowfishのバグは関連しているように見えるかも知れませんが、まったく別のバグです。MD5 Cryptのバグが混入してしまった原因は二つあります。

第一の原因はリリースプロセス中にソースコードの安全性強化を目的にCの文字列関数をより安全と考えられている関数に置き換える作業をしたことです。

第二の原因はMD5 CryptのバグはPHPのテストコードでエラーとして検出していたにも関わらず見逃されたことです。折角のテストコードも実行して確認しなければ意味がありません。

上記の二つが原因ですが、第三の原因をあげるとすればCrypt Blowfishの脆弱性がリリースプロセス中に発見され、対応せざるを得なかったことでしょう。PHPプロジェクトにも品質管理を担当するQAチームがあります。QAチームはテストが失敗していることに気付いていても、Crypt Blowfishの脆弱性対応のためにテストが失敗するようになったと考えた可能性があります。リリースプロセス中の修正は新たなバグを生みやすいので最小限にすべきですが、今回はこの方針が守られていませんでした。

まとめ

PHP 5.3.7、PHP 5.3.8で修正された脆弱性は非常に深刻なものでした。しかし、深刻な脆弱性であるからといって自分が利用・開発しているシステムに大きな影響があるかどうかは別問題です。深刻な脆弱性が見つかった場合、冷静にどのような脆弱性か確認し、自身のシステムへの影響を見極める必要があります。

MD5 Cryptの問題はユニットテストを実行し確認すれば簡単に発見できる問題でした。PHPのリリース候補は広く公開されています。オープンソースはボランティアによって支えられています。余裕のある方はリリース候補のソースコードをダウンロードし、⁠make test⁠を実行して失敗するテストの原因をフィードバックしてはいかがでしょうか? もしかすると今回のようなとんでもないバグを見つけるような貢献ができるかも知れません。

Google Code Searchでcrypt関数が利用されているコードを検索した結果を見ていて、crypt関数の使い方を明らかに間違えているプロジェクトが複数あることが分かりました。次回はcrypt関数の使い方について解説したいと思います。

おすすめ記事

記事・ニュース一覧