想象一下,你正在维护一个老旧的文件管理系统,功能很基础,就是让用户上传图片或者下载文档。有一天,你发现服务器上的 /etc/passwd 文件莫名出现在公开访问目录下,或者黑客直接通过 URL 下载了你数据库的备份文件。这种窒息感,相信不少 PHP 开发者都经历过。
目录遍历漏洞(Directory Traversal),在安全圈里也常被叫作路径穿越(Path Traversal),听起来很高深,但实际上它就是利用了程序对用户输入“过于信任”的致命弱点。今天,我们不讲枯燥的理论,直接切入实战,从最典型的代码漏洞看起,一步步教你如何排查、修复,以及如何从服务器层面彻底封堵。
一、 漏洞是怎么形成的?先看懂这行代码
很多初学者甚至中级开发者,写文件读取功能时,第一反应往往是这样的:
<?php
$file = $_GET['filename'];
echo file_get_contents('/var/www/uploads/' . $file);
?>
乍一看,似乎没什么问题。用户传入 photo.jpg,程序就读取 /var/www/uploads/photo.jpg。但如果你传入的是 ../../etc/passwd 呢?
参数拼接后的路径变成了:
/var/www/uploads/../../etc/passwd
经过路径解析,这实际上指向了系统的敏感文件。这就是典型的目录遍历漏洞。攻击者通过精心构造的 ../ 序列,层层剥离目录层级,最终访问到Web根目录之外的任意文件。
为什么这种错误如此普遍?
在PHP早期版本中,开发者往往缺乏安全意识,或者为了追求开发速度,忽略了输入验证。更糟糕的是,有些框架或者第三方库可能存在类似的默认行为,如果没有进行二次审计,漏洞就会一直潜伏在生产环境中。
二、 常见的攻击载体与实战案例
目录遍历不仅仅出现在 $_GET 参数中,它可能潜伏在任何一个接收用户输入并用于文件系统操作的地方。让我们看看几个真实的实战场景。
场景1:文件名直接拼接
这是最常见的情况,通常出现在图片查看、文件下载等功能中。
漏洞代码示例:
<?php
// 假设用户访问 /download.php?file=config.php
$filename = $_REQUEST['file'];
$path = '/var/www/data/' . $filename;
header('Content-Description: File Transfer');
header('Content-Type: application/octet-stream');
readfile($path);
?>
攻击载荷:
file=../../../../etc/shadowfile=..\..\..\..\etc\passwd(Windows环境下)file=%2e%2e%2f%2e%2e%2fetc%2fpasswd(URL编码绕过)
场景2:基于后缀的文件包含
有些开发者认为,只要限制文件后缀就能防止漏洞,这是一种危险的错觉。
看似安全的代码:
<?php
$filename = $_GET['img'];
if (!preg_match('/\.(jpg|png|gif)$/i', $filename)) {
die('Invalid image type');
}
include('/var/www/uploads/' . $filename . '.jpg');
?>
绕过方式:
如果攻击者传入 img=../../etc/passwd,正则匹配成功(因为.jpg在末尾),但最终拼接的路径变成了:
/var/www/uploads/../../etc/passwd.jpg
如果 /etc/passwd.jpg 存在,或者 PHP 的 open_basedir 配置不当,漏洞依然存在。更危险的是,如果这里是 include 且攻击者能上传恶意PHP文件,这就演变成了远程代码执行(RCE)。
场景3:ZIP/压缩文件解压
在处理用户上传的压缩包并解压时,攻击者可以构造恶意的 ZIP 文件,内部文件路径包含 ../,解压后覆盖服务器上的关键配置文件(如 .htaccess 或 wp-config.php)。
<?php
$zip = new ZipArchive();
$filename = $_POST['upload'];
$zip->open($filename);
$zip->extractTo('/var/www/html/uploads/');
$zip->close();
?>
如果 ZIP 中包含 ../../index.php 这样的条目,解压后就会直接覆盖网站入口文件。
三、 如何从代码审计中发现目录遍历?
作为开发者,我们需要像黑客一样思考。以下是一些高效的审计技巧:
1. 追踪数据流(Data Flow Analysis)
从用户输入点($_GET, $_POST, $_COOKIE, $_SERVER, $_FILES)开始,追踪这些变量如何传递到文件系统函数。
重点关注的函数:
file_get_contents(),fopen(),readfile()include(),require(),include_once(),require_once()file(),file_put_contents()scandir(),opendir(),readdir()ZipArchive,Phar扩展相关函数curl请求(虽然主要是SSRF,但有时也与文件路径相关)
2. 检查“ sanitization”是否充分
很多代码看似做了过滤,实则漏洞百出。
错误的过滤方式:
// 仅替换 ../,容易被编码绕过
$filter = str_replace('../', '', $filename);
// 结果:攻击者传入 ../../etc/passwd -> 替换后仍为 ../etc/passwd
另一种错误:
// 只检查是否以 uploads/ 开头,但可以用空字节或编码绕过
if (strpos($filename, 'uploads/') === 0) { ... }
3. 白名单机制是最佳实践
与其尝试过滤所有可能的恶意输入,不如只允许已知的、安全的值。
<?php
// 使用白名单数组
$allowedFiles = ['about.txt', 'contact.txt', 'privacy.txt'];
$filename = $_GET['file'];
if (in_array($filename, $allowedFiles)) {
readfile('/var/www/html/docs/' . $filename);
} else {
http_response_code(403);
echo 'Access denied';
}
?>
这种方式彻底堵死了任意文件读取的可能性,因为用户只能选择预定义的文件。
四、 深度防御:代码层面的修复策略
如果你不能修改核心逻辑,或者业务确实需要动态文件名,那么必须采用多层防御。
策略1:规范化路径并强制前缀匹配
PHP 提供了 realpath() 函数,可以将路径解析为绝对路径,并解析所有的 . 和 ..。
<?php
$baseDir = '/var/www/uploads';
$userInput = $_GET['file'];
// 拼接完整路径
$fullPath = $baseDir . DIRECTORY_SEPARATOR . $userInput;
// 规范化路径:解析符号链接和相对路径
$resolvedPath = realpath($fullPath);
// 检查解析后的路径是否仍在允许的基目录下
if ($resolvedPath === false || strpos($resolvedPath, $baseDir) !== 0) {
die('Invalid path');
}
// 安全地读取文件
readfile($resolvedPath);
?>
关键点:
realpath()会返回绝对路径,并将所有的..解析掉。strpos($resolvedPath, $baseDir) === 0确保最终路径必须以基目录开头。- 注意:在某些文件系统挂载或符号链接复杂的环境中,
realpath()可能返回false,需要妥善处理。
策略2:使用 basename() 提取文件名
basename() 函数返回路径中的文件名部分,自动去除目录信息。
<?php
$filename = basename($_GET['file']);
// 如果用户传入 ../../etc/passwd,basename 返回 passwd
// 然后我们可以再结合白名单或后缀检查
if (preg_match('/^[a-zA-Z0-9_-]+\.(jpg|png|gif)$/', $filename)) {
readfile('/var/www/uploads/' . $filename);
}
?>
局限性: basename() 只能去除目录部分,不能防止非法字符或符号链接攻击。必须配合其他检查使用。
策略3:禁用危险函数与限制文件扩展名
在 php.ini 中配置:
; 禁用可能被利用的文件系统函数
disable_functions = file_get_contents, readfile, include, require, include_once, require_once
注意: 这会影响所有使用该函数的代码,需谨慎评估。通常更推荐在应用层面修复,而非依赖 ini 配置。
对于文件上传,严格限制 MIME 类型和文件扩展名,并使用随机文件名重命名,避免直接使用用户提供的文件名。
<?php
$extension = pathinfo($_FILES['upload']['name'], PATHINFO_EXTENSION);
$allowedExts = ['jpg', 'jpeg', 'png', 'gif'];
if (!in_array(strtolower($extension), $allowedExts)) {
die('Invalid extension');
}
// 生成随机文件名,避免路径遍历
$newFilename = bin2hex(random_bytes(16)) . '.' . $extension;
move_uploaded_file($_FILES['upload']['tmp_name'], '/var/www/uploads/' . $newFilename);
?>
五、 服务器与安全配置层面的防御
代码修复是根本,但服务器配置可以提供额外的安全层。即使代码存在漏洞,良好的配置也能阻止攻击成功。
1. open_basedir 限制
open_basedir 可以将 PHP 脚本的文件访问限制在指定的目录树内。
在 php.ini 或 .htaccess 中配置:
open_basedir = /var/www/html/uploads:/tmp
这样,即使攻击者试图读取 /etc/passwd,PHP 也会抛出警告并拒绝访问。
优点: 配置简单,效果显著。 缺点: 某些框架或库可能依赖访问其他目录(如会话文件、临时文件),需要仔细调试。且在某些高级攻击场景下(如符号链接),可能被绕过。
2. 文件系统权限最小化
确保 Web 服务器用户(如 www-data 或 nginx)对文件系统只有必要的最小权限。
# 限制上传目录仅可写,不可执行
chown www-data:www-data /var/www/uploads
chmod 750 /var/www/uploads
# 核心配置文件不可被 Web 用户读取
chmod 600 /var/www/config.php
chown root:root /var/www/config.php
如果 Web 用户没有权限读取 /etc/ 或系统敏感文件,即使发生遍历,攻击者也无法获取内容。
3. 禁用不安全的函数与扩展
除了 disable_functions,还应考虑禁用 allow_url_include,防止远程文件包含攻击。
allow_url_include = Off
allow_url_fopen = Off ; 如果不需要远程文件获取
4. 使用 Web 应用防火墙(WAF)
WAF 可以识别并拦截包含 ../、%2e%2e%2f 等特征的请求。虽然 WAF 不是银弹,但可以作为纵深防御的一部分。
例如,ModSecurity 规则示例:
SecRule ARGS "@rx \.\.\/" "id:1001,phase:1,deny,status:403,msg:'Directory Traversal Attempt'"
六、 测试与验证:如何确认漏洞存在?
在合法授权的渗透测试中,可以使用自动化工具或手动测试。
手动测试步骤:
- 识别入口点: 查找所有接收文件路径参数的接口。
- 尝试基本 Payload:
../../etc/passwd..\..\..\..\etc\passwd(Windows)%2e%2e%2f%2e%2e%2fetc%2fpasswd(URL编码)....//....//etc/passwd(绕过简单的../过滤)/etc/passwd%00(空字节注入,适用于 PHP < 5.3.4)
- 观察响应:
- 如果返回文件内容,漏洞存在。
- 如果返回 403/404,但未报错,可能是被 WAF 拦截或应用层过滤。
- 如果返回 PHP 错误(如
Warning: file_get_contents(): Failed to open stream),说明路径未过滤,但目标文件不存在。
使用工具:
- Burp Suite: 手动重放请求,修改参数进行测试。
- Sqlmap: 虽然主要用于 SQL 注入,但其
--dbs等参数在某些情况下也可用于探测。 - Nikto / OWASP ZAP: 自动化扫描工具,可检测常见的目录遍历漏洞。
- 自定义 Python 脚本: 编写脚本批量尝试各种编码和绕过技巧。
七、 总结:安全是一个过程,不是一次性任务
目录遍历漏洞之所以长期存在,是因为它的产生往往源于“方便”而非“恶意”。开发者在快速迭代功能时,容易忽略输入验证的完整性。
要真正防御此类漏洞,需要建立多层次的安全体系:
- 代码层面: 永远不要信任用户输入。使用白名单、
realpath()规范化路径、basename()提取文件名。 - 配置层面: 启用
open_basedir,最小化文件系统权限,禁用不必要的 PHP 函数。 - 监控层面: 部署 WAF,记录文件访问日志,及时发现异常请求。
- 意识层面: 定期对代码进行安全审计,使用静态分析工具(如 PHPStan, Psalm 配合安全插件)检测潜在漏洞。
记住,安全不是一劳永逸的。新的绕过技巧不断涌现,服务器环境也在不断变化。作为开发者,保持对安全的敬畏之心,持续学习,才能在复杂的网络环境中守护好每一行代码。
希望这篇指南能帮助你深入理解 PHP 目录遍历漏洞,并在实际工作中有效防御。如果你的项目中有疑似漏洞的代码,不妨对照检查一下,或许会有意想不到的发现。安全无小事,防患于未然,才是王道。
