realpath()是唯一可靠的路径规范化手段,必须先拼接白名单根目录再调用,并严格检查返回值是否为false及是否以安全根目录开头。

ThinkPHP 不会自动过滤 ../,所有路径类参数(如 filename、path)都必须手动校验——靠 str_replace() 或 basename() 单独使用,100% 被绕过。
realpath() 是唯一可靠路径规范化手段
realpath() 会真正解析 ../、./、多重斜杠、符号链接、URL 编码(如 %2e%2e%2f),并返回操作系统层面的真实绝对路径。它不是“美化字符串”,而是让 PHP 内核做一次路径归一化计算。
常见错误是只调用 realpath($userInput) 就直接拼进 file_get_contents()——这完全没用,因为用户输入可能带任意前缀,比如 ../../etc/passwd 拼到 /var/www/html/ 后才调 realpath(),结果就是真实路径跑出根目录。
- 必须先拼接:把用户输入拼到你控制的白名单根目录下,再调
realpath() - 必须严格检查返回值:
if (false === $real) { ... },不能写成if (!$real)(空字符串也会被当 false) - 必须比对前缀:
strpos($real, $safeRoot) === 0,且$safeRoot必须以/结尾(否则/var/www/uploads2/evil.txt可能被误判)
ThinkPHP 中正确校验上传/下载路径的写法
以 ThinkPHP 6.x 为例,假设允许访问 public/uploads/ 下的文件:
立即学习“PHP免费学习笔记(深入)”;
$uploadRoot = realpath(app()->getRootPath() . 'public/uploads');
if (false === $uploadRoot) {
throw new HttpException(500, 'Upload root not accessible');
}
// 注意:必须先 basename() 截断,再拼接,防止空字节或编码绕过影响 realpath()
$raw = input('get.filename');
$safeName = basename(urldecode($raw));
$fullPath = realpath($uploadRoot . '/' . $safeName);
if (false === $fullPath || 0 !== strpos($fullPath, $uploadRoot)) {
throw new HttpException(403, 'Invalid file path');
}
if (!is_file($fullPath) || !is_readable($fullPath)) {
throw new HttpException(404, 'File not found or unreadable');
}
return response()->download($fullPath);
关键点:
-
urldecode()必须在basename()之后立即调用,否则%2e%2e%2f这类编码会被basename()当作普通字符保留 - 不要跳过
is_file()和is_readable()——realpath()成功只说明路径合法,不保证文件存在或可读 - ThinkPHP 的
request::param()和input()完全等价,都不做路径清洗,别指望框架帮你兜底
为什么 str_replace(['..', '.'], '', $path) 是危险的
这种写法在实战中等于没防护。攻击者可以轻松绕过:
-
.//→str_replace()不匹配,realpath()仍会解析为上级目录 -
%2e%2e%2f(即../的 URL 编码)→str_replace()完全不处理编码 -
....//→ 替换后变成..//,依然能穿越 -
..\..\(Windows 风格)→str_replace()默认只匹配 Unix 斜杠
更糟的是,这类过滤会干扰正常业务:用户传 my..report.pdf,结果被删成 myreport.pdf,文件名被意外篡改。
真正的防线只有一条:让系统自己算出真实路径,再确认它是否落在你授权的物理边界内。
大文件下载时 realpath() 的坑要额外注意
ThinkPHP 的 response()->download() 底层可能调用 readfile(),而 readfile() 对路径不做任何校验。如果你传入的是未经 realpath() + 前缀校验的变量,攻击者就能通过 ?filename=../../../etc/shadow 直接触发下载。
TP6.1+ 推荐改用 Response::stream() 分块输出,但路径校验逻辑不变:
- 分块函数里打开文件前,仍需走一遍
realpath()+strpos()校验 - 不要用相对路径(如
./uploads/xxx)传给fopen(),CLI 环境下工作目录可能不是项目根目录 - Windows 下若路径含中文,
realpath()可能失败,需提前用mb_convert_encoding()转码
最易被忽略的一点:校验必须在每次请求入口就完成,而不是“等框架走到 download 方法里再判断”。路径一旦拼错,后续所有操作都在越权上下文中执行。



















