0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

5つの検証でPHPの任意ファイル読み込みを防ぐ(ディレクトリトラバーサル対策・WordPress対応・コピペOK)

0
Posted at

自作の PHP で「ファイルをダウンロードさせる」「ページを切り替えて include する」処理を書いたことがある方向けです。ここにユーザー入力をそのまま渡すと、公開するつもりのないファイル(設定ファイル・鍵・バックアップ)まで読めてしまう ディレクトリトラバーサル / 任意ファイル読み込み になります。

コピペして使える検証関数と 5 つのチェック、WordPress での正しい書き方、サーバー側の多層防御、自分のコードを洗い出す grep までをまとめます。

なぜ起きるのか(1 分)

原因はひとつだけです。ユーザーが決めた文字列を、ファイルパスの一部として使っていること。

// いずれも危険。$_GET の値がパスの一部になっている
require __DIR__ . '/pages/' . $_GET['page'] . '.php';
readfile('/var/www/files/' . $_GET['name']);
$html = file_get_contents($_GET['tpl']);

パスは「上位ディレクトリを指す表記(../)」を含められるため、開発者が想定した公開用ディレクトリの外に出られます。読み出しだけなら情報漏えい、include / require に渡っている場合はその中身が PHP として実行されるため、被害は一段深刻になります。

ありがちなのが str_replace('../', '', $_GET['name']) のような「危険な文字列を消す」対策ですが、除去は 1 回しか走らないため、除去後に再び同じ並びが現れる書き方をされると素通りします。URL エンコード・OS ごとの区切り文字と、考慮すべき表記も際限なく増えます。**「危険な値を消す」ではなく「安全な値だけを通す」**が唯一の安定解です。

対策の手は 2 つで、どちらか一方で足ります。許可リスト方式(ユーザーからはキーだけ受け取り、実際のパスはサーバー側の対応表から引く)と、基準ディレクトリ配下であることの検証(連結後に realpath() で正規化して判定する)です。

コピペで使える検証関数(5 つのチェック)

ファイル名が動的に決まる場合の実装です。5 つのチェックが順番に効いています。

<?php
/**
 * $base ディレクトリ配下のファイルに限ってフルパスを返す。
 * 許可できない場合は null を返す(呼び出し側で 404 にする)。
 */
function resolve_within(string $base, string $userInput): ?string
{
    // 【検証1】ヌルバイト混入を先に弾く(PHP 8 系ではこの後 ValueError になる)
    if (strpos($userInput, "\0") !== false) {
        return null;
    }

    // 【検証2】パス区切りを許さずファイル名だけ取り出す。basename() は
    // Windows 以外でバックスラッシュを区切り扱いしないので先に「/」へ寄せる
    $name = basename(str_replace('\\', '/', $userInput));
    if ($name === '' || $name === '.' || $name === '..') {
        return null;
    }

    // 【検証3】基準ディレクトリを正規化(false なら設定ミス)
    $baseReal = realpath($base);
    if ($baseReal === false) {
        return null;
    }

    // 【検証4】連結してから正規化する。realpath() は /../ と /./ を解決し
    // シンボリックリンクも展開する。存在しない・権限が無い・
    // open_basedir 違反なら false
    $target = realpath($baseReal . DIRECTORY_SEPARATOR . $name);
    if ($target === false) {
        return null;
    }

    // 【検証5】区切り文字まで含めて前方一致を確認する。$baseReal だけで
    // 比較すると /var/www/files に /var/www/files-old が一致してしまう
    $prefix = $baseReal . DIRECTORY_SEPARATOR;
    if (strncmp($target, $prefix, strlen($prefix)) !== 0) {
        return null;
    }

    return is_file($target) ? $target : null;
}

検証 5 は PHP 8.0 以降なら str_starts_with($target, $prefix) と書けます(同関数は PHP 8.0 で追加)。PHP 7 系も動かすなら strncmp() のままにしてください。

検証 4 と 5 はセットです。 realpath() は正規化するだけで、どこを指していても値を返します。配下かどうかの判定は検証 5 が担い、逆に検証 5 を正規化前の文字列に対して行っても意味がありません。

ダウンロード配信の実装例

上の関数を使った配信スクリプトです。

<?php
require __DIR__ . '/resolve_within.php';

$file = resolve_within(__DIR__ . '/private_files', (string)($_GET['name'] ?? ''));
if ($file === null) {
    http_response_code(404);
    exit;
}

// 拡張子も許可リストで縛り、MIME タイプは自分で決める(推測させない)
$allowed = [
    'pdf' => 'application/pdf',
    'png' => 'image/png',
    'jpg' => 'image/jpeg',
];
$ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));
if (!isset($allowed[$ext])) {
    http_response_code(404);
    exit;
}

// ヘッダに入れる名前も安全な文字だけに落とす(改行やクォートの混入防止)
$download = preg_replace('/[^A-Za-z0-9._-]/', '_', basename($file));

header('Content-Type: ' . $allowed[$ext]);
header('Content-Disposition: attachment; filename="' . $download . '"');
header('X-Content-Type-Options: nosniff');
header('Content-Length: ' . filesize($file));
readfile($file);

配信対象がログイン利用者向けの資料なら、この前段に必ずログイン状態と閲覧権限のチェックを置いてください。パスの検証は「別のファイルを読ませない」対策であって、「その人が見てよいか」の判断は別物です。

include / テンプレート切り替えは許可リスト一択

include 系にユーザー入力が届く設計は、検証を重ねるより 対応表を引く形に書き換える方が確実です。

<?php
// キーと実ファイルの対応表はサーバー側だけが持つ
$pages = [
    'about'   => 'pages/about.php',
    'contact' => 'pages/contact.php',
    'price'   => 'pages/price.php',
];

$key = (string)($_GET['page'] ?? 'about');
if (!isset($pages[$key])) {
    http_response_code(404);
    exit;
}

require __DIR__ . '/' . $pages[$key];

この形なら $_GET['page'] に何が入ってもパスは対応表の 3 つ以外になりません。言語ファイル・レイアウト・帳票テンプレートの切り替えも同じ書き方でそろえられます。

WordPress での書き方

自作テーマ・自作プラグインで同じ形をよく見かけます。

<?php
// NG: ユーザー入力を ABSPATH に連結している
include ABSPATH . 'wp-content/' . $_GET['tpl'];

// OK: 許可リスト + validate_file() + プラグイン基準のパス
$allowed = [
    'report'  => 'templates/report.php',
    'invoice' => 'templates/invoice.php',
];
$key = sanitize_key($_GET['tpl'] ?? '');
if (!isset($allowed[$key])) {
    wp_die('不正なリクエストです。', '', ['response' => 400]);
}

$rel = $allowed[$key];
if (validate_file($rel) !== 0) {          // 0 = 問題なし
    wp_die('不正なリクエストです。', '', ['response' => 400]);
}

require plugin_dir_path(__FILE__) . $rel;

validate_file() は WordPress 本体の検証関数で、戻り値は 0 = 問題なし / 1 = ../ を含む / 2 = Windows のドライブ指定を含む / 3 = 許可ファイルの配列に無い です。ただし見ているのはパス文字列の書き方であって、「基準ディレクトリ配下か」は保証しません。可変のファイル名を扱うときは前掲の realpath() 判定と併用してください。

アップロード済みファイルの配信なら、パスではなく添付 ID で扱うのが最も安全です。

<?php
$attachment_id = absint($_GET['id'] ?? 0);
if (get_post_type($attachment_id) !== 'attachment') {
    wp_die('不正なリクエストです。', '', ['response' => 400]);
}

$path = get_attached_file($attachment_id);   // WordPress がパスを解決する
if (!$path || !is_file($path)) {
    wp_die('ファイルが見つかりません。', '', ['response' => 404]);
}

受け取るのが整数の ID だけなので、そもそもパスを組み立てる余地がありません。

サーバー側の多層防御

アプリ側を直したうえで、書き漏らしに備えて php.ini(またはバーチャルホスト単位)も締めます。

; 指定ディレクトリの外はファイル操作自体を禁止する
open_basedir = /var/www/example.com/:/tmp/

; include 系で URL を読ませない(既定 0 = 無効。PHP 7.4.0 以降は非推奨の項目)
allow_url_include = Off

; 外部 URL 取得が不要なら無効化を検討(既定 1 = 有効)
; ※ ライブラリやプラグインが外部通信に使っている場合があり、要検証
allow_url_fopen = Off

open_basedir を設定すると、realpath() は制限の外を指した時点で false を返します。前掲の検証関数がそのまま安全側に倒れるので相性がよい組み合わせです。

さらに、非公開ファイルのディレクトリを直接配信させない設定も入れておきます。

# 配信は PHP を経由させ、実体のディレクトリには直接触らせない
location ^~ /private_files/ {
    deny all;
    return 404;
}

理想は、そもそも非公開ファイルをドキュメントルートの外に置くことです。

自分のサイトを確認する

まず、危険な形が残っていないかをコードから洗い出します。

# ユーザー入力が include / require に届いていないか
grep -rnE '(include|require)(_once)?[^;]*\$_(GET|POST|REQUEST|COOKIE)' --include='*.php' .

# ファイル操作系の関数に直接渡っていないか
grep -rnE '(readfile|file_get_contents|fopen|file_exists|unlink|copy)\s*\([^)]*\$_(GET|POST|REQUEST|COOKIE)' --include='*.php' .

そのうえで、自分が管理しているサイトに限って想定外の値が弾かれるかを確認します。

# 存在しない名前 → 404 が返ること
curl -s -o /dev/null -w '%{http_code}\n' 'https://example.com/download.php?name=no-such-file'

# パス区切りを含む名前 → 200 にならないこと(404 か 400 が正しい)
curl -s -o /dev/null -w '%{http_code}\n' --path-as-is 'https://example.com/download.php?name=../config.php'

--path-as-is は curl 側でパスを正規化させないためのオプションです。ここで 200 が返るなら対策前と考えてください。他社のサイトに対しては絶対に実施しないでください。

まとめ

  • 原因は「ユーザー入力をパスの一部にしていること」の一点。消す対策は破られるので、安全な値だけを通す設計にする
  • include / テンプレート切り替えは許可リストの対応表に置き換える
  • ファイル名が可変なら**realpath() で正規化 → 基準ディレクトリ配下かを区切り文字込みで判定**(検証 4 と 5 はセット)
  • WordPress は validate_file() と添付 ID 経由の配信を使う。validate_file() は書き方の検証であって配下の保証ではない
  • open_basedir と非公開ファイルのドキュメントルート外配置で多層防御にし、パスの検証とは別に権限チェックも置く

関連記事

本記事のような設定の抜けは、Web サイトを 9 つの守りでまるごと守るサイトドックでまとめて対策できます → https://sitedock.jp

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?