在PHP开发里,scandir是一个用来读取目录条目的基础函数,它返回包含文件和子目录名的数组。不过如果只是简单地把外部参数传给它,很容易引发目录遍历漏洞,导致服务器上的敏感文件被非法列出或读取。真正安全的目录遍历,需要在调用scandir前后都做严格的路径控制。

一、scandir的基本用法与隐患
scandir函数的签名非常直白,传入一个目录路径,就能拿到该路径下的所有项。下面是一段最常见、但也最危险的写法:
<?php
// 危险示例:直接使用用户输入
$dir = $_GET['path'];
$files = scandir($dir);
foreach ($files as $f) {
echo $f . "<br>";
}
?>
这段代码把URL中的path参数原样交给scandir。攻击者在浏览器里输入?path=../../etc,就可以让PHP去扫描系统目录,从而泄露服务器结构。更严重的是,如果后续代码还允许下载这些文件,后果就不只是遍历而是任意文件读取了。
很多新手以为scandir只能列当前目录,其实它完全依赖传入的字符串。只要字符串在操作系统看来是合法路径,它就会照做。因此,限制路径范围的责任完全在开发者自己,而不是函数本身。
二、安全的目录遍历实现思路
要避免路径穿越,核心思路是:先定义允许访问的根目录,再把用户传入的相对名拼进去,最后用realpath拿到规范化绝对路径,确认它确实以根目录开头。下面给出一种封装函数:
<?php
define('BASE_DIR', '/var/www/uploads/');
function safe_scandir($sub_path) {
// 禁止包含斜杠和上级引用,仅允许纯目录名
if (preg_match('/[/\\.]/', $sub_path)) {
return false;
}
$target = realpath(BASE_DIR . $sub_path);
if ($target === false) {
return false;
}
// 确认目标仍在基目录内
if (strpos($target, realpath(BASE_DIR)) !== 0) {
return false;
}
return scandir($target);
}
$list = safe_scandir($_GET['folder']);
if ($list === false) {
echo '非法访问';
} else {
foreach ($list as $name) {
if ($name === '.' || $name === '..') continue;
echo htmlspecialchars($name) . "<br>";
}
}
?>
上面的代码首先用正则拒绝了一切带斜杠、反斜杠和点的输入,这样用户根本没法传入../。接着把输入拼到基目录后,用realpath展开为真实绝对路径,再判断是不是从基目录开始。哪怕系统层做了软链,strpos检查也能挡住越界。
这种写法的优点是逻辑清晰、不依赖服务器配置;缺点是要为每个业务单独定义基目录。相比直接靠php.ini里的open_basedir做全局限制,它更灵活,也能在共享主机之外给出细粒度控制。
三、用scandir实现递归目录树遍历
scandir本身只列一层,如果要遍历整个子目录树,可以在安全封装的基础上做递归。注意每次进入子目录都要复用前面的校验逻辑,不能中途放松。
<?php
function scan_tree($sub_path, $depth = 0) {
$list = safe_scandir($sub_path);
if ($list === false) return;
foreach ($list as $name) {
if ($name === '.' || $name === '..') continue;
echo str_repeat(' ', $depth) . htmlspecialchars($name) . "<br>";
// 拼出下级相对路径继续递归
$next = $sub_path === '' ? $name : $sub_path . '/' . $name;
scan_tree($next, $depth + 1);
}
}
scan_tree('');
?>
这里把空字符串作为起始,表示从BASE_DIR根开始。每遇到一个条目,先打印缩进名称;若它其实是目录,则把名字拼到当前相对路径后再次调用自己。由于safe_scandir已经拦掉了点号与斜杠,递归时不会出现无限返回父目录的死循环。
从性能角度看,递归深度大时可能占用较多栈空间,但对于一般的文件管理后台足够用。若目录极多,可改为显式栈或队列来避免递归过深。无论如何,过滤掉.与..是保证遍历正确的关键一步。
四、常见误区与补充建议
有人觉得在scandir之后用basename()处理一下返回项就安全了,这是概念混淆。basename只处理文件名本身,不改变你传给scandir的路径,穿越发生在读取之前,而非之后。还有人以为把open_basedir设好就不需要写代码防护,但open_basedir在某些扩展或命令行模式下并不生效,不能作为唯一防线。
另外,scandir返回的数组包含.和..两项,若在前端展示时忘了跳过,会让用户看到父目录链接,甚至造成重复遍历。推荐统一在循环里用条件continue掉这两项,保持界面干净也避免逻辑错误。
最后提醒,任何用到scandir的地方,输出文件名到页面都要经htmlspecialchars处理,防止文件名里夹带HTML标签造成注入。把路径校验、递归封装和输出转义三件事做全,目录遍历功能才能既好用又安全。