PHP目录遍历代码实操与目录遍历漏洞安全防御全解析
目录遍历这个坑,真的坑了不少人。今天咱们就来彻底搞明白它。
啥是目录遍历?先搞懂这个概念
你想想,如果用户传个参数进来,你的代码直接拿去拼路径,读文件、列目录,那不就等于把服务器的钥匙交给人家了吗?
// 这就是典型的漏洞代码,看着就扎心
$file = $_GET['filename'];
include($file);
用户传个 ../config/database.php,好家伙,你的数据库配置直接给扒出来了。
目录遍历的核心逻辑就一句话: 攻击者通过构造特殊的路径字符(比如 ../),绕开正常路径,访问到本不该让普通用户碰的文件或目录。
实操演示:漏洞长啥样
我写个最简单的例子,让你直观感受这个问题有多严重。
<?php
/**
* 危险示例:目录遍历漏洞
* 这个代码在实际项目中真的见过不少
*/
// 直接从GET参数取文件名,没有任何过滤
$filename = $_GET['file'];
// 拼接到路径上
$filepath = '/var/www/html/uploads/' . $filename;
// 直接读取文件内容
echo file_get_contents($filepath);
// 或者更危险的,直接include
include($filepath);
?>
攻击者怎么利用?
假设你的目录结构是这样的:
/var/www/html/
├── index.php
├── uploads/
│ └── document.pdf
└── config/
└── db.php (数据库配置,敏感信息!)
正常用户请求:
http://yoursite.com/file.php?file=uploads/document.pdf
这个没问题,显示文档内容。
攻击者请求:
http://yoursite.com/file.php?file=../config/db.php
这时候 $filepath 就变成了:
/var/www/html/uploads/../config/db.php
路径解析后就是 /var/www/html/config/db.php,数据库的账号密码直接输出到页面上。
更狠的:读系统文件
http://yoursite.com/file.php?file=../../../../etc/passwd
或者直接读你的源码:
http://yoursite.com/file.php?file=../../../../var/www/html/index.php
Linux服务器上,/etc/passwd 和 /etc/shadow 是经典目标。Windows上可能是 C:\Windows\System32\config\SAM。
PHP中常见的目录遍历场景
场景一:文件包含(最经典)
<?php
// 根据参数包含不同页面
$page = $_GET['page'];
include($page . '.php');
?>
攻击者传 page=../etc/passwd%00(配合空字节截断,PHP 5.3.4以下版本有效),或者直接传 page=php://filter/convert.base64-encode/resource=config。
场景二:文件上传后的路径处理
<?php
$uploadDir = '/var/www/uploads/';
$file = $_FILES['uploaded']['name'];
$targetPath = $uploadDir . $file;
move_uploaded_file($_FILES['uploaded']['tmp_name'], $targetPath);
?>
如果用户上传的文件名是 ../../shell.php,文件就上传到了 /var/www/shell.php,直接拿到webshell。
场景三:日志文件包含
<?php
$logFile = $_GET['log'];
$logPath = '/var/log/' . $logFile;
highlight_file($logPath);
?>
攻击者传 log=../../etc/nginx/access.log,甚至通过注入恶意内容到日志中,实现远程代码执行。
场景四:目录列表功能
<?php
$dir = isset($_GET['dir']) ? $_GET['dir'] : '.';
$files = scandir($dir);
print_r($files);
?>
这个更直接,用户想查哪个目录就查哪个目录。
防御方案:从原理到代码全覆盖
防御一:路径白名单校验
这是最根本的思路——只允许访问预先定义好的路径。
<?php
/**
* 白名单方案:最安全的做法
*/
// 定义允许访问的目录
$allowedDirs = [
'docs' => '/var/www/html/documentation',
'images' => '/var/www/html/public/images',
'logs' => '/var/www/html/logs',
];
// 获取用户输入
$userInput = $_GET['dir'] ?? 'docs';
// 检查是否在白名单中
if (!array_key_exists($userInput, $allowedDirs)) {
http_response_code(400);
die('无效的目录参数');
}
// 使用白名单中的真实路径
$realPath = $allowedDirs[$userInput];
// 再次确认路径存在且是目录
if (!is_dir($realPath)) {
die('目录不存在');
}
// 列出目录内容
$files = scandir($realPath);
foreach ($files as $file) {
if ($file !== '.' && $file !== '..') {
echo htmlspecialchars($file) . "<br>\n";
}
}
?>
防御二:路径标准化 + 边界检查
<?php
/**
* 路径标准化 + 边界检查方案
* 先把路径规范化,再检查是否在允许的范围内
*/
function isSafePath(string $userPath, string $allowedBaseDir): bool
{
// 获取物理路径的绝对路径(解析所有符号链接和相对路径)
$realUserPath = realpath($allowedBaseDir . DIRECTORY_SEPARATOR . $userPath);
$realBaseDir = realpath($allowedBaseDir);
// realpath 在路径不存在时会返回 false
if ($realUserPath === false || $realBaseDir === false) {
return false;
}
// 检查规范化后的路径是否以允许的目录开头
return str_starts_with($realUserPath, $realBaseDir . DIRECTORY_SEPARATOR)
|| $realUserPath === $realBaseDir;
}
// 使用示例
$allowedBase = '/var/www/html/uploads';
$filename = $_GET['file'] ?? '';
if (!isSafePath($filename, $allowedBase)) {
http_response_code(403);
die('非法的路径访问');
}
$fullPath = $allowedBase . DIRECTORY_SEPARATOR . basename($filename);
echo "安全文件: " . htmlspecialchars($fullPath);
?>
防御三:basename 过滤
<?php
/**
* 只取文件名部分,剥离路径
*/
$filename = $_GET['file'] ?? '';
// basename 会返回路径中的最后一个部分
// "/etc/passwd" -> "passwd"
// "uploads/document.pdf" -> "document.pdf"
$safeFilename = basename($filename);
// 再检查文件名是否包含非法字符
if (!preg_match('/^[a-zA-Z0-9_\-\.]+$/', $safeFilename)) {
die('文件名包含非法字符');
}
$allowedPath = '/var/www/uploads/' . $safeFilename;
// 最终还要检查文件是否存在
if (!file_exists($allowedPath)) {
die('文件不存在');
}
readfile($allowedPath);
?>
防御四:黑名单 + 正则校验
<?php
/**
* 多重校验方案:综合防御
*/
function validateFilePath(string $input): array
{
$errors = [];
// 1. 黑名单:禁止的路径特征
$blacklistedPatterns = [
'/\.\./', // 目录穿越
'/etc/', // 系统目录
'/proc/', // Linux进程信息
'/sys/', // 系统信息
'/var/log/', // 日志目录
'php://', // PHP流封装
'data://', // 数据流
'expect://', // 期望流
'zip://', // ZIP流
'phar://', // PHAR流
'ogg://', // 音频流
'resource://', // 资源流
];
foreach ($blacklistedPatterns as $pattern) {
if (preg_match($pattern, $input)) {
$errors[] = "检测到危险路径模式: $pattern";
}
}
// 2. 只允许字母、数字、下划线、横线、点
if (!preg_match('/^[a-zA-Z0-9_\-\.]+$/', $input)) {
$errors[] = "文件名包含非法字符";
}
// 3. 限制文件名长度
if (strlen($input) > 100) {
$errors[] = "文件名过长";
}
// 4. 不允许点号开头(隐藏文件)
if (str_starts_with($input, '.')) {
$errors[] = "不允许访问隐藏文件";
}
return $errors;
}
// 使用
$input = $_GET['file'] ?? '';
$errors = validateFilePath($input);
if (!empty($errors)) {
http_response_code(400);
foreach ($errors as $error) {
error_log("路径校验失败: " . $error . " | 原始输入: " . $input);
}
die('文件路径不合法');
}
// 构造安全路径
$safePath = __DIR__ . '/uploads/' . $input;
// 最终安全检查
if (!realpath($safePath) || !str_starts_with(realpath($safePath), realpath(__DIR__ . '/uploads/'))) {
die('路径越界访问');
}
header('Content-Type: application/octet-stream');
readfile($safePath);
?>
防御五:使用哈希值代替文件名
<?php
/**
* 彻底绕过目录遍历:用哈希值做文件名
* 上传时把原文件名存数据库,访问时通过ID查询
*/
// 上传时
$originalName = $_FILES['upload']['name'];
$hash = md5($originalName . time() . mt_rand());
// 或者用更安全的哈希
$hash = bin2hex(random_bytes(32));
$uploadPath = '/var/www/uploads/' . $hash;
move_uploaded_file($_FILES['upload']['tmp_name'], $uploadPath);
// 存数据库
$stmt = $pdo->prepare("INSERT INTO files (hash, original_name, size) VALUES (?, ?, ?)");
$stmt->execute([$hash, $originalName, $_FILES['upload']['size']]);
$fileId = $pdo->lastInsertId();
// 下载时:用户只能看到ID,拿不到文件名
// http://yoursite.com/download.php?id=123
$fileId = (int)$_GET['id'];
$stmt = $pdo->prepare("SELECT hash, original_name FROM files WHERE id = ?");
$stmt->execute([$fileId]);
$file = $stmt->fetch();
if (!$file) {
die('文件不存在');
}
// 绝对不信任用户输入的路径
$filePath = '/var/www/uploads/' . $file['hash'];
if (!file_exists($filePath)) {
die('文件已删除');
}
header('Content-Disposition: attachment; filename="' . htmlspecialchars($file['original_name']) . '"');
readfile($filePath);
?>
更高级的防御:Web服务器层面
代码防御总有漏网之鱼,Web服务器配置是第二道防线。
Nginx配置
# 禁止访问隐藏文件和敏感目录
location ~* /\. {
deny all;
access_log off;
log_not_found off;
}
# 禁止访问系统目录
location ~* ^/(etc|proc|sys|var)/ {
deny all;
return 404;
}
# 对文件下载接口加强限制
location /download/ {
# 只允许特定后缀
valid_referers none blocked server_names *.yoursite.com;
if ($invalid_referer) {
return 403;
}
# 限制文件大小
client_max_body_size 10m;
# 只允许文件以特定字符开头
# 这配合上面的哈希方案使用
rewrite ^/download/([a-f0-9]{64})$ /uploads/$1 last;
}
# 禁止访问PHP文件中的敏感操作
location ~* ^/(uploads|images)/.*\.php$ {
deny all;
}
Apache配置
# .htaccess 中的防御规则
# 禁止访问隐藏文件
<FilesMatch "^\.">
Require all denied
</FilesMatch>
# 禁止访问特定目录
<Directory "/var/www/html/uploads">
# 只允许特定文件类型
<FilesMatch "\.(?i:pdf|jpg|jpeg|png|gif|doc|docx|txt)$">
Require all granted
</FilesMatch>
<FilesMatch ">
Require all denied
</FilesMatch>
# 禁止执行PHP
<FilesMatch "\.ph(p[3457]?|t|tml|ps)$">
Require all denied
</FilesMatch>
</Directory>
# 限制路径长度,防止超长路径攻击
LimitRequestLine 4096
LimitRequestFields 20
LimitRequestFieldSize 8190
PHP配置文件层面的加固
; php.ini 中的安全配置
; 关闭危险函数
disable_functions = include,require,include_once,require_once,system,exec,shell_exec,passthru,popen,proc_open,parse_ini_file,show_source
; 关闭流封装协议(根据实际需求)
allow_url_include = Off
allow_url_fopen = On
; 限制open_basedir(每个虚拟主机单独配置)
open_basedir = /var/www/html/uploads/:/var/www/html/public/:/tmp/
; 关闭PHP版本信息泄露
expose_php = Off
; 错误信息不暴露给前端
display_errors = Off
log_errors = On
error_log = /var/log/php-errors.log
; 限制文件上传相关
file_uploads = On
upload_max_filesize = 10M
max_file_uploads = 10
max_execution_time = 30
实战:完整的文件管理模块(安全版)
<?php
/**
* 安全的文件管理系统
* 综合运用多种防御手段
*/
class SecureFileManager
{
private string $baseDir;
private array $allowedExtensions;
private int $maxFileSize;
private PDO $db;
public function __construct(PDO $db, string $baseDir = '/var/www/uploads')
{
$this->db = $db;
$this->baseDir = rtrim($baseDir, '/\\');
$this->allowedExtensions = ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'txt', 'doc', 'docx'];
$this->maxFileSize = 10 * 1024 * 1024; // 10MB
}
/**
* 列出目录内容
*/
public function listDirectory(string $path = ''): array
{
// 1. 清理路径,只保留合法字符
$path = $this->sanitizePath($path);
// 2. 获取完整路径并规范化
$fullPath = $this->resolveSafePath($path);
if ($fullPath === null) {
throw new RuntimeException('非法路径访问');
}
// 3. 检查是否为目录
if (!is_dir($fullPath)) {
throw new RuntimeException('目录不存在');
}
// 4. 读取目录内容
$items = [];
$iterator = new DirectoryIterator($fullPath);
foreach ($iterator as $file) {
if ($file->isDot()) continue;
$items[] = [
'name' => $file->getFilename(),
'type' => $file->isDir() ? 'directory' : 'file',
'size' => $file->getSize(),
'modified' => $file->getMTime(),
];
}
return $items;
}
/**
* 下载文件(通过ID,不暴露真实文件名)
*/
public function downloadFile(int $fileId): void
{
// 1. 从数据库获取文件信息
$stmt = $this->db->prepare("
SELECT hash, original_name, size, mime_type, upload_time
FROM files
WHERE id = :id AND status = 'active'
");
$stmt->execute([':id' => $fileId]);
$file = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$file) {
throw new RuntimeException('文件不存在');
}
// 2. 构建安全路径(使用哈希值,用户无法猜测)
$filePath = $this->baseDir . DIRECTORY_SEPARATOR . $file['hash'];
// 3. 最终路径安全检查
$realPath = realpath($filePath);
if ($realPath === false || !str_starts_with($realPath, $this->baseDir)) {
throw new RuntimeException('非法文件访问');
}
// 4. 发送文件
header('Content-Type: ' . $file['mime_type']);
header('Content-Disposition: attachment; filename="' . htmlspecialchars($file['original_name']) . '"');
header('Content-Length: ' . $file['size']);
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: DENY');
readfile($realPath);
exit;
}
/**
* 上传文件
*/
public function uploadFile(array $fileData): int
{
if ($fileData['error'] !== UPLOAD_ERR_OK) {
throw new RuntimeException('上传失败,错误码: ' . $fileData['error']);
}
if ($fileData['size'] > $this->maxFileSize) {
throw new RuntimeException('文件大小超过限制');
}
// 验证MIME类型(不仅检查extension,还要检查实际内容)
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mimeType = finfo_file($finfo, $fileData['tmp_name']);
finfo_close($finfo);
// 白名单验证MIME类型
$allowedMimes = [
'application/pdf',
'image/jpeg', 'image/png', 'image/gif',
'text/plain',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
];
if (!in_array($mimeType, $allowedMimes)) {
throw new RuntimeException('不允许的文件类型');
}
// 生成唯一文件名
$hash = bin2hex(random_bytes(32));
$extension = pathinfo($fileData['name'], PATHINFO_EXTENSION);
// 存储路径
$targetPath = $this->baseDir . DIRECTORY_SEPARATOR . $hash;
// 移动到目标位置
if (!move_uploaded_file($fileData['tmp_name'], $targetPath)) {
throw new RuntimeException('文件保存失败');
}
// 设置权限(只读)
chmod($targetPath, 0644);
// 记录到数据库
$stmt = $this->db->prepare("
INSERT INTO files (hash, original_name, size, mime_type, upload_time)
VALUES (:hash, :name, :size, :mime, NOW())
");
$stmt->execute([
':hash' => $hash,
':name' => $fileData['name'],
':size' => $fileData['size'],
':mime' => $mimeType,
]);
return (int)$this->db->lastInsertId();
}
/**
* 清理路径输入
*/
private function sanitizePath(string $path): string
{
// 移除所有路径分隔符和特殊字符
$path = preg_replace('/[^\w\/\.\-]/', '', $path);
$path = str_replace(['../', '..\\', '.\\', '\\.'], '', $path);
$path = str_replace(['//', '\\\\'], '/', $path);
return trim($path, '/\\');
}
/**
* 解析安全路径
*/
private function resolveSafePath(string $path = ''): ?string
{
if ($path === '') {
return $this->baseDir;
}
// 拼接并规范化路径
$fullPath = $this->baseDir . DIRECTORY_SEPARATOR . $path;
$realPath = realpath($fullPath);
if ($realPath === false) {
return null;
}
// 确保规范化后的路径在允许的基目录内
if (!str_starts_with($realPath, $this->baseDir . DIRECTORY_SEPARATOR)
&& $realPath !== $this->baseDir) {
return null;
}
return $realPath;
}
}
// 使用示例
try {
$manager = new SecureFileManager($pdo);
// 列出目录
$files = $manager->listDirectory('documents');
// 下载文件(通过ID,安全)
$manager->downloadFile(123);
// 上传文件
$newId = $manager->uploadFile($_FILES['document']);
} catch (RuntimeException $e) {
http_response_code(400);
error_log('FileManager error: ' . $e->getMessage());
echo '操作失败';
}
?>
防御代码的完整检查清单
每次写文件操作相关代码时,对照这个清单过一遍:
□ 输入验证:路径参数是否经过严格的白名单/黑名单过滤?
□ 路径规范化:是否使用了 realpath() 来解析符号链接和相对路径?
□ 边界检查:规范化后的路径是否在允许的目录范围内?
□ 文件系统操作:是否使用了安全的函数(如 real_path + basename)?
□ 输出编码:文件名输出时是否做了 HTML 实体编码?
□ 错误处理:错误信息是否没有泄露服务器路径结构?
□ 权限控制:文件权限是否设置为最小必要权限?
□ Web服务器配置:是否配置了相应的访问限制规则?
□ 日志记录:异常访问是否被记录并监控?
□ 依赖版本:PHP版本是否足够新(建议 8.0+)?
真实案例:一个漏洞导致的连锁反应
2023年某电商平台出了个事件:
他们的产品图片接口长这样:
$img = $_GET['img'];
readfile('/var/www/images/' . $img);
攻击者发现后,先扫目录遍历:
/img.php?img=../../
看到了完整的目录结构。
然后读配置文件:
/img.php?img=../../config/database.php
拿到了数据库密码。
接着利用密码登录后台,修改了商品价格接口,在订单系统里注入了恶意代码。
最后通过后台的日志查看功能(又一个没过滤的路径拼接),执行了系统命令。
从头到尾,只用了两个没过滤的参数。
总结:安全不是单一措施,是层层设防
目录遍历看着简单,但防御思路是相通的:
- 永远不要信任用户输入的路径——这是铁律
- 白名单优于黑名单——允许什么比禁止什么更安全
- 纵深防御——代码层、Web服务器层、系统层各自加锁
- 最小权限原则——Web进程只应该有必要的文件系统访问权限
- 监控和日志——出了事能追溯,异常访问能被发现
防御这类漏洞没什么捷径,就是把每个用户输入都当成敌人来对待。代码写的时候多花十分钟想想”如果用户传了这个怎么办”,生产环境就少一个半夜被电话叫醒的机会。
