ファイルアップロード機能の闇:なぜ「画像専用」が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;
}
}
—
最後に:セキュリティは「疑うこと」から始まる
ファイルアップロード機能の実装において、ユーザーが入力するデータ、ブラウザが送信してくるヘッダ、そしてアップロードされるファイルの中身、そのすべてが「悪意ある攻撃者の手によるものかもしれない」という前提で設計を組み立ててほしい。
「動けばいいや」で作ったコードが、数日後に会社の信頼を吹き飛ばす踏み台になる。
チームメンバー全員にこの思想を共有し、明日からの開発コードを今一度見直してくれ。頼んだぞ。
コメント