嘿,朋友!你是不是在写PHP代码的时候,经常需要读取某个文件夹里有哪些文件?这时候 glob() 和 scandir() 两个函数简直是神器。但你知道吗,如果用得不好,它们可是会打开安全大门的——特别是当用户输入直接混进去的时候。今天咱们就坐下来,一边泡杯茶,一边把这事儿掰开揉碎讲清楚。
一、先认识这两位”老朋友”
glob() —— 会通配符的”文件检索员”
glob() 听起来挺高大上,其实它就是PHP里的”文件通配符搜索”。你可以给它传一个模式,它就把所有匹配的文件都找出来给你。
// 最简单的用法:列出当前目录下所有PHP文件
$files = glob("*.php");
print_r($files);
// 指定路径
$files = glob("/var/www/html/uploads/*.jpg");
print_r($files);
// 使用GLOB_BRACE多选模式
$files = glob(".{jpg,png,gif}", GLOB_BRACE);
print_r($files);
// 返回完整路径(这个很常用!)
$files = glob("/var/www/html/uploads/*", GLOB_MARK);
print_r($files);
你看,GLOB_MARK 这个参数会在文件名后面加个斜杠,方便你判断是不是目录。GLOB_BRACE 则让你一次匹配多种扩展名,不用写三个 glob 调用。
scandir() —— 直来直去的”目录清单员”
如果说 glob() 是个会玩花样的大厨,那 scandir() 就是个老实巴交的服务员——你让它端什么,它就端什么,不多嘴,不乱加料。
// 列出目录内容,升序排列
$items = scandir("/var/www/html/uploads");
print_r($items);
// 降序排列
$items = scandir("/var/www/html/uploads", SCANDIR_SORT_DESCENDING);
print_r($items);
// 配合路径处理,去掉开头的 . 和 ..
$items = scandir("/var/www/html/uploads");
$items = array_slice($items, 2); // 跳过 . 和 ..
注意看,scandir() 返回的结果里永远带着 . 和 ..,这两个东西代表当前目录和父目录。在web应用里,如果你直接把这俩显示给用户,可能会暴露目录结构,所以通常要过滤掉。
二、这两个函数有啥区别?怎么选?
咱们用个对比表,一眼就能看明白:
| 特性 | glob() | scandir() |
|---|---|---|
| 支持通配符 | ✅ 支持 * ? {} |
❌ 不支持 |
| 返回完整路径 | 需要加 GLOB_MARK |
需要自己拼接 |
| 性能 | 稍慢(有模式匹配) | 更快(直接读目录) |
| 返回值排序 | 不确定顺序 | 字母序(可指定) |
| 处理符号链接 | 默认跟随 | 默认跟随 |
| 适用场景 | 按规则找文件 | 列出所有条目 |
举个例子,如果你要”找出所有大于1MB的图片文件”,用 glob() 配合文件检查更合适;如果你只是要”列出这个目录里有啥”,scandir() 更快更直接。
三、安全漏洞:目录遍历攻击
这才是今天重点要讲的。很多开发者觉得”我就读个目录,能出啥事”——然后就被黑进来了。
漏洞是怎么产生的?
假设你写了一个图片库系统,用户访问 ?dir=photos 时显示 uploads/photos/ 目录的内容:
<?php
// ❌ 危险的代码!千万别这么写!
$dir = $_GET['dir'];
$files = glob($dir . '/*');
foreach ($files as $file) {
echo "<img src='$file'><br>";
}
?>
黑客只需要在URL里传入:
?dir=../../etc
或者更狠的:
?dir=../../var/log
你的服务器就把 /etc 或 /var/log 目录下的文件列表全部展示出来了!轻则暴露配置文件,重则能读到 passwd 文件、SSH密钥、甚至数据库密码。
漏洞本质
这叫做目录遍历(Directory Traversal),也叫路径穿越(Path Traversal)。攻击者通过构造包含 ../ 的路径,跳出你预设的目录,访问系统任意位置。
PHP 的 glob() 和 scandir() 本身没有这个漏洞——是你没有对用户输入做过滤,让它们直接进了函数。
四、防御方案:层层设防
方案一:白名单校验(最推荐)
不要让用户决定目录,而是预定义允许的目录:
<?php
// ✅ 安全的做法:白名单机制
$allowedDirs = [
'photos' => '/var/www/html/uploads/photos',
'docs' => '/var/www/html/uploads/docs',
'videos' => '/var/www/html/uploads/videos'
];
$dirKey = $_GET['dir'] ?? '';
if (!array_key_exists($dirKey, $allowedDirs)) {
http_response_code(403);
die('非法目录访问');
}
$basePath = $allowedDirs[$dirKey];
// 再次确认路径没有逃逸
$realPath = realpath($basePath);
if ($realPath === false || !is_dir($realPath)) {
http_response_code(404);
die('目录不存在');
}
$files = glob($realPath . '/*');
foreach ($files as $file) {
if (is_file($file)) {
echo "<img src='/uploads/" . basename($file) . "'><br>";
}
}
?>
这里做了三件事:
- 白名单控制:用户只能选预定义的目录键
- realpath 确认:把路径解析为绝对路径,防止符号链接或
../逃逸 - basename 过滤:输出文件名时用
basename()确保只有文件名,没有路径
方案二:路径规范化与校验
如果你确实需要动态路径,一定要做严格的规范化检查:
<?php
function safeGlob($userPath, $allowedBase) {
// 获取允许的基准目录的绝对路径
$realBase = realpath($allowedBase);
if ($realBase === false || !is_dir($realBase)) {
return [];
}
// 将用户路径也解析为绝对路径
$realPath = realpath($userPath);
if ($realPath === false) {
return [];
}
// 检查解析后的路径是否在允许基准目录下
// 注意:用 trailingslashstr 确保比较时一致
if (strpos($realPath . '/', $realBase . '/') !== 0) {
// 路径逃逸了!
error_log('目录遍历攻击尝试: ' . $userPath);
return [];
}
// 安全,返回结果
return glob($realPath . '/*');
}
// 使用
$allowedBase = '/var/www/html/uploads';
$files = safeGlob($_GET['dir'], $allowedBase);
?>
这个函数的核心逻辑是:无论用户输入什么,先 realpath 解析,再检查解析后的路径是否以允许的基准路径开头。这样就算传入 ../../etc,realpath 解析后也不会落在 /var/www/html/uploads 下面,直接被拦截。
方案三:禁用危险函数(加固服务器)
在 php.ini 中可以限制某些函数,虽然不能直接禁用 glob 和 scandir,但可以:
; 禁用危险函数
disable_functions = system, exec, shell_exec, passthrough, popen, escapeshellcmd
; 限制文件包含
open_basedir = /var/www/html:/tmp
; 关闭危险模式(PHP 8.0+ 已移除)
; safe_mode = On
open_basedir 特别有用,它把PHP能访问的文件限制在指定目录内。即使代码有漏洞,攻击者也无法读取 open_basedir 之外的文件。
五、完整的安全示例:一个图片库系统
咱们来写一个完整的、生产可用的示例,把前面讲的都结合起来:
<?php
/**
* 安全的文件浏览类
* 演示如何正确使用 glob/scandir 并防御目录遍历
*/
class SafeFileBrowser
{
private string $allowedRoot;
private array $allowedExtensions;
public function __construct(
string $allowedRoot,
array $allowedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp']
) {
// 清理根路径,去掉末尾斜杠
$this->allowedRoot = rtrim(realpath($allowedRoot) ?: $allowedRoot, '/\\');
$this->allowedExtensions = $allowedExtensions;
}
/**
* 安全地列出目录内容
*/
public function scanDirectory(string $userPath): array
{
// 1. 验证输入:只允许字母、数字、下划线、中划线和斜杠
if (!preg_match('/^[\w\-\/]+$/u', $userPath)) {
return $this->errorResult('非法字符');
}
// 2. 构建完整路径并解析
$fullPath = $this->allowedRoot . '/' . ltrim($userPath, '/\\');
$realPath = realpath($fullPath);
// 3. 路径解析失败
if ($realPath === false) {
return $this->errorResult('路径不存在');
}
// 4. 关键检查:确保解析后的路径在允许根目录下
if (strpos($realPath . '/', $this->allowedRoot . '/') !== 0) {
error_log('⚠️ 目录遍历尝试: ' . $userPath . ' 解析为 ' . $realPath);
return $this->errorResult('访问被拒绝');
}
// 5. 检查是否是目录
if (!is_dir($realPath)) {
return $this->errorResult('不是目录');
}
// 6. 使用 glob 获取文件列表
$files = glob($realPath . '/*');
if ($files === false) {
$files = [];
}
// 7. 过滤,只保留允许类型的文件
$result = [];
foreach ($files as $file) {
if (is_file($file)) {
$ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));
if (in_array($ext, $this->allowedExtensions)) {
$result[] = [
'name' => basename($file),
'path' => $file,
'size' => filesize($file),
'type' => $ext,
];
}
}
}
// 按名称排序
usort($result, function ($a, $b) {
return strcmp($a['name'], $b['name']);
});
return ['success' => true, 'data' => $result];
}
private function errorResult(string $message): array
{
return ['success' => false, 'error' => $message];
}
}
// ========== 使用示例 ==========
$browser = new SafeFileBrowser('/var/www/html/uploads');
$userPath = $_GET['dir'] ?? '';
$result = $browser->scanDirectory($userPath);
header('Content-Type: application/json');
echo json_encode($result, JSON_PRETTY_PRINT);
这个类做了这么多层防护:
- 输入正则校验:拒绝非法字符
- realpath 解析:消除
../和符号链接的干扰 - 前缀匹配检查:确保不会逃逸出允许目录
- 扩展名过滤:只返回允许类型的文件
- 日志记录:把攻击尝试记录下来供审计
六、scandir 的安全用法
刚才主要讲 glob,其实 scandir 也一样危险,修复思路完全一样:
<?php
/**
* 使用 scandir 的安全示例
*/
function safeScanDir(string $userPath, string $allowedRoot): array
{
$realRoot = realpath($allowedRoot);
if ($realRoot === false) {
return ['error' => '根目录不存在'];
}
$targetPath = $realRoot . '/' . ltrim($userPath, '/\\');
$realTarget = realpath($targetPath);
if ($realTarget === false || strpos($realTarget . '/', $realRoot . '/') !== 0) {
error_log('⚠️ 扫描路径逃逸: ' . $userPath);
return ['error' => '禁止访问'];
}
if (!is_dir($realTarget)) {
return ['error' => '不是目录'];
}
// scandir 本身不会注入,但返回的 . 和 .. 要过滤
$items = scandir($realTarget);
$items = array_diff($items, ['.', '..']);
return ['success' => true, 'data' => $items];
}
// 使用
$result = safeScanDir($_GET['dir'] ?? '', '/var/www/html/uploads');
scandir 比 glob 简单,因为它不做模式匹配,所以注入风险稍低,但路径遍历风险完全一样,必须做同样的校验。
七、常见错误与最佳实践总结
❌ 这些写法是错的
// 错1:直接拼接用户输入
glob($_GET['dir'] . '/*');
// 错2:只用 trim 过滤
$dir = trim($_GET['dir'], '../'); // 这根本挡不住!
// 错3:只检查开头
str_starts_with($userPath, 'uploads/') // 可以用 ../ 绕过
// 错4:用 str_replace 删 ../
str_replace('../', '', $userPath) // ../../ 替换一次变成 ../,还是能逃逸
✅ 这些写法是对的
// 正1:白名单
$allowed = ['photos', 'docs'];
if (!in_array($_GET['dir'], $allowed)) { die('403'); }
// 正2:realpath + 前缀检查(最通用)
$real = realpath($userPath);
if (strpos($real . '/', $allowedRoot . '/') !== 0) { die('403'); }
// 正3:open_basedir + 严格输入校验
// php.ini 中设置 open_basedir,代码中再做二次校验
// 正4:使用框架的安全函数
// Laravel: Storage::disk('public')->files($path)
// Symfony: Filesystem 组件内置安全处理
八、给小朋友也能听懂的比喻
想象你有一个上锁的柜子(允许的目录),里面有很多抽屉(子目录)。glob 和 scandir 就是你去翻抽屉的工具。
- 安全的做法:你手里只有一把特定的钥匙,只能开你自己柜子上的抽屉,而且每个抽屉上都有标签,你只能看标签上允许看的抽屉。
- 不安全的做法:你把钥匙挂在大门上,谁都能拿,还能用铁丝撬开其他柜子,甚至把钥匙交给坏人,让他打开你家里的保险箱。
目录遍历攻击就是那个用铁丝撬锁的坏人,而 realpath 校验就是你的智能锁——不管你递进来什么钥匙形状,它都会先把它变成标准形状,再检查这把钥匙是不是能开你的锁。
好了,今天的内容就到这里。记住一句话:永远不要信任用户输入的路径,realpath 加上前缀检查是你的最佳伙伴。代码写安全了,心里才踏实。如果还有疑问,随时来问!
