别用多级目录动态拼接,改用白名单映射或ID查表;因用户输入路径易被注入../、编码绕过等,basename()和realpath()均无法全覆盖防护,而白名单和ID查表从源头隔离输入,确保路径决策脱离用户控制。

直接结论:别用多级目录动态拼接,改用白名单映射或 ID 查表。任何带用户输入的路径拼接,哪怕加了 basename() 或 realpath(),只要没做根目录前缀强校验,都算“不稳”。
为什么多级目录拼接天然不稳
开发者常写的这类代码:
$page = $_GET['page']; // 如 'admin/user/list'
include('pages/' . $page . '.php');
看着只是多了一层目录,但问题全在 $page 上——它可能被注入 ../../etc/passwd、%2e%2e%2f%2e%2e%2fetc%2fpasswd(URL 编码)、甚至 ..%00/etc/passwd(空字节截断)。PHP 的 include 会先解析路径再执行,而不同 PHP 版本、Web 服务器(Apache/Nginx)、编码处理方式对这些变体的响应不一致,单靠一层过滤很难全覆盖。
-
basename()只能防../,挡不住.../、....//或编码绕过 -
realpath()在路径不存在时返回false,但若攻击者构造一个存在的非法路径(如软链接指向/etc),它反而会成功解析 - 用
$_SERVER['DOCUMENT_ROOT']拼接看似绝对,但若没校验结果是否仍在白名单根目录内,仍可能越权
白名单映射:最简且最可靠的做法
把“路径字符串”彻底转成“受控键名”,从源头切断路径操控可能。
立即学习“PHP免费学习笔记(深入)”;
- 定义一个严格白名单数组:
$pages = ['dashboard' => '/pages/admin/dashboard.php', 'user_list' => '/pages/admin/user/list.php']; - 只接收用户传来的键名:
$key = $_GET['page'] ?? ''; - 用
isset($pages[$key])判断,不匹配就die()或 404 - 包含时直接使用数组值:
include(__DIR__ . $pages[$key]);(__DIR__确保绝对路径)
这个方案不依赖任何路径函数过滤,也不怕编码、空字节、符号链接——因为根本没让原始输入参与路径构建。
ID 查表替代路径传递:适合文件/模板数量大的场景
当页面模板超过几十个,白名单数组维护成本高,就该用数据库或配置文件查 ID。
- 数据库建表:
id | slug | full_path | is_active,其中full_path是写死的绝对路径(如/var/www/app/templates/report_v2.php) - 接收参数:
$id = (int)$_GET['t'];(强制转整型,杜绝字符串注入) - 查库:
SELECT full_path FROM templates WHERE id = ? AND is_active = 1(必须带is_active校验) - 查到后,用
is_file()+is_readable()再确认一次文件存在且可读
关键点在于:用户永远看不到真实路径,也猜不到下一个 ID 对应什么内容,连暴力枚举都无效。
如果真要拼多级目录,请用 realpath() + 前缀双重锁死
仅限遗留系统无法重构时的兜底方案,必须同时满足以下全部条件:
- 预设白名单根目录:
$root = '/var/www/app/pages/';(末尾带斜杠) - 拼接后立即调用
$real = realpath($root . $_GET['page']); - 检查
$real === false,是则拒绝 - 用
str_starts_with($real, $root)(PHP 8.0+)或strpos($real, $root) === 0(PHP 7.x)验证前缀 - 最后才
include($real);
漏掉任意一步,比如没检查 realpath() 返回值,或用 === false 写成 == false,漏洞就回来了。这种写法容错率极低,不是“稳”,只是“比裸拼好一点”。
真正稳的核心,是让路径决策完全脱离用户输入。白名单和 ID 查表不是“更麻烦的写法”,而是把风险从运行时前置到了开发时——错误在写代码时就被发现,而不是上线后被扫描器打穿。



















