说实话,每次我审计PHP代码时,目录遍历(Directory Traversal)都是我最先关注的点之一。这玩意儿看起来简单,但一旦中招,后果往往比想象中严重得多。今天咱们不整那些虚头巴脑的理论,直接上干货,聊聊PHP里到底有哪些遍历方法,哪些写法最容易踩坑,以及怎么把坑填上。
常见的PHP目录遍历方法
先说说PHP里常用的几种读取目录的方式,毕竟你得知道”敌人”有哪些招式,才能防得住。
1. scandir() - 最常用也最危险
这个函数返回指定目录中所有文件和目录的数组,简单粗暴。
<?php
// 危险写法:直接拼接用户输入
$dir = $_GET['path'];
$files = scandir($dir);
foreach ($files as $file) {
echo "<a href='{$file}'>{$file}</a><br>";
}
?>
这段代码看着没啥问题,对吧?但如果攻击者传入 ../../etc/passwd,你懂的。
2. opendir() + readdir() - 经典组合
这种方式更底层,适合需要精细控制的场景。
<?php
// 同样危险
$path = $_GET['folder'];
$handle = opendir($path);
while (($file = readdir($handle)) !== false) {
echo $file . "<br>";
}
closedir($handle);
?>
3. glob() - 模式匹配神器
glob可以按通配符查找文件,功能强大,但同样存在风险。
<?php
// 用户决定搜索路径
$searchPath = $_GET['base'] . '/*.jpg';
$images = glob($searchPath);
print_r($images);
?>
4. 文件系统函数族
还有 file(), fopen(), readfile() 等,这些函数在传入可遍历路径时也会暴露同样的问题。
真实漏洞案例剖析
光说理论没用,我来讲几个实际发生过的案例,都是血淋淋的教训。
案例一:某CMS后台文件管理漏洞
2023年某知名开源CMS爆出严重漏洞,后台文件管理器直接暴露:
<?php
// 管理员文件管理页面
$action = $_GET['action'] ?? 'list';
$dir = $_GET['dir'] ?? '/www/wwwroot/default';
// 只做了简单的白名单校验,但存在绕过
$allowedDirs = ['/www/wwwroot/default', '/www/wwwroot/backup'];
if (!in_array($dir, $allowedDirs)) {
die('非法访问');
}
// 问题出在这里:in_array是精确匹配,但路径可以变形
// 比如传入 /www/wwwroot/default/../etc
if ($action === 'list') {
$files = scandir($dir);
// ... 输出文件列表
}
?>
攻击者只需要传入:
http://target/admin/filemanager.php?action=list&dir=/www/wwwroot/default/../etc
路径经过解析后变成 /www/wwwroot/etc,成功越狱到系统目录。这个案例告诉我们:不要信任任何用户输入的路径,即使你做了白名单,也要对路径进行标准化处理。
案例二:PHP版本特性被利用
有些老版本PHP(5.3及以下)存在一个特性:null字节注入。
<?php
// 老代码常见写法
$filename = $_GET['file'] . '.txt';
$content = file_get_contents($filename);
echo $content;
?>
攻击者传入:
file=../../etc/passwd%00
在PHP 5.3以下,%00会被当作字符串结束符,实际请求的文件变成 ../../etc/passwd,绕过 .txt 的后缀限制。虽然现代PHP已经修复了这个bug,但在一些老旧系统上依然能见到。
案例三:基于框架的路径穿越
很多开发者以为用了框架就安全了,其实不然。看下面这个例子:
<?php
// 使用Laravel风格的实现
public function download(Request $request)
{
$filename = $request->input('name');
$path = storage_path('app/uploads/' . $filename);
// 开发者以为有basename过滤就安全了
$safeName = basename($filename);
$path = storage_path('app/uploads/' . $safeName);
return response()->download($path);
}
?>
问题在于,即使使用了 basename(),如果攻击者传入:
name=../../../etc/passwd
basename() 返回 passwd,最终路径变成:
storage/app/uploads/passwd
这看似安全,但如果 storage_path() 本身没有做绝对路径限制,或者应用有其他逻辑缺陷,依然可能被利用。更危险的是,有些框架的 storage_path() 实现可能存在路径解析差异。
防御方案:从入门到精通
聊完了漏洞,咱们说说怎么防。这里我按防御强度分几个层次来讲。
第一层:输入验证(基础但必要)
<?php
/**
* 验证路径是否合法
*/
function isValidPath(string $path): bool
{
// 去除开头的斜杠,防止绝对路径
$path = ltrim($path, '/');
// 检查是否包含路径穿越序列
if (strpos($path, '..') !== false) {
return false;
}
// 只允许字母、数字、下划线、连字符和斜杠
if (!preg_match('/^[a-zA-Z0-9_\-\/]+$/', $path)) {
return false;
}
return true;
}
// 使用示例
$dir = $_GET['dir'] ?? '';
if (!isValidPath($dir)) {
http_response_code(400);
die('非法的路径参数');
}
?>
这个方法简单有效,但有个问题:黑名单机制容易被绕过。比如UTF-8编码的路径、URL编码、空字节等都可能让正则失效。
第二层:路径标准化(推荐)
这是更稳妥的做法,核心思想是:把路径解析成绝对路径,然后检查是否在允许的目录内。
<?php
/**
* 安全地解析并验证目录路径
*
* @param string $userPath 用户输入的路径
* @param string $baseDir 允许的基准目录
* @return string 验证后的安全路径
* @throws Exception 当路径非法时抛出异常
*/
function getSafePath(string $userPath, string $baseDir): string
{
// 获取基准目录的绝对路径
$realBaseDir = realpath($baseDir);
if ($realBaseDir === false) {
throw new Exception('基准目录不存在');
}
// 拼接用户路径
$fullPath = $realBaseDir . DIRECTORY_SEPARATOR . $userPath;
// 规范化路径(解析所有 . 和 ..)
$normalizedPath = realpath(dirname($fullPath) . '/' . basename($fullPath));
// 关键检查:规范化后的路径必须在基准目录内
if (strpos($normalizedPath, $realBaseDir . DIRECTORY_SEPARATOR) !== 0
&& $normalizedPath !== $realBaseDir) {
throw new Exception('路径越界访问');
}
return $normalizedPath;
}
// 使用示例
try {
$baseDir = '/www/wwwroot/myapp/uploads';
$userPath = $_GET['file'] ?? '';
$safePath = getSafePath($userPath, $baseDir);
// 现在可以安全地操作文件了
$content = file_get_contents($safePath);
echo $content;
} catch (Exception $e) {
http_response_code(403);
die('访问被拒绝: ' . $e->getMessage());
}
?>
这个方法之所以可靠,是因为它利用了操作系统层面的路径解析。无论攻击者怎么编码、怎么嵌套 ..,realpath() 都会还原油径的真实位置,然后我们只需要检查最终位置是否在白名单内。
第三层:使用文件系统函数族的安全封装
如果你需要在代码中频繁操作文件,建议封装一个工具类:
<?php
/**
* 安全的文件系统操作类
*/
class SafeFilesystem
{
private string $allowedBaseDir;
public function __construct(string $allowedBaseDir)
{
$this->allowedBaseDir = realpath($allowedBaseDir);
if ($this->allowedBaseDir === false) {
throw new InvalidArgumentException("基准目录不存在: {$allowedBaseDir}");
}
}
/**
* 列出目录内容
*/
public function listDirectory(string $subPath): array
{
$safePath = $this->resolvePath($subPath);
if (!is_dir($safePath)) {
throw new InvalidArgumentException("目录不存在或无法访问");
}
return scandir($safePath);
}
/**
* 读取文件内容
*/
public function readFile(string $subPath): string
{
$safePath = $this->resolvePath($subPath);
if (!is_file($safePath)) {
throw new InvalidArgumentException("文件不存在");
}
return file_get_contents($safePath);
}
/**
* 下载文件
*/
public function download(string $subPath): void
{
$safePath = $this->resolvePath($subPath);
if (!is_file($safePath)) {
throw new InvalidArgumentException("文件不存在");
}
header('Content-Description: File Transfer');
header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename="' . basename($safePath) . '"');
header('Content-Length: ' . filesize($safePath));
readfile($safePath);
exit;
}
/**
* 核心方法:解析并验证路径
*/
private function resolvePath(string $subPath): string
{
// 空路径直接返回基准目录
if (empty($subPath)) {
return $this->allowedBaseDir;
}
// 拼接并解析
$fullPath = $this->allowedBaseDir . DIRECTORY_SEPARATOR . $subPath;
$resolvedPath = realpath($fullPath);
// 验证解析后的路径是否在允许范围内
if ($resolvedPath === false) {
throw new InvalidArgumentException("路径不存在");
}
// 关键安全检查
if (strpos($resolvedPath, $this->allowedBaseDir . DIRECTORY_SEPARATOR) !== 0
&& $resolvedPath !== $this->allowedBaseDir) {
throw new InvalidArgumentException("禁止访问上级目录");
}
return $resolvedPath;
}
}
// 使用示例
$fs = new SafeFilesystem('/www/wwwroot/myapp/uploads');
try {
// 安全地列出目录
$files = $fs->listDirectory('2024/01');
print_r($files);
// 安全地读取文件
$content = $fs->readFile('2024/01/image.jpg');
// 安全地下载文件
$fs->download('2024/01/document.pdf');
} catch (Exception $e) {
http_response_code(403);
echo '错误: ' . $e->getMessage();
}
?>
这个类有几个设计亮点:
- 基准目录在构造时固定,之后所有操作都基于这个基准
- 每次路径解析都调用
realpath(),确保路径的真实位置 - 白名单检查,只允许在基准目录内的操作
- 统一的异常处理,调用方可以统一捕获错误
第四层:应用层额外防护
除了代码层面的防御,还有一些运维和配置层面的建议:
<?php
/**
* 额外的安全增强措施
*/
// 1. 使用open_basedir限制PHP的文件访问范围
// 在php.ini中配置:
// open_basedir = /www/wwwroot/myapp/:/tmp/
// 2. 禁用危险函数(在php.ini中)
// disable_functions = scandir, readdir, opendir, glob, file, file_get_contents
// 3. 使用路径哈希代替实际路径
// 比如用文件ID而不是文件名
class PathHashingExample
{
private array $pathMap = [];
public function registerFile(string $realPath, string $hash): void
{
$this->pathMap[$hash] = realpath($realPath);
}
public function getPath(string $hash): string
{
if (!isset($this->pathMap[$hash])) {
throw new InvalidArgumentException("无效的文件标识");
}
return $this->pathMap[$hash];
}
}
// 4. 日志记录和监控
function logAccessAttempt(string $userPath, string $resolvedPath, bool $allowed): void
{
$logEntry = sprintf(
"[%s] user_path=%s resolved_path=%s allowed=%s",
date('Y-m-d H:i:s'),
$userPath,
$resolvedPath,
$allowed ? 'true' : 'false'
);
error_log($logEntry . PHP_EOL, 3, '/var/log/php-traversal.log');
}
?>
常见误区提醒
在实际开发中,我发现很多开发者容易踩这些坑:
误区一:”我用了basename()就安全了”
<?php
// 错误示范
$path = '/uploads/' . basename($_GET['file']);
?>
basename() 只能提取文件名,但如果基础路径可控,依然可以遍历。
误区二:”我只允许特定后缀”
<?php
// 错误示范
if (!pathinfo($_GET['file'], PATHINFO_EXTENSION) === 'jpg') {
die('只允许图片');
}
?>
攻击者可以传入 ../../etc/passwd.jpg,虽然文件不存在,但如果配合其他漏洞(如包含漏洞),依然危险。
误区三:”服务器配置了目录权限就万无一失”
<?php
// 错误示范 - 依赖服务器配置
// 如果PHP进程有读取权限,代码层面的漏洞依然致命
?>
代码层面的验证和服务器配置是互补的,不能互相替代。
测试你的代码是否安全
最后,给你一套简单的测试方法,你可以用这些payload测试自己的代码:
测试用例:
1. ../../etc/passwd
2. ..\..\windows\system32\config\sam
3. /etc/passwd
4. %2e%2e%2f%2e%2e%2fetc%2fpasswd(URL编码)
5. ..%00/(空字节注入,老版本PHP)
6. .%2e/%2e./etc/passwd(混合编码)
7. /www/wwwroot/myapp/../../etc/passwd(绝对路径+穿越)
预期结果:
- 所有测试都应该返回403错误或错误提示
- 不应该泄露任何系统信息
- 日志中应该记录这些尝试
总结
目录遍历漏洞看起来简单,但实际攻击中往往能结合其他漏洞发挥巨大威力。我的建议是:
- 永远不要信任用户输入的路径
- 使用
realpath()进行路径标准化和验证 - 建立白名单机制,只允许访问特定目录
- 记录所有路径访问日志,便于审计
- 定期使用安全扫描工具检查代码
记住,安全不是靠一条规则就能解决的,而是需要多层防御。代码层面的验证、服务器配置、监控日志,三者缺一不可。希望今天的分享能帮到你,如果有具体的代码需要审计,随时找我聊聊。
