コラム / セキュリティ /
ファイルアップロード機能の脆弱性チェックリスト ─ 拡張子検証だけでは止まらない攻撃と、現場で効く9つの実装
📖 約8分 / 公開日: 2026/06/06
「画像アップロードからサイトを乗っ取られた」という相談は、毎月のように届きます。共通しているのは、アップロード処理に検証が一切なかったわけではない点です。拡張子はチェックしていた。MIME タイプも見ていた。それでも .php を仕込まれ、Web シェルを設置されていました。
拡張子と MIME の検証は、攻撃者にとって最初の関門ですらありません。両方とも、ブラウザのリクエストを書き換えるだけで簡単に偽装できます。ファイルアップロードを安全にするというのは、検証を1つ足すことではなく、複数の独立した防御を重ねることです。実際の被害対応で再発防止として組み込んでいる項目を、効く順に並べます。
## 1. 拡張子はホワイトリストで判定する
ブラックリスト方式、つまり「.php と .phtml と .exe を弾く」発想は必ず破られます。.php5、.pht、.phar、サーバ設定次第で実行される拡張子は無数にあり、列挙は不可能です。
許可する拡張子だけを定義し、それ以外を全て拒否する。画像なら jpg、jpeg、png、webp、gif の5つで足ります。この時点で実行可能ファイルの大半は入口で落ちます。判定は小文字化してから行ってください。.PHP や .Php を見落とす実装を何度も見ています。
## 2. 多重拡張子と NULL バイトを潰す
shell.php.jpg というファイル名は、Apache の AddHandler 設定が緩いと .php として実行されます。ファイル名に含まれるドットを全て検査し、許可拡張子は末尾の1つだけで判定する。中間に実行可能拡張子が混ざっていたら拒否するのが安全です。
image.php%00.jpg のような NULL バイト挿入は古い PHP では有効でした。現行の PHP 8.3 では基本的に無効化されていますが、ファイル名を自前でパースしている処理があると話は別です。受け取ったファイル名は決してそのまま使わず、自分で生成した UUID に置き換える。これが一番確実です。元のファイル名は表示用にメタデータとして DB に持てば十分で、ファイルシステム上の名前として使う理由はありません。
## 3. MIME は信用せず、中身を読む
リクエストの Content-Type ヘッダは攻撃者が自由に書けます。Laravel の $file->getMimeType() は拡張子ベースで判定するため、これも当てになりません。
信用できるのはファイルの中身、マジックナンバーです。PHP なら finfo を FILEINFO_MIME_TYPE で開いて先頭バイトから実際の型を読む。JPEG なら FFD8FF、PNG なら 89504E47 で始まります。Node なら file-type パッケージが同じことをしてくれます。拡張子は jpg なのに中身が PHP スクリプトというファイルは、ここで初めて捕まえられます。
ただしマジックナンバーも万能ではありません。GIF89a; の後ろに PHP コードを連結した多言語ファイル(polyglot)は、GIF として有効でありながら PHP としても実行されます。中身検証は必要条件であって十分条件ではない、という前提を崩さないでください。
## 4. 画像は必ず再エンコードする
polyglot を含む細工された画像への最も強い対策が、受け取った画像をサーバ側で必ず作り直すことです。
ImageMagick や GD、sharp で読み込んで別ファイルとして出力し直すと、画像本来のピクセルデータ以外、つまり末尾に連結された PHP コードや EXIF に埋め込まれたスクリプトは全て捨てられます。同時にサムネイル生成や容量圧縮もできるので、やらない理由がありません。
注意点が一つ。ImageMagick 自体に過去 ImageTragick(CVE-2016-3714)という、画像を読ませるだけで任意コマンドが実行される深刻な脆弱性がありました。policy.xml で MVG や MSL、HTTPS といった危険な coder を無効化し、バージョンを最新に保つこと。再エンコードの道具自体が穴になる構図は、定期的に繰り返されています。可能なら ImageMagick より攻撃面の狭い libvips(sharp の内部)を選ぶのが今の推奨です。
## 5. 保存先を公開ディレクトリの外に置く
ここが設計の分かれ目です。アップロードされたファイルを public/uploads/ に直接置く実装は、たとえ検証を通っても危険が残ります。万一すり抜けたファイルが、URL を直接叩くだけで実行されてしまう。
保存先は document root の外、Laravel なら storage/app に置き、配信はコントローラ経由にする。ファイルを返す前に認可チェックを挟めるようになり、実行権限のないディレクトリから読むだけなのでスクリプトとして動く余地がなくなります。
Apache を使っているなら、アップロードディレクトリに php_flag engine off と Options -ExecCGI を効かせた .htaccess を置く多層防御も併用してください。nginx なら location 単位で fastcgi を通さない設定です。
## 6. S3 直アップロードの presigned URL には独自の盲点がある
サーバ負荷を下げるため、ブラウザから S3 へ presigned URL で直接アップロードさせる構成が増えました。便利ですが、サーバを経由しないということは、上記の中身検証が全て飛ばされるということです。
Client が宣言した Content-Type のまま、検証なしに何でも保存できてしまう。対策は、presigned URL 発行時に Content-Type と容量上限を条件として固定し、アップロード完了後に Lambda や非同期ジョブで中身を検証してから「公開可能」フラグを立てる二段構えにすること。検証前のオブジェクトは非公開バケットに入れ、通過したものだけ公開バケットや CDN に移す。presigned URL は楽に見えて、検証の責任がクライアント任せになりやすい構成だと理解して使ってください。
## 7. 容量と枚数の上限をアプリ層で必ず持つ
php.ini の upload_max_filesize に頼り切ると、設定が変わった瞬間に DoS の入口になります。アプリケーションのバリデーションで明示的に上限を持つ。Laravel なら max:5120 のように KB 単位で書けます。
画像なら1枚5MBもあれば過剰なくらいで、10MB を超える画像アップロードを許す業務はほとんどありません。同時に解像度の上限も設けてください。100000×100000 ピクセルの画像は数十バイトの圧縮ファイルでも、展開時にメモリを食い潰します。decompression bomb と呼ばれる古典的な攻撃で、画像ライブラリのメモリ上限設定で防ぎます。
## 8. アップロード後にウイルススキャンを挟む
ユーザー同士がファイルを共有するサービスや、PDF・Office 文書を受け取る業務システムでは、ClamAV を1枚噛ませる価値があります。無料で、Docker サイドカーとして動かせて、既知マルウェアの大半は止まります。
完璧ではありません。新種は検知をすり抜けます。それでも、受け取った文書を別ユーザーへ再配布するサービスで「うちはスキャンしていません」と説明する事態は避けたい。スキャンは検証済みフラグが立つ前、隔離領域にある間に実行します。
## 9. ログとレート制限を最後に置く
誰が、いつ、何を、どの IP から上げたか。アップロードの監査ログがないサイトは、侵入されても侵入経路を特定できません。被害対応で最初に欲しくなるのがこのログで、なくて泣くことになります。
1 IP あたりのアップロード回数にレート制限をかけ、短時間の大量アップロードを遮断する。Laravel の throttle ミドルウェアや、nginx の limit_req で十分です。総当たりで実行可能ファイルを探る攻撃を、地味ですが確実に鈍らせます。
## 9項目すべてを一度に入れる必要はない
優先順位をつけるなら、5番(公開ディレクトリ外への保存)、4番(再エンコード)、2番(ファイル名のUUID置換)の3つが投資対効果で抜けています。この3つだけで、実際に観測される攻撃の大半は成立しなくなります。
逆に、拡張子チェックと MIME 検証「だけ」で安全だと思っているなら、その認識が一番危険です。両方とも数分で偽装できる関門で、防御の主役にはなりません。既存システムのアップロード処理を点検するなら、まず保存先が public 配下かどうかを見てください。そこが公開ディレクトリなら、他が完璧でも一度すり抜けたら終わりです。設計の土台から見直す価値があります。
拡張子と MIME の検証は、攻撃者にとって最初の関門ですらありません。両方とも、ブラウザのリクエストを書き換えるだけで簡単に偽装できます。ファイルアップロードを安全にするというのは、検証を1つ足すことではなく、複数の独立した防御を重ねることです。実際の被害対応で再発防止として組み込んでいる項目を、効く順に並べます。
## 1. 拡張子はホワイトリストで判定する
ブラックリスト方式、つまり「.php と .phtml と .exe を弾く」発想は必ず破られます。.php5、.pht、.phar、サーバ設定次第で実行される拡張子は無数にあり、列挙は不可能です。
許可する拡張子だけを定義し、それ以外を全て拒否する。画像なら jpg、jpeg、png、webp、gif の5つで足ります。この時点で実行可能ファイルの大半は入口で落ちます。判定は小文字化してから行ってください。.PHP や .Php を見落とす実装を何度も見ています。
## 2. 多重拡張子と NULL バイトを潰す
shell.php.jpg というファイル名は、Apache の AddHandler 設定が緩いと .php として実行されます。ファイル名に含まれるドットを全て検査し、許可拡張子は末尾の1つだけで判定する。中間に実行可能拡張子が混ざっていたら拒否するのが安全です。
image.php%00.jpg のような NULL バイト挿入は古い PHP では有効でした。現行の PHP 8.3 では基本的に無効化されていますが、ファイル名を自前でパースしている処理があると話は別です。受け取ったファイル名は決してそのまま使わず、自分で生成した UUID に置き換える。これが一番確実です。元のファイル名は表示用にメタデータとして DB に持てば十分で、ファイルシステム上の名前として使う理由はありません。
## 3. MIME は信用せず、中身を読む
リクエストの Content-Type ヘッダは攻撃者が自由に書けます。Laravel の $file->getMimeType() は拡張子ベースで判定するため、これも当てになりません。
信用できるのはファイルの中身、マジックナンバーです。PHP なら finfo を FILEINFO_MIME_TYPE で開いて先頭バイトから実際の型を読む。JPEG なら FFD8FF、PNG なら 89504E47 で始まります。Node なら file-type パッケージが同じことをしてくれます。拡張子は jpg なのに中身が PHP スクリプトというファイルは、ここで初めて捕まえられます。
ただしマジックナンバーも万能ではありません。GIF89a; の後ろに PHP コードを連結した多言語ファイル(polyglot)は、GIF として有効でありながら PHP としても実行されます。中身検証は必要条件であって十分条件ではない、という前提を崩さないでください。
## 4. 画像は必ず再エンコードする
polyglot を含む細工された画像への最も強い対策が、受け取った画像をサーバ側で必ず作り直すことです。
ImageMagick や GD、sharp で読み込んで別ファイルとして出力し直すと、画像本来のピクセルデータ以外、つまり末尾に連結された PHP コードや EXIF に埋め込まれたスクリプトは全て捨てられます。同時にサムネイル生成や容量圧縮もできるので、やらない理由がありません。
注意点が一つ。ImageMagick 自体に過去 ImageTragick(CVE-2016-3714)という、画像を読ませるだけで任意コマンドが実行される深刻な脆弱性がありました。policy.xml で MVG や MSL、HTTPS といった危険な coder を無効化し、バージョンを最新に保つこと。再エンコードの道具自体が穴になる構図は、定期的に繰り返されています。可能なら ImageMagick より攻撃面の狭い libvips(sharp の内部)を選ぶのが今の推奨です。
## 5. 保存先を公開ディレクトリの外に置く
ここが設計の分かれ目です。アップロードされたファイルを public/uploads/ に直接置く実装は、たとえ検証を通っても危険が残ります。万一すり抜けたファイルが、URL を直接叩くだけで実行されてしまう。
保存先は document root の外、Laravel なら storage/app に置き、配信はコントローラ経由にする。ファイルを返す前に認可チェックを挟めるようになり、実行権限のないディレクトリから読むだけなのでスクリプトとして動く余地がなくなります。
Apache を使っているなら、アップロードディレクトリに php_flag engine off と Options -ExecCGI を効かせた .htaccess を置く多層防御も併用してください。nginx なら location 単位で fastcgi を通さない設定です。
## 6. S3 直アップロードの presigned URL には独自の盲点がある
サーバ負荷を下げるため、ブラウザから S3 へ presigned URL で直接アップロードさせる構成が増えました。便利ですが、サーバを経由しないということは、上記の中身検証が全て飛ばされるということです。
Client が宣言した Content-Type のまま、検証なしに何でも保存できてしまう。対策は、presigned URL 発行時に Content-Type と容量上限を条件として固定し、アップロード完了後に Lambda や非同期ジョブで中身を検証してから「公開可能」フラグを立てる二段構えにすること。検証前のオブジェクトは非公開バケットに入れ、通過したものだけ公開バケットや CDN に移す。presigned URL は楽に見えて、検証の責任がクライアント任せになりやすい構成だと理解して使ってください。
## 7. 容量と枚数の上限をアプリ層で必ず持つ
php.ini の upload_max_filesize に頼り切ると、設定が変わった瞬間に DoS の入口になります。アプリケーションのバリデーションで明示的に上限を持つ。Laravel なら max:5120 のように KB 単位で書けます。
画像なら1枚5MBもあれば過剰なくらいで、10MB を超える画像アップロードを許す業務はほとんどありません。同時に解像度の上限も設けてください。100000×100000 ピクセルの画像は数十バイトの圧縮ファイルでも、展開時にメモリを食い潰します。decompression bomb と呼ばれる古典的な攻撃で、画像ライブラリのメモリ上限設定で防ぎます。
## 8. アップロード後にウイルススキャンを挟む
ユーザー同士がファイルを共有するサービスや、PDF・Office 文書を受け取る業務システムでは、ClamAV を1枚噛ませる価値があります。無料で、Docker サイドカーとして動かせて、既知マルウェアの大半は止まります。
完璧ではありません。新種は検知をすり抜けます。それでも、受け取った文書を別ユーザーへ再配布するサービスで「うちはスキャンしていません」と説明する事態は避けたい。スキャンは検証済みフラグが立つ前、隔離領域にある間に実行します。
## 9. ログとレート制限を最後に置く
誰が、いつ、何を、どの IP から上げたか。アップロードの監査ログがないサイトは、侵入されても侵入経路を特定できません。被害対応で最初に欲しくなるのがこのログで、なくて泣くことになります。
1 IP あたりのアップロード回数にレート制限をかけ、短時間の大量アップロードを遮断する。Laravel の throttle ミドルウェアや、nginx の limit_req で十分です。総当たりで実行可能ファイルを探る攻撃を、地味ですが確実に鈍らせます。
## 9項目すべてを一度に入れる必要はない
優先順位をつけるなら、5番(公開ディレクトリ外への保存)、4番(再エンコード)、2番(ファイル名のUUID置換)の3つが投資対効果で抜けています。この3つだけで、実際に観測される攻撃の大半は成立しなくなります。
逆に、拡張子チェックと MIME 検証「だけ」で安全だと思っているなら、その認識が一番危険です。両方とも数分で偽装できる関門で、防御の主役にはなりません。既存システムのアップロード処理を点検するなら、まず保存先が public 配下かどうかを見てください。そこが公開ディレクトリなら、他が完璧でも一度すり抜けたら終わりです。設計の土台から見直す価値があります。