2015年7月15日水曜日

PHPのunserialize関数に外部由来の値を処理させると脆弱性の原因になる

既にいくつかの記事で指摘がありますが、PHPのunserialize関数に外部由来の値を処理させると脆弱性の原因になります。 しかし、ブログ記事等を見ていると、外部由来の値をunserialize関数に処理させているケースが多くあります。

ユースケースの一例としては、「複数の値をクッキーにセットする方法」として用いる場合です。 PHP クッキーに複数の値を一括登録する方法という記事では、以下の方法で複数の値をクッキーにセットしています。
$status = array(
 "height" => 167,
 "weight" => 50,
 "sight" => 1.2
);
setcookie("status", serialize($status));
クッキーの受け取り側は以下のコードです。
print_r(unserialize($_COOKIE['status']));
出力結果は以下となります。
Array
(
[height] => 167
[weight] => 50
[sight] => 1.2
)
このようなunserialize関数の使い方は危険なのですが、上記に示したコードの範囲では脆弱性とまでは言えません。そこで、脆弱な例(getcookie.php)を下記に示します。
<?php
  require_once 'Logger.php';  // ログ出力クラス

  if (empty($_COOKIE['status']))
    die('クッキーが空です');
  $status = unserialize($_COOKIE['status']); // デシリアライズ

  // 以下バリデーション
  if (! is_array($status))
    die('statusは配列が必要です');
  if (! isset($status['height']))
    die('heightがセットされていません');
  if (! isset($status['weight']))
    die('weightがセットされていません');
  if (! isset($status['sight']))
    die('sightがセットされていません');

  // 以下表示
  echo 'height : ' . htmlspecialchars($status['height']) . '<br>';
  echo 'weight : ' . htmlspecialchars($status['weight']) . '<br>';
  echo 'sight : ' . htmlspecialchars($status['sight']) . '<br>';
ここでログ出力クラスLogger.phpの中身は以下となっています。
<?php
class Logger {
  const LOGDIR = '/tmp/';  // ログ出力ディレクトリ
  private $filename = '';  // ログファイル名
  private $log = '';       // ログバッファ

  public function __construct($filename) {  // コンストラクタ…ファイル名を指定
    if (! preg_match('/\A[a-z0-9\.]+\z/i', $filename)) { // ファイル名のバリデーション
      throw new Exception('Logger: ファイル名は英数字とドットで指定して下さい');
    }
    $this->filename = $filename; // ファイル名
    $this->log = '';             // ログバッファ
  }

  public function __destruct() { // デストラクタではバッファの中身をファイルに書き出し
    $path = self::LOGDIR . $this->filename;  // ファイル名の組み立て
    $fp = fopen($path, 'a');
    if ($fp === false) {
      die('Logger: ファイルがオープンできません' . htmlspecialchars($path));
    }
    if (! flock($fp, LOCK_EX)) {   // 排他ロックする
      die('Logger: ファイルのロックに失敗しました');
    }
    fwrite($fp, $this->log); // ログの書き出し
    fflush($fp);             // フラッシュしてからロック解除
    flock($fp, LOCK_UN);
    fclose($fp);
  }

  public function add($log) {  // ログ出力
    $this->log .= $log;        // バッファに追加するだけ
  }
}
ご覧のようにLoggerクラスはログ出力を目的としたもので、addメソッドではログをオブジェクト内のバッファに貯めこんでいき、アプリケーションの終了時にデストラクタによりログの中身をファイルに書き出すというものです。

脆弱なサンプル getcookie.php はLogger.phpをインクルードしているものの実際には呼び出しておらず、とくに悪いことはできないように見えますが、現実には、攻撃者はLoggerクラスを悪用する事が可能です。

ここで、攻撃者が使用する攻撃用のスクリプトを示します。これは攻撃者が手元の環境で動かすスクリプトです。
<?php
  require 'Logger.php';  // ファイル名のバリデーションは無効にしておく
  $x = new Logger('../../../var/www/html/evil.php');
  $x->add("<?php phpinfo(); ?>\n");
  setcookie('status', serialize($x));
これが生成するCookieは以下のとおりです。実際にはCookieの値はパーセントエンコードされています。また、下記で[NUL]はヌルバイトを示します。
Set-Cookie: status=O:6:"Logger":2:{s:16:"[NUL]Logger[NUL]filename";s:30:"../../../var/www/html/evil.php";s:11:"[NUL]Logger[NUL]log";s:20:"<?php phpinfo(); ?>
攻撃者はこのクッキーを攻撃対象のサイトで有効になるように自分のブラウザにセットします。この状態で先のgetcookie.phpにアクセスします。問題になる箇所は以下の3行です。
$status = unserialize($_COOKIE['status']); // Loggerオブジェクトがデシリアライズされる
if (! is_array($status))           // 配列ではないので
  die('statusは配列が必要です');   // 終了する
あれあれ、単に「statusは配列が必要です」というエラーメッセージを表示して止まるだけ…ではありません。Loggerオブジェクトが生成されているのでこの後Loggerクラスのデストラクタが動きます。

ここでファイル名は /tmp/../../../var/www/html/evil.php が指定されているので、ディレクトリトラバーサルと同じ原理で /var/www/html/evil.php がオープンされ、バッファの内容 <php phpinfo(); ?>が書き込まれます。
後は、攻撃者は攻撃対象のサイトにて evil.php を閲覧することにより、任意のPHPスクリプトが実行できることになります。

いったんまとめ

攻撃が成立する条件は下記となります。
  • PHPのunserialize関数に与える引数を外部からコントロールできる
  • アプリケーションにてクラスを定義しており、デストラクタが攻撃に悪用できる
  • ドキュメントルート下のディレクトリにPHPスクリプトから書き込みができる
ただし、以下の記事にあるように、攻撃に使える経路としてはデストラクタだけではありません。
また、この問題の有名な脆弱性がCakePHPにありました。以下の記事が参考になります。

何が問題か

攻撃者がunserialize関数に任意の文字列を処理させることが出来る場合、攻撃者は自分の都合の良いクラスのオブジェクトを攻撃対象アプリケーション内で生成させることができます。このクラスは、アプリケーション側で元々定義されているものに限られるため、unserializeがあると直ちに危険であるとは限りませんが、大規模なアプリケーションの場合、危険性を見極めるのはかなり困難であろうと思います。

根本的な解決策

攻撃者が任意のクラスのオブジェクトを自由に生成できる状況は、直接の危険性がないとしても、好ましくない状況と言えます。このため、根本的解決策として以下を推奨します。
  • 外部からコントロールできる値をunserialize関数に処理させない
同じ意味のことをセキュアコーディングの用語を用いると下記のようになります。
  • 信頼境界を超えて渡されるデータをデシリアライズしてはならない
そして、このような文脈では、「信頼できないデータを検証する」とか「信頼できないデータは無害解する」というような表現を見かけますが、このデシリアライズ問題については検証も無害化も困難です。検証が複雑すぎるからです。

このため、serialize/unserializeの代わりに、implode/explodeやjson_encode/json_decodeを使うとよいでしょう。どうしてもserialize/unserializeを使いたい場合は、セッション変数等安全な方法でシリアライズされた値をやりとりします。
  • serialize/unserializeの代わりに、implode/explodeやjson_encode/json_decodeを用いる

保険的対策

Loggerクラスのデストラクタの冒頭は以下のファイル名組み立てですが、局所的にみるとディレクトリトラバーサル脆弱性があります。
$path = self::LOGDIR . $this->filename;  // ファイル名の組み立て
通常のケースでは、コンストラクタでフアイル名をバリデーションしているので、ディレクトリトラバーサル攻撃はできないように見えますが、デシリアライズ等の経路でオブジェクトが生成された場合、コンストラクタは通らないので、不正なファイル名が使われる可能性があります。このため、以下のように$this->filenameにbasename関数を通しておくと安全です。
$path = self::LOGDIR . basename($this->filename);  // 安全なファイル名組み立て
上記は、unserializeによるオブジェクト汚染に対しては保険的な対策ですが、ディレクトリトラバーサル脆弱性に対して根本的解決策となります。
上記のように、一見大丈夫と思える箇所であっても、脆弱性の発生原因箇所に対して局所的に脆弱性を解消しておくことで、アプリケーション全体の安全性を高めることができます。

参考: もう入力値検証はセキュリティ対策として *あてにしない* ようにしよう

また、ドキュメントルート下のディレクトリに対して、PHPスクリプトから書き込みができないように権限を制限することも保険的な対策になります。

まとめ

PHPのunserialize関数の危険性について説明しました。ここで紹介した攻撃方法は、オブジェクトが生成されてしまうとどうしてもデストラクタを通ることになるため、オブジェクトが生成されてからバリデーション等をおこなっても手遅れです。かといって、シリアライズされた状態のままバリデーションを行うことも困難です。このため、外部由来のデータについてはunserialize関数を用いないことが現実的な対策となります。


【HASHコンサルティング広告】
HASHコンサルティング株式会社は、セキュリティエンジニアを募集しています。
興味のある方は、twitterfacebookのメッセージ、あるいは問い合わせページからお問い合わせください。


1 件のコメント:

  1. stas さんのとりくみにより、PHP 7.0 で許可するクラスの配列を指定するオプションが追加されました。日本語マニュアルでも読めるようになっています。

    https://wiki.php.net/rfc/secure_unserialize

    返信削除

フォロワー

ブログ アーカイブ