【実務・中級編】 安全なファイルアップロード機能の実装とディレクトリトラバーサル対策 – 暗号理論・認証基盤 & エンドポイントセキュリティ防御ガイド

ファイルアップロード機能の闇:なぜ「画像専用」がRCE(遠隔コード実行)の踏み台になるのか

おい、ちょっと手を止めてくれ。
お前たちが日々何気なく実装している「ファイルアップロード機能」、あれはWebアプリケーションにおける最も危険なアキレス腱の一つだ。

「ちゃんと拡張子を .jpg に制限してます」
「MIMEタイプが image/jpeg のものだけ受け付けてます」

……もし、後輩の口からこんなセリフが出てきたら、俺なら即座にそのプルリクエストをフェイルさせる。甘い。あまりにも甘すぎる。攻撃者はそんな表層的なバリデーションなど、秒速でバイパスしてくる。

今日は、数々のインシデント現場で血を流してきた俺が、安全なファイルアップロード機能の実装とディレクトリトラバーサル対策について、綺麗事抜きの現実的な話を叩き込む。教科書には載っていない「攻撃者の視点」と、明日からそのまま使える「堅牢なコード」を授けよう。

—

1. 攻撃者がファイルアップロード機能を狙う本当の理由

Webアプリケーションにファイルをアップロードさせる機能は、攻撃者にとって「サーバー内部に侵入するための最も確実な足がかり」だ。

彼らの目的は、単にサーバーのストレージを圧迫することではない。「任意のコードをサーバー上で実行すること(RCE)」、これに尽きる。
もし、アップロードされたファイルをそのままWebサーバーの公開ディレクトリ(ドキュメントルート)に保存し、さらに実行権限を与えていたらどうなるか?

攻撃者は shell.php という名前のファイルをアップロードし、それにアクセスするだけで、Webサーバーの権限で任意のOSコマンド(cat /etc/passwd やリバースシェルなど)を自由自在に実行できてしまう。こうなったら、そのシステムはもうおしまいだ。外部からの侵入を防ぐ最後の砦は完全に突破されている。

—

2. ありがちな「ザル」なバリデーションと、その無力化

現場でよく見る「ダメな実装」と、攻撃者がそれをどうやって突破するかを整理しておこう。

抜け穴その1:拡張子ブラックリスト方式

php や exe のみを弾く方式。これは論外だ。攻撃者は shell.phtml, shell.php5, shell.phar などの別名を使い、サーバーがそれをPHPスクリプトとして解釈する設定になっていれば、一撃で踏み台にされる。

抜け穴その2:Content-Type(MIMEタイプ)の過信

HTTPリクエストヘッダの Content-Type: image/jpeg を見ているだけのコードは、curl や Burp Suite などのプロキシツールを使えば、中身がPHPコードであっても一瞬で偽装できる。ヘッダなど、クライアントから送られてくるものは一切信用してはならない。

抜け穴その3:甘いファイル名チェックとディレクトリトラバーサル

ファイル名に ../../etc/passwd のような相対パスが含まれているのを防がないでおくと、意図しないディレクトリ(ソースコードの書き換えや設定ファイルの上書き)にファイルを配置される恐れがある。

—

3. 完全防御のための4大鉄則

安全なファイルアップロード機能を実装するためには、以下の4つの鉄則を同時に満たす必要がある。

1. 保存先の完全分離と実行権限の剥奪: アップロードされたファイルは、Webサーバーの公開ディレクトリ(ドキュメントルート)外に保存する。また、仮にドキュメントルート内に置かざるを得ない場合でも、そのディレクトリ内でのスクリプトの実行権限(PHP等のパース)をNginxやApacheの設定で確実に殺す。
2. ホワイトリスト方式の拡張子検証: 許可する拡張子を極限まで絞り込み(例: .jpg, .png のみ)、それ以外はすべて拒否する。
3. MIMEタイプの厳密なマジックバイト検証: 拡張子やHTTPヘッダではなく、ファイルの先頭数バイト(マジックバイナリ)をプログラム自身が読み込み、本当に画像ファイルであるかを判定する。
4. ファイル名の完全無害化(サニタイズ・ハッシュ化): ユーザーが入力した元のファイル名は一切信用せず、一意なランダム文字列(UUIDやハッシュ)にリネームして保存する。

—

4. 【コピペで動く】セキュアなアップロード処理の実装サンプル(PHP)

百聞は一見に如かずだ。以下に、上記の鉄則をすべて満たしたセキュアなPHPのアップロード処理サンプルコードを提示する。実務のベースとしてそのまま利用してくれ。

<?php
/**
 * セキュアなファイルアップロード処理サンプル
 * 
 * 【前提条件】
 * - 保存先ディレクトリはWeb公開領域(ドキュメントルート)の外側に配置すること
 * - 例: /var/www/secure_uploads/ (Webサーバー経由で直接アクセスできない場所)
 */

// 1. 定数定義
define('UPLOAD_DIR', '/var/www/secure_uploads/'); // ドキュメントルート外の安全なパス
define('MAX_FILE_SIZE', 5 * 1024 * 1024);        // 最大5MB

// リクエストメソッドの確認
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    http_response_code(405);
    exit('Method Not Allowed');
}

// CSRFトークンの検証(実際のアプリケーションではここで検証を行うこと)
// if (!hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) { ... }

try {
    // エラーチェック
    if (!isset($_FILES['userfile']['error']) || is_array($_FILES['userfile']['error'])) {
        throw new RuntimeException('Invalid parameters.');
    }

    switch ($_FILES['userfile']['error']) {
        BURP_CASE_OK: // UPLOAD_ERR_OK
            break;
        case UPLOAD_ERR_INI_SIZE:
        case UPLOAD_ERR_FORM_SIZE:
            throw new RuntimeException('Exceeded filesize limit.');
        default:
            throw new RuntimeException('Unknown errors.');
    }

    // ファイルサイズチェック
    if ($_FILES['userfile']['size'] > MAX_FILE_SIZE) {
        throw new RuntimeException('Exceeded filesize limit (Max 5MB).');
    }

    // 2. マジックバイト(ファイルシグネチャ)による厳密なMIMEタイプ判定
    // ※ 拡張子やHTTPヘッダのContent-Typeは簡単に偽装されるため一切信用しない
    $finfo = new finfo(FILEINFO_MIME_TYPE);
    $mimeType = $finfo->file($_FILES['userfile']['tmp_name']);

    $allowedMimeTypes = [
        'image/jpeg' => 'jpg',
        'image/png'  => 'png',
        'image/gif'  => 'gif',
    ];

    if (!array_key_exists($mimeType, $allowedMimeTypes)) {
        throw new RuntimeException('Invalid file format. Only JPEG, PNG, and GIF are allowed.');
    }

    $extension = $allowedMimeTypes[$mimeType];

    // 3. ファイル名の完全なハッシュ化(ディレクトリトラバーサル・インジェクション対策)
    // 元のファイル名は日本語や特殊文字、パス区切り文字が含まれるため絶対にそのまま使わない
    $safeFilename = sprintf('%s_%s.%s',
        date('YmdHis'),
        bin2hex(random_bytes(16)),
        $extension
    );

    $destination = UPLOAD_DIR . $safeFilename;

    // ディレクトリトラバーサル対策の念押し(realpathによるパスの正規化検証)
    // 万が一、パスが外側に脱出していないかを確認
    $realBase = realpath(UPLOAD_DIR);
    // 保存先ディレクトリが存在しない場合は作成(権限は厳しく設定すること)
    if ($realBase === false) {
        mkdir(UPLOAD_DIR, 0750, true);
        $realBase = realpath(UPLOAD_DIR);
    }

    // 4. 安全な移動処理
    if (!move_uploaded_file($_FILES['userfile']['tmp_name'], $destination)) {
        throw new RuntimeException('Failed to move uploaded file.');
    }

    // パーミッションの絞り込み(所有者のみ読み書き可能に設定)
    chmod($destination, 0640);

    echo json_encode([
        'status' => 'success',
        'message' => 'File uploaded successfully.',
        'filename' => $safeFilename
    ], JSON_UNESCAPED_UNICODE);

} catch (RuntimeException $e) {
    http_response_code(400);
    echo json_encode([
        'status' => 'error',
        'message' => $e->getMessage()
    ], JSON_UNESCAPED_UNICODE);
}

—

5. インフラ側(Nginx)での防御設定

コード側でどれだけ頑張っても、Webサーバー側の設定がザルであれば意味がない。特にNginxやApacheで、アップロードディレクトリ内でのスクリプト実行を禁止する設定は必須だ。

もしどうしてもドキュメントルート内にファイルを保存しなければならないレガシーな事情がある場合は、Nginxの設定ファイルに以下のブロックを必ず記述しろ。これにより、指定ディレクトリ内でのPHP等のスクリプト実行を完全に封じ込めることができる。

# アップロード用ディレクトリ内でのスクリプト実行禁止設定 (Nginx)
location ^~ /uploads/ {
    # 静的ファイルの配信のみを許可し、PHPなどの高速CGI/FastCGI処理をバイパスする
    # これにより、たとえ shell.php がアップロードされても、単なるテキストとしてダウンロードされるか403になる
    
    location ~* \.php$ {
        deny all;
    }
    
    # 必要に応じて他の実行系拡張子もすべて拒否
    location ~* \.(php|php5|phtml|phar|pl|py|jsp|asp|sh|cgi)$ {
        deny all;
    }
}

—

最後に:セキュリティは「疑うこと」から始まる

ファイルアップロード機能の実装において、ユーザーが入力するデータ、ブラウザが送信してくるヘッダ、そしてアップロードされるファイルの中身、そのすべてが「悪意ある攻撃者の手によるものかもしれない」という前提で設計を組み立ててほしい。

「動けばいいや」で作ったコードが、数日後に会社の信頼を吹き飛ばす踏み台になる。
チームメンバー全員にこの思想を共有し、明日からの開発コードを今一度見直してくれ。頼んだぞ。

コメント

タイトルとURLをコピーしました