说实话,我在代码审查里见过太多让人冷汗直流的PHP文件了。那种把用户传进来的文件名直接拼进 include 或 file_get_contents 的代码,就像是在自家大门上挂了块牌子:“欢迎光临,请随便进”。
很多人觉得“我这是内网应用,没人会黑我”,或者“我只是做了个简单的文件查看器”。但现实是,攻击者根本不需要知道你在哪里部署,他们只需要一个随机的扫描器。
今天我想和你聊聊怎么彻底干掉目录遍历漏洞(Directory Traversal),而不是贴一堆枯燥的安全理论。咱们用 scandir() 配合 is_dir() 来构建一个真正安全的文件管理方案。
为什么简单的拼接是最危险的“捷径”
先看看那段让你睡不着觉的代码长什么样:
<?php
// 典型的反模式,别这么做!
$file = $_GET['file'];
include('/var/www/uploads/' . $file);
?>
当你访问 ?file=../../etc/passwd 时,攻击者直接读取了系统核心文件。如果你用的是 ../uploads/config.php,你的数据库密码就暴露了。
更糟糕的是,有些人试图用黑名单过滤:
<?php
$file = $_GET['file'];
if (strpos($file, '..') !== false) {
die('非法路径');
}
include('/var/www/uploads/' . $file);
这有用吗?有点用,但不够。现代攻击工具已经能绕过简单的字符串检查。而且,你不可能穷举所有危险组合。
真正的问题是:你根本无法信任任何来自用户输入的路径。你要做的不是过滤坏字符,而是验证好路径。
scandir 的核心优势:把路径锁定在盒子里
scandir() 不是用来扫描整个硬盘的魔法工具,它是一个让你能够列举指定目录内容的函数。配合 is_dir(),你可以构建一个白名单机制。
让我用一个真实的场景来说明。假设你在做一个内部文件管理系统,用户可以查看 uploads/ 目录下的子文件夹和图片。
第一步:理解绝对路径的魅力
很多开发者害怕使用绝对路径,觉得写起来麻烦。但其实,绝对路径是你最强大的盟友。因为它不会随运行环境改变,你可以精确地控制它在哪。
<?php
// 定义允许的根目录
const UPLOAD_ROOT = '/var/www/myapp/uploads';
function safeScan($relativePath) {
// 将相对路径解析为绝对路径
$fullPath = realpath(UPLOAD_ROOT . '/' . $relativePath);
// 检查路径是否存在
if ($fullPath === false) {
return ['error' => '路径不存在'];
}
// 关键检查:确保解析后的路径仍然在 UPLOAD_ROOT 内
if (strpos($fullPath, UPLOAD_ROOT) !== 0) {
return ['error' => '非法访问:路径逃逸'];
}
return $fullPath;
}
这个 realpath() 函数是关键。它会把所有 ..、. 和符号链接都解析成真实路径。当攻击者传入 ../../etc/passwd 时,realpath() 返回的是 /etc/passwd,显然不在 /var/www/myapp/uploads 之下,检查直接失败。
第二步:用 scandir 构建安全列表
现在,我们有了安全的绝对路径,可以用 scandir() 来列出内容了:
<?php
function getSafeDirectoryContents($relativePath) {
$safePath = safeScan($relativePath);
if (isset($safePath['error'])) {
return ['error' => $safePath['error']];
}
// 用 is_dir 双重确认这是个目录
if (!is_dir($safePath)) {
return ['error' => '这不是一个目录'];
}
// scandir 返回包括 . 和 .. 的完整列表
$items = scandir($safePath);
// 过滤掉隐藏文件(可选,根据需求)
$safeItems = array_filter($items, function($item) use ($safePath) {
// 排除 . 和 ..
if ($item === '.' || $item === '..') {
return false;
}
$fullItemPath = $safePath . '/' . $item;
// 只返回文件或目录,过滤掉设备文件等
if (is_file($fullItemPath) || is_dir($fullItemPath)) {
return true;
}
return false;
});
return [
'path' => $safePath,
'items' => $safeItems
];
}
这段代码看起来有点长,但每一步都在保护你。is_dir() 在这里不只是检查存在性,更是确认“这确实是个目录,我可以扫描它”。
实战:创建一个安全的文件浏览器API
光有理论不够,咱们写个完整的API示例。假设你需要一个端点,让前端获取文件夹列表:
<?php
// 假设这是你的路由处理文件
header('Content-Type: application/json');
$allowedRoot = '/var/www/myapp/public/uploads';
// 获取请求路径,默认根目录
$requestedPath = isset($_GET['path']) ? $_GET['path'] : '';
// 清理路径:移除开头和结尾的斜杠,替换多个斜杠
$requestedPath = trim($requestedPath, '/');
$requestedPath = preg_replace('#/{2,}#', '/', $requestedPath);
// 安全解析
$resolvedPath = realpath($allowedRoot . '/' . $requestedPath);
// 核心安全检查
if ($resolvedPath === false) {
http_response_code(404);
echo json_encode(['error' => '目录不存在']);
exit;
}
if (strpos($resolvedPath, $allowedRoot) !== 0) {
http_response_code(403);
echo json_encode(['error' => '访问被拒绝:路径不包含在允许的根目录下']);
exit;
}
if (!is_dir($resolvedPath)) {
http_response_code(400);
echo json_encode(['error' => '这不是一个目录']);
exit;
}
// 安全扫描
$files = scandir($resolvedPath);
// 分离文件和目录
$directories = [];
$filesList = [];
foreach ($files as $item) {
if ($item === '.' || $item === '..') {
continue;
}
$fullPath = $resolvedPath . '/' . $item;
$itemData = [
'name' => $item,
'path' => $requestedPath ? $requestedPath . '/' . $item : $item,
'type' => is_dir($fullPath) ? 'dir' : 'file',
'size' => is_dir($fullPath) ? null : filesize($fullPath),
'modified' => date('Y-m-d H:i:s', filemtime($fullPath))
];
if (is_dir($fullPath)) {
$directories[] = $itemData;
} else {
$filesList[] = $itemData;
}
}
// 返回结果
echo json_encode([
'current_path' => $requestedPath,
'parent_path' => ($requestedPath !== '') ? dirname($requestedPath) : '',
'directories' => $directories,
'files' => $filesList
]);
前端调用起来非常简单:
- 查看上传目录:
GET /api/files?path= - 查看子文件夹:
GET /api/files?path=images/2023 - 尝试攻击:
GET /api/files?path=../../etc→ 返回 403
深入理解:为什么 is_dir 不能少?
你可能会问,既然 realpath() 已经解析了路径,为什么还要 is_dir()?
这里有个关键点:权限问题。
在某些Linux系统中,如果用户对某个目录有读权限但没有执行权限,is_dir() 可能返回 false,即使路径存在。但更重要的是,is_dir() 帮你确认了“这确实是一个目录”,而不是一个同名文件。
想象一下这个攻击向量:攻击者创建一个名为 admin 的文件(而不是目录),然后你的代码尝试 scandir('admin')。如果这个文件存在,scandir() 会报错或返回意外结果。is_dir() 在这里起到了类型校验的作用。
另一个实际例子:符号链接。如果目录 link 实际上是指向 /etc 的符号链接,realpath() 会解析到真实路径 /etc,你的安全检查 strpos($resolvedPath, $allowedRoot) !== 0 会拦截它。但如果你先检查 is_dir(),它可能返回 true(因为符号链接指向的是目录),从而给你一种虚假的安全感。
所以正确的顺序是:
realpath()解析真实路径- 检查是否在允许范围内
is_dir()确认是目录scandir()扫描内容
性能与体验的平衡
很多开发者担心 realpath() 和 is_dir() 会拖慢系统。确实,每次请求都做文件I/O检查是有成本的。但在安全面前,这点延迟是值得的。
不过,你可以通过缓存优化体验:
<?php
class SecureFileBrowser {
private $allowedRoot;
private $cache = [];
private $cacheTtl = 60; // 缓存60秒
public function __construct($root) {
$this->allowedRoot = realpath($root);
}
public function getDirectoryList($relativePath) {
$cacheKey = md5($this->allowedRoot . '/' . $relativePath);
// 检查缓存
if (isset($this->cache[$cacheKey]) &&
(time() - $this->cache[$cacheKey]['time']) < $this->cacheTtl) {
return $this->cache[$cacheKey]['data'];
}
$fullPath = $this->allowedRoot . '/' . $relativePath;
$resolvedPath = realpath($fullPath);
if ($resolvedPath === false ||
strpos($resolvedPath, $this->allowedRoot) !== 0 ||
!is_dir($resolvedPath)) {
return null;
}
$items = scandir($resolvedPath);
$result = $this->parseItems($items, $resolvedPath, $relativePath);
// 存入缓存
$this->cache[$cacheKey] = [
'time' => time(),
'data' => $result
];
return $result;
}
private function parseItems($items, $basePath, $relativeBase) {
$directories = [];
$files = [];
foreach ($items as $item) {
if ($item === '.' || $item === '..') continue;
$fullPath = $basePath . '/' . $item;
$isDir = is_dir($fullPath);
$itemInfo = [
'name' => $item,
'type' => $isDir ? 'dir' : 'file',
'path' => $relativeBase ? $relativeBase . '/' . $item : $item
];
if ($isDir) {
$directories[] = $itemInfo;
} else {
$files[] = $itemInfo;
}
}
return [
'directories' => $directories,
'files' => $files,
'current_path' => $relativeBase
];
}
}
// 使用示例
$browser = new SecureFileBrowser('/var/www/myapp/uploads');
$result = $browser->getDirectoryList('images/2023');
这个缓存机制在高频访问场景下特别有用,比如后台管理界面的文件浏览。
常见陷阱与最佳实践
陷阱1:只检查开头,不检查完整路径
// 错误示范
if (strpos($path, '../') !== false) {
die('非法');
}
这种黑名单方法会被绕过。攻击者可以使用编码、双写、绝对路径等多种方式绕过。
陷阱2:忽略符号链接
// 错误示范
if (is_dir($path)) {
// 直接扫描,可能跟随符号链接到危险位置
scandir($path);
}
总是使用 realpath() 来解析符号链接,确保你扫描的是真实位置。
陷阱3:在循环中重复检查
每个文件都调用 realpath() 和 is_dir() 会显著降低性能。在批量操作时,先检查目录,再对目录内项目进行检查。
最佳实践总结
- 始终使用绝对路径:让路径解析变得简单明确
- 白名单而非黑名单:只允许你知道的路径,而不是过滤掉危险的路径
- 分层验证:
realpath()→ 范围检查 →is_dir()→scandir() - 最小权限原则:Web服务器用户只应有必要目录的读权限
- 记录异常:当检测到路径逃逸尝试时,记录日志供安全审计
给小朋友的比喻
想象你有一个超级安全的糖果罐,里面装着各种颜色的糖果。你想让小朋友自己选糖果,但不能让他们把罐子搬走或者打开别的罐子。
realpath() 就像是一个透明管道,无论小朋友怎么推拉,都能看到糖果最终会滚到哪里。
is_dir() 就像是一个检查员,确认你拿的确实是个罐子,而不是个装满毒药的瓶子。
scandir() 就像是打开罐子看看里面有什么糖果。
strpos($resolvedPath, $allowedRoot) !== 0 就像是确认糖果罐还在你的房间里,没有被搬到隔壁危险区域。
这样,小朋友既能享受选糖果的乐趣,又不会吃到毒药。
最后的话
目录遍历漏洞不是那种“理论上存在”的问题,它是OWASP Top 10里的常客,每年都有大量的网站因此中招。用 scandir() 配合 is_dir() 和 realpath() 构建的白名单机制,虽然不是唯一的安全方案,但绝对是最简单、最有效的基础防护。
下次当你想写 include($_GET['file']) 的时候,记得停下来想想:这个路径最终会指向哪里?它是否真的在你的控制范围内?
安全不是性能的对立面,而是可靠性的基础。一个被利用的漏洞,会让你的整个项目价值归零。
如果你正在维护老旧代码,先从替换所有直接路径拼接开始。如果是新项目,从一开始就建立这个习惯。你的未来自己会感谢现在的谨慎。
