PHPにおけるディレクトリトラバーサルへの対応方法

いまさらながら、ディレクトリトラバーサルへの対応方法について調べました。

問題となる脆弱性
次のようなプログラムを getfile_ng.php として用意し、http://example.jp/getfile_ng.php で動作できるようにしたとします。

<?php
$filename=$_GET['filename'];
$file = '/var/www/html/' . $filename;
if (file_exists($file) === true) {
  readfile($file); // NG
}

このとき「http://example.jp/getfile_ng.php?filename=../../../../../etc/passwd」とすると/etc/passwdが表示されてしまいます。すぐに思いつく対応策としては、「.html」のように固定の文字列をつける方法です。こうしておけば大丈夫でしょうか。

<?php
$filename=$_GET['filename'];
$file = '/var/www/html/' . $filename . '.html';
if (file_exists($file) === true) {
  readfile($file); // NG
}

これに対しては「http://example.jp/getfile_ng.php?filename=../../../../../etc/passwd%00」とすると/etc/passwdが表示されてしまうことがあります。Firefox 23.0.1 for Linux, PHP 5.4.19 on CentOS6では問題は発生しませんでしたが、参考資料ではこのように紹介されていたので古い環境などでは発生すると思われるため、紹介をしておきます。こういった方法では対策が足りません。

一般には下記のような対策のいずれかをとれば良いと言われています。

  • ファイル名にディレクトリ名が含まれないようにする
  • ファイル名の妥当性チェックをする(単純なのは英数字に限定かつ拡張子は固定)
  • 外部からファイル名を直接指定できないようにする

strposとstr_replaceで対策
対応策はファイル指定したいものがどういったものであるかという要件により変わってきます。ひとつの方法に、「..」が含まれていたら処理を終了して、NULLバイト攻撃に対してはNULLを除去する処理があります。

<?php
function get_filename() {
  $filename=$_GET['filename'];
  if (strpos($filename, '..') !== false) {
    die('filenameには相対パスは指定不可');
  }
  return str_replace('\0', '', $filename);
}

$filename = get_filename();
$file = '/var/www/html/' . $filename;
if (file_exists($file) === true) {
  readfile($file); // OK
}

strposで「..」をチェック、str_replaceで「\0」を除去という処理をしています。

basenameとstr_replaceとmb_eregで対策
basename関数を使って、ファイル名の指定にはディレクトリ名を指定できないようにする方法もあります。次の例ではNULLバイト攻撃に対するNULLを除去する処理は入れて、拡張子が想定しているものと一致しているかを確認しています。

<?php
function get_file() {
  $filename = str_replace('\0', '', $_GET['filename']);
  return '/var/www/html/' . basename($filename) . '.html';
}

$file = get_file();
if (file_exists($file) === true) {
  if (mb_ereg('\.html$', $file) === 1) {
    readfile($file); // OK
  } else {
    die("拡張子が .html 以外です");
  }
}

ここではNULLバイト攻撃などに対して安全なバイナリセーフな関数mb_eregを使っています。同じくバイナリセーフなpreg_matchなどを使う方法もあります。「PHP: PCRE 関数 – Manual」で紹介されている関数ですね。似たような関数で、「PHP: POSIX 正規表現関数 – Manualで紹介されている関数がありますが、こちらはバイナリセーフではなくPHP5.3.0で非推奨となりPHP6.0.0で削除されるそうなので、特別な理由がなければ利用しない方が良いでしょう。

ファイル名をチェック
場合によってはファイル名全体について正規表現で妥当性チェックをした方が良い場合もあるでしょう。ファイル名が英数字だけでよいのなら、下記のようになります。

<?php
function get_file() {
  $filename = str_replace('\0', '', $_GET['filename']);
  if (! preg_match('/\A[a-z0-9]+\z/ui', $filename)) {
    die('filenameは英数字のみ指定可能');
  }
  return '/var/www/html/' . basename($filename) . '.html';
}

$file = get_file();
if (file_exists($file) === true && mb_ereg('\.html$', $file) === 1) {
  readfile($file); // OK
}

どこでファイル名チェックをするのが良いのかは意見が色々ありそうですが、最後に開くところでチェックしておけば間違いがないだろうということで、最後にいれてあります。

open_basedirを指定してより安全に
PHP5.3.0以降であれば、open_basedirを指定して、限定されたディレクトリ内のみ公開という方法がとれます。上記の対策を突破されたときの保険として入れておくと良いでしょう。

<?php
ini_set('open_basedir', '/var/www/html'); // 追加

function get_filename() {
  $filename=$_GET['filename'];
  if (strpos($filename, '..') !== false) {
    die('filenameには相対パスは指定不可');
  }
  return str_replace('\0', '', $filename);
}

$filename = get_filename();
$file = '/var/www/html/' . $filename;
if (file_exists($file) === true) {
  readfile($file); // OK
}

下記書籍が参考になります。

同じタグの記事: PHP
同じカテゴリの記事: Program
関連書籍: PHP