说实话,写PHP多年,我见过太多人在这两个函数上栽跟头。scandir和glob看着简单,用好了是神器,用错了就是高危漏洞的温床。今天咱们不聊虚的,直接上干货,把这两个函数的用法、坑点、以及如何防止目录遍历攻击一次性讲透。
一、scandir:基础用法与常见陷阱
先说scandir。这个函数太常用了,用法也简单:
<?php
// 基础用法:读取目录
$files = scandir('/var/www/html/uploads');
// 返回的是数组,包含 "." 和 "..",记得过滤掉
$files = array_diff(scandir('/var/www/html/uploads'), array('.', '..'));
// 按字母顺序排列
sort($files);
// 逆序排列
rsort($files);
print_r($files);
但是,你有没有想过,如果这个目录路径是用户可控的呢?
<?php
// 危险代码示例
$dir = $_GET['path'];
$files = scandir($dir);
foreach ($files as $file) {
echo $file . '<br>';
}
这段代码看着没啥问题,对吧?但攻击者只需要传个 ?path=/etc/,你服务器上的所有文件信息就全暴露了。这就是典型的目录遍历漏洞。
二、glob:更强大的文件匹配工具
如果说scandir是基础款,那glob就是高级定制版。它支持通配符匹配,功能更强:
<?php
// 获取所有PHP文件
$phpFiles = glob('/var/www/html/*.php');
// 获取所有图片和PDF
$mediaFiles = glob('/var/www/html/uploads/*.{jpg,png,pdf}', GLOB_BRACE);
// 递归获取所有文件(PHP 5.3.1+)
$allFiles = glob('/var/www/html/**/*', GLOB_BRACE | GLOB_RECURSE);
// 只获取目录
$directories = glob('/var/www/html/*', GLOB_ONLYDIR);
print_r($phpFiles);
glob有个很大的优势:它可以一步完成”查找+过滤”。但是,它同样面临目录遍历的风险:
<?php
// 危险代码示例
$search = $_GET['search'];
$files = glob('/var/www/html/' . $search);
攻击者传个 ?search=../../etc/*,就能跨目录读取文件了。
三、目录遍历攻击原理与危害
在讲修复之前,咱们得先理解攻击者是怎么利用这些漏洞的。
目录遍历的核心思路就是:利用路径分隔符(../)跳出预期目录,访问敏感位置。
<?php
// 攻击者可能尝试的路径
$paths = [
'/etc/passwd', // Linux系统用户信息
'/etc/shadow', // 密码哈希
'C:\Windows\System32\drivers\etc\hosts', // Windows主机文件
'/var/log/apache2/access.log', // Web服务器日志
'../../.env', // 应用环境变量(包含数据库密码等)
'php://filter/convert.base64-encode/resource=config.php' // PHP流包装器
];
一旦攻击成功,后果可能是:
- 信息泄露:读取服务器敏感配置文件
- 代码执行:结合其他漏洞上传恶意文件
- 权限提升:通过读取ssh密钥等文件提升权限
- 业务破坏:删除或篡改重要文件
四、修复方案:从入门到精通
方案一:白名单验证(最推荐)
永远不要信任用户输入的路径,只允许访问你明确知道的目录:
<?php
/**
* 安全的目录遍历防护
* 使用白名单机制
*/
// 定义允许访问的基目录
$allowedBaseDir = '/var/www/html/uploads';
// 获取用户输入
$userPath = $_GET['path'] ?? '';
// 规范化路径,解析所有符号链接和相对路径
$realPath = realpath($allowedBaseDir . DIRECTORY_SEPARATOR . $userPath);
// 关键验证:确保解析后的真实路径仍在允许目录下
if ($realPath === false || strpos($realPath, $allowedBaseDir) !== 0) {
http_response_code(403);
die('非法访问');
}
// 现在可以安全操作
$files = scandir($realPath);
$files = array_diff($files, array('.', '..'));
sort($files);
print_r($files);
这段代码的关键点:
- 使用
realpath()解析真实路径,消除..和符号链接的影响 - 使用
strpos()验证解析后的路径是否在允许范围内 - 返回403状态码而不是直接die,更符合Web规范
方案二:路径规范化函数封装
如果你需要频繁做路径验证,建议封装一个通用函数:
<?php
/**
* 安全路径解析函数
*
* @param string $baseDir 允许的基目录
* @param string $userPath 用户输入的路径
* @return string|false 安全路径,如果非法返回false
*/
function getSafePath(string $baseDir, string $userPath): string|false
{
// 规范化基目录,确保以目录分隔符结尾
$baseDir = rtrim(realpath($baseDir), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
// 如果基目录不存在,返回false
if ($baseDir === false || $baseDir === DIRECTORY_SEPARATOR) {
return false;
}
// 拼接并解析路径
$fullPath = realpath($baseDir . $userPath);
// 验证解析后的路径是否在基目录下
if ($fullPath === false || strpos($fullPath, $baseDir) !== 0) {
return false;
}
return $fullPath;
}
// 使用示例
$basePath = '/var/www/html/uploads';
$userPath = $_GET['file'] ?? '';
$safePath = getSafePath($basePath, $userPath);
if ($safePath === false) {
http_response_code(403);
die('访问被拒绝');
}
// 安全地读取文件
$content = file_get_contents($safePath);
echo $content;
方案三:针对glob的安全使用
glob函数需要特别小心,因为它本身不支持路径验证:
<?php
/**
* 安全使用glob函数
*/
$allowedDir = '/var/www/html/uploads';
$searchPattern = $_GET['pattern'] ?? '*';
// 步骤1:先验证基础目录的安全性
$safeBaseDir = realpath($allowedDir);
if ($safeBaseDir === false || !is_dir($safeBaseDir)) {
die('目录不存在');
}
// 步骤2:对用户输入的模式进行严格过滤
// 只允许字母、数字、下划线、连字符和星号
if (!preg_match('/^[a-zA-Z0-9_\-*\?]+$/', $searchPattern)) {
die('非法的搜索模式');
}
// 步骤3:构建搜索路径
$searchPath = $safeBaseDir . DIRECTORY_SEPARATOR . $searchPattern;
// 步骤4:执行glob
$files = glob($searchPath);
if ($files === false) {
$files = [];
}
// 步骤5:验证返回的每个文件都在允许目录下
$validFiles = array_filter($files, function($file) use ($safeBaseDir) {
$realPath = realpath($file);
return $realPath !== false && strpos($realPath, $safeBaseDir) === 0;
});
print_r(array_values($validFiles));
方案四:使用更安全的替代方案
如果你只是需要列出文件,考虑使用DirectoryIterator,它更面向对象,也更容易控制:
<?php
/**
* 使用DirectoryIterator进行安全遍历
*/
$allowedDir = '/var/www/html/uploads';
$userInput = $_GET['subdir'] ?? '';
// 构建完整路径
$fullPath = realpath($allowedDir . DIRECTORY_SEPARATOR . $userInput);
// 验证路径
if ($fullPath === false || !is_dir($fullPath) || strpos($fullPath, realpath($allowedDir)) !== 0) {
http_response_code(403);
die('访问被拒绝');
}
// 使用DirectoryIterator
$iterator = new DirectoryIterator($fullPath);
foreach ($iterator as $fileinfo) {
if ($fileinfo->isDot()) {
continue;
}
// 可以进一步验证文件类型
if ($fileinfo->isFile()) {
echo $fileinfo->getFilename() . ' (' . $fileinfo->getSize() . ' bytes)' . PHP_EOL;
} elseif ($fileinfo->isDir()) {
echo '[' . $fileinfo->getFilename() . ']' . PHP_EOL;
}
}
五、防御体系:多层防护策略
单一防护手段总有被绕过的可能,我建议你建立多层防护:
第一层:输入验证
<?php
// 对用户输入进行严格过滤
function validatePathInput(string $input): bool
{
// 禁止路径遍历字符
if (strpos($input, '..') !== false) {
return false;
}
// 禁止特殊字符
if (preg_match('/[<>*?"\'|;(){}]/', $input)) {
return false;
}
// 限制长度
if (strlen($input) > 100) {
return false;
}
// 只允许字母、数字、下划线、连字符和正斜杠
if (!preg_match('/^[a-zA-Z0-9_\-\/]+$/', $input)) {
return false;
}
return true;
}
第二层:路径规范化
<?php
// 使用realpath解析真实路径
function normalizePath(string $path): string|false
{
// 解决符号链接和相对路径问题
$normalized = realpath($path);
if ($normalized === false) {
return false;
}
// 确保是目录
if (!is_dir($normalized)) {
return false;
}
return $normalized;
}
第三层:权限控制
<?php
// 确保PHP进程对目标目录有读取权限
function checkDirectoryAccess(string $dir): bool
{
// 检查目录是否存在
if (!is_dir($dir)) {
return false;
}
// 检查读取权限
if (!is_readable($dir)) {
return false;
}
// 检查执行权限(遍历目录需要)
if (!is_executable($dir)) {
return false;
}
return true;
}
第四层:日志监控
<?php
// 记录所有目录访问尝试
function logDirectoryAccess(string $userPath, string $realPath, bool $allowed): void
{
$logEntry = sprintf(
'%s | IP: %s | User Path: %s | Real Path: %s | Allowed: %s',
date('Y-m-d H:i:s'),
$_SERVER['REMOTE_ADDR'] ?? 'unknown',
$userPath,
$realPath,
$allowed ? 'yes' : 'no'
);
// 写入日志文件
error_log($logEntry . PHP_EOL, 3, '/var/log/php/dir_access.log');
// 对于非法访问,发送告警
if (!$allowed) {
// 可以集成告警系统,如邮件、Slack、钉钉等
error_log("SECURITY ALERT: Directory traversal attempt detected!", 0);
}
}
六、完整的安全目录遍历类
把上面的思路整合成一个可复用的类:
”`php <?php /**
安全的目录遍历器
集成了多层防护机制 */ class SafeDirectoryTraversal { private string \(allowedBaseDir; private array \)allowedExtensions = []; private bool $logEnabled = true;
public function __construct(string \(baseDir, array \)allowedExtensions = []) {
// 验证并规范化基础目录 $this->allowedBaseDir = realpath($baseDir); if ($this->allowedBaseDir === false || !is_dir($this->allowedBaseDir)) { throw new InvalidArgumentException("基础目录不存在或无法访问: {$baseDir}"); } $this->allowedExtensions = $allowedExtensions;}
/**
安全地列出目录内容 */ public function scan(string \(relativePath = ''): array { // 输入验证 if (!\)this->validateInput($relativePath)) {
$this->logAccess($relativePath, '', false); return [];}
// 路径规范化 \(fullPath = \)this->normalizePath($relativePath);
if ($fullPath === false) {
$this->logAccess($relativePath, '', false); return [];}
// 执行扫描 \(files = scandir(\)fullPath);
if ($files === false) {
$this->logAccess($relativePath, $fullPath, false); return [];}
// 过滤和排序 \(result = array_diff(\)files, array(‘.’, ‘..’));
// 如果指定了扩展名过滤 if (!empty($this->allowedExtensions)) {
$result = array_filter($result, function($file) { $ext = strtolower(pathinfo($file, PATHINFO_EXTENSION)); return in_array($ext, $this->allowedExtensions); });}
sort($result);
\(this->logAccess(\)relativePath, $fullPath, true);
return array_values($result); }
/**
安全地使用glob */ public function glob(string \(pattern): array { // 验证模式 if (!\)this->validateGlobPattern($pattern)) {
$this->logAccess($pattern, '', false); return [];}
// 构建安全路径 \(safePattern = \)this->allowedBaseDir . DIRECTORY_SEPARATOR . $pattern;
// 执行glob \(files = glob(\)safePattern);
if ($files === false) {
$files = [];}
// 验证每个文件 \(validFiles = array_filter(\)files, function($file) {
$realPath = realpath($file); return $realPath !== false && strpos($realPath, $this->allowedBaseDir) === 0;});
\(this->logAccess(\)pattern, implode(‘,’, array_values($validFiles)), true);
return array_values($validFiles); }
/**
输入验证 */ private function validateInput(string \(input): bool { // 禁止路径遍历 if (strpos(\)input, ‘..’) !== false) {
return false;}
// 禁止绝对路径 if (strpos($input, DIRECTORY_SEPARATOR . DIRECTORY_SEPARATOR) !== false
|| preg_match('/^[a-zA-Z]:/', $input)) { return false;}
// 禁止特殊字符 if (preg_match(‘/[<>*?“\’|;`\((){}]/', \)input)) {
return false;}
// 限制长度 if (strlen($input) > 200) {
return false;}
return true; }
/**
Glob模式验证 / private function validateGlobPattern(string $pattern): bool { // 只允许字母、数字、下划线、连字符、星号、问号和点号 if (!pregmatch(‘/^[a-zA-Z0-9-\?.\/]+\(/', \)pattern)) {
return false;}
// 禁止路径遍历 if (strpos($pattern, ‘..’) !== false) {
return false;}
return true; }
/**
路径规范化 */ private function normalizePath(string \(relativePath): string|false { \)fullPath = realpath(\(this->allowedBaseDir . DIRECTORY_SEPARATOR . \)relativePath);
if ($fullPath === false) {
return false;}
// 验证是否在允许目录下 if (strpos(\(fullPath, \)this->allowedBaseDir . DIRECTORY_SEPARATOR) !== 0
&& $fullPath !== $this->allowedBaseDir) { return false;}
return $fullPath; }
/**
日志记录 */ private function logAccess(string \(userPath, string \)realPath, bool \(allowed): void { if (!\)this->logEnabled) {
return;}
$logEntry = sprintf(
"[%s] IP: %s | User: %s | Real: %s | Allowed: %s", date('Y-m-d H:i:s'), $_SERVER['REMOTE_ADDR'] ?? 'cli', $userPath, $realPath ?: 'N/A', $allowed ? 'YES' : 'NO');
error_log($logEntry . PHP_EOL, 3, ‘/var/log/php/safe_dir_traversal.log’); } }
// 使用示例 try {
$safeScanner = new SafeDirectoryTraversal('/var/www/html/uploads
