PHP下载文件防路径遍历:完整安全指南与最佳实践
目录导读
- 什么是路径遍历攻击?
- PHP下载文件的常见漏洞场景
- 核心防御策略:白名单与路径规范化
- 实战代码示例:安全的文件下载函数
- 进阶防护:权限控制与文件流访问
- 常见问答:开发者最关心的6个问题
- 构建多层防御体系
什么是路径遍历攻击?
路径遍历(Path Traversal),又称目录穿越攻击,是一种利用程序对用户输入路径验证不严,通过或等特殊字符,突破限制访问非授权目录的攻击方式。

用户的请求是download.php?file=../etc/passwd,如果程序直接拼接路径,攻击者就能读取服务器敏感文件,这类攻击在2023年OWASP Top 10中仍位列“安全配置错误”类别的高危风险。
PHP下载文件的常见漏洞场景
// 危险代码示例
$file = $_GET['file'];
header('Content-Disposition: attachment; filename="'.basename($file).'"');
readfile('/var/www/uploads/'.$file);
上述代码存在两个问题:
- 直接信任用户输入的
$file参数。 - 未过滤或绝对路径(如
/etc/passwd)。
攻击者可构造: 将允许下载的文件映射为内部ID,用户只能传递ID而非文件名: 优点:用户无法接触实际路径,完全阻断遍历。 如果必须传递文件名,应使用 注意: 只允许字母、数字、下划线、连字符和点。 以下是一个生产环境可用的安全下载函数: 关键防御点: 用户请求时验证token的有效性和过期时间,即使攻击者猜到token也无法遍历系统文件。 相比 并限制文件大小以防消耗资源: Q1:使用 Q2:如何防御符号链接攻击? Q3:为什么不能直接使用用户输入构建路径? Q4:公司为了用户体验需要动态文件名怎么处理? Q5:nginx/apache层面如何辅助防护? Q6:大文件下载时怎么防止内存溢出? 防路径遍历的核心原则是永远不信任用户输入,建议采用以下多层策略: 通过组合上述技术,即使攻击者尝试构造复杂payload(如 参考资料:PHP官方手册 readfile 函数、OWASP Path Traversal 防护指南、常见CMS安全基线标准
download.php?file=../../../../etc/passwd
从而读取/etc/passwd
核心防御策略:白名单与路径规范化
1 严格白名单验证(最安全)
$allowedFiles = [
'1' => 'report_2024.pdf',
'2' => 'user_guide_v3.pdf',
'3' => 'logo.png'
];
$id = $_GET['id'];
if (!isset($allowedFiles[$id])) {
die('Invalid file ID.');
}
$filePath = '/secure/downloads/' . $allowedFiles[$id];
2 路径规范化与目录限制
realpath()结合basename():$baseDir = '/var/www/downloads/';
$inputFile = basename($_GET['file']); // 去除路径字符
$fullPath = realpath($baseDir . $inputFile);
// 关键检查:实际路径必须在允许的基目录下
if ($fullPath === false || strpos($fullPath, $baseDir) !== 0) {
die('Invalid file path.');
}
basename()和realpath()必须组合使用,单纯使用basename()仍可能被../../file绕过。3 结合正则过滤
if (preg_match('/^[a-zA-Z0-9_\-\.]+$/', $_GET['file']) !== 1) {
die('Invalid filename format.');
}
实战代码示例:安全的文件下载函数
<?php
function secureDownload($userInput, $baseDir = '/var/www/secure_downloads/') {
// 1. 移除路径字符
$filename = basename($userInput);
// 2. 构建完整路径
$fullPath = realpath($baseDir . $filename);
// 3. 多重检查
if ($fullPath === false) {
http_response_code(404);
exit('File not found.');
}
// 4. 确保路径在基目录下(防止符号链接攻击)
$baseDirReal = realpath($baseDir);
if (strpos($fullPath, $baseDirReal) !== 0) {
http_response_code(403);
exit('Access denied.');
}
// 5. 检查文件是否存在且可读
if (!is_file($fullPath) || !is_readable($fullPath)) {
http_response_code(404);
exit('File unavailable.');
}
// 6. 安全地输出文件
header('Content-Description: File Transfer');
header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename="'.$filename.'"');
header('Content-Length: ' . filesize($fullPath));
header('Cache-Control: private, no-store');
// 使用readfile必须确保输出缓冲已清除
ob_clean();
flush();
readfile($fullPath);
exit;
}
// 使用示例
if (isset($_GET['file'])) {
secureDownload($_GET['file']);
}
basename()去除路径。realpath()解析为绝对路径。进阶防护:权限控制与文件流访问
1 数据库存储映射表
CREATE TABLE downloads (
id INT AUTO_INCREMENT PRIMARY KEY,
token VARCHAR(64) UNIQUE,
filepath VARCHAR(255) NOT NULL,
max_downloads INT DEFAULT 1,
expires_at TIMESTAMP
);
2 使用PHP流读取文件
$stream = fopen($safePath, 'rb');
fpassthru($stream);
fclose($stream);
readfile(),流读取在大文件下载时内存占用更低。3 限制文件类型与大小
$allowedExt = ['pdf', 'docx', 'png', 'jpg'];
$ext = strtolower(pathinfo($fullPath, PATHINFO_EXTENSION));
if (!in_array($ext, $allowedExt)) {
die('File type not allowed.');
}
if (filesize($fullPath) > 100 * 1024 * 1024) { // 100MB
die('File too large.');
}
常见问答:开发者最关心的6个问题
basename()就足够安全吗?
A:不够。basename()会去除路径,但攻击者可利用URL编码或绝对路径(如/etc/passwd)绕过,必须结合realpath()和前缀检查。
A:基目录使用realpath()解析,对比实际路径是否以基目录开头,符号链接即使指向外部,也会被前缀检查拦截。
A:用户输入可能包含、空字节(如%00)或特殊字符,在旧版PHP中,空字节可截断字符串导致路径逃逸,应当始终假定用户输入是恶意。
A:使用白名单+内部ID映射,例如文件ID→真实路径,用户只能传递ID,或使用签名的下载Token由服务端生成。
A:配置alias或root指令限制静态目录,配合rewrite规则禁止直接访问敏感目录,但PHP层的验证不可省略。
A:使用fopen+fpassthru流式输出,或分块读取,避免使用file_get_contents一次性加载到内存。构建多层防御体系
basename() + realpath() + 前缀验证。Content-Disposition和缓存控制。..././..././.../etc),也能被多层过滤拦截,始终将安全作为功能开发的核心部分,而非事后补丁。