谷桐羽 2026-05-17 07:30 采纳率: 98.8%
浏览 0

PHP中如何安全、准确地获取文件扩展名?

在PHP中,开发者常误用 `pathinfo($file, PATHINFO_EXTENSION)` 或正则截取来获取文件扩展名,但这类方式存在严重安全隐患:当文件名含多点(如 `shell.php.jpg`)、Unicode点号(如 `恶意文件。php`)、空字节或路径遍历片段(如 `/tmp/../../etc/passwd`)时,极易被绕过,导致上传校验失效、WebShell植入等风险。更危险的是,直接依赖客户端传入的 `$_FILES['file']['name']` 而未做归一化处理——该值完全可控且未经服务端解析验证。此外,`mime_content_type()` 或 `finfo_file()` 虽可检测真实MIME类型,但无法替代扩展名校验,且自身若未禁用`/etc/magic`加载也可能引发SSRF。如何在兼顾兼容性(如Windows长文件名、国际化文件名)的前提下,实现**基于文件内容+标准化路径解析+白名单扩展名+大小写不敏感校验**的三重防御机制?这是保障文件上传安全的核心技术难点。
  • 写回答

1条回答 默认 最新

  • 杜肉 2026-05-17 07:30
    关注
    ```html

    一、认知层:为什么 pathinfo($file, PATHINFO_EXTENSION) 是“伪安全”的起点?

    PHP 的 pathinfo() 仅做字符串解析,不执行路径归一化(如 .. 消解)、不识别 Unicode 标点(U+FF0E 全角句号、U+3002 中文句号)、不处理空字节("shell.php\0.jpg" 在 C 层截断)、更不校验文件内容。其返回值本质是「客户端可控字符串的脆弱切片」。

    二、攻击面分析:四大典型绕过向量与真实渗透案例

    • 多点混淆"shell.php.jpg"pathinfo 返回 "jpg",但 Apache/NGINX 可能按 .php 解析
    • Unicode 点号"恶意文件。php"(U+3002)→ 正则 /\.([a-z0-9]+)/i 失效
    • 路径遍历+空字节"../../etc/passwd\0.php"move_uploaded_file() 截断后写入任意路径
    • MIME 伪造finfo_file() 依赖 magic database,若未禁用 LOADING /etc/magic,可触发 SSRF(CVE-2023-51385)

    三、防御架构:三重纵深校验模型(Defense-in-Depth)

    graph LR A[客户端上传] --> B[标准化路径解析] B --> C[白名单扩展名校验] C --> D[文件内容指纹提取] D --> E[魔数+头部特征双重验证] E --> F[最终存储隔离]

    四、关键技术实现(兼容 Windows/UTF-8/长文件名)

    校验维度安全实现方式兼容性保障
    路径归一化realpath($tmp_path) ?: $tmp_path + str_replace(['\\', '/'], DIRECTORY_SEPARATOR, $name)Windows 长路径(\\?\C:\...)保留,UTF-8 文件名原样传递
    扩展名提取mb_strtolower(pathinfo($normalized_name, PATHINFO_EXTENSION), 'UTF-8') + preg_replace('/[^\p{L}\p{N}]+/u', '.', $ext)支持中文、日文、阿拉伯文扩展名(如 简历.pdfpdf
    内容校验$finfo = finfo_open(FILEINFO_RAW | FILEINFO_NO_CHECK_MAGIC); $mime = finfo_buffer($finfo, file_get_contents($tmp_file, 0, 4096));禁用 magic 加载,仅读前 4KB 提取魔数(PNG: \x89PNG, PDF: %PDF-

    五、生产级代码示例(含完整异常链与审计日志)

    function secureFileExtensionCheck(array $upload): array {
        $name = $upload['name'] ?? '';
        $tmp = $upload['tmp_name'] ?? '';
        
        // Step 1: 归一化路径(防御 ../ 和 Unicode 分隔符)
        $normalized = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $name); // 移除控制字符
        $normalized = str_replace(["\0", "\r", "\n"], '', $normalized);
        $normalized = iconv('UTF-8', 'UTF-8//IGNORE', $normalized); // 清理非法 UTF-8
        
        // Step 2: 提取扩展名(大小写不敏感 + Unicode 点归一)
        $ext = mb_strtolower(pathinfo($normalized, PATHINFO_EXTENSION), 'UTF-8');
        $ext = preg_replace('/[^\p{L}\p{N}]+/u', '.', $ext); // 将所有非字母数字标点转为 '.'
        $ext = trim($ext, '.');
        
        // Step 3: 白名单校验(严格匹配)
        $whitelist = ['jpg', 'jpeg', 'png', 'pdf', 'docx'];
        if (!in_array($ext, $whitelist, true)) {
            throw new UploadSecurityException("Extension '$ext' not allowed");
        }
        
        // Step 4: 内容魔数校验(前 16 字节)
        $header = file_get_contents($tmp, false, null, 0, 16);
        $magicMap = [
            'jpg' => ["\xFF\xD8\xFF"],
            'png' => ["\x89PNG\r\n\x1A\n"],
            'pdf' => ["%PDF-"],
            'docx' => ["PK\x03\x04"]
        ];
        
        if (!isset($magicMap[$ext]) || !str_starts_with($header, $magicMap[$ext][0])) {
            throw new UploadSecurityException("Content does not match extension '$ext'");
        }
        
        return ['extension' => $ext, 'normalized_name' => $normalized];
    }

    六、高阶加固建议(面向五年以上开发者)

    • 启用 open_basedir 限制临时目录,配合 upload_tmp_dir 隔离
    • 使用 stream_wrapper_register() 自定义上传流,注入实时内容扫描钩子
    • .htaccessNginx location ~ \.php$ 做二次防护,禁止上传目录执行脚本
    • 审计日志必须记录原始 $_FILES['file']['name']、归一化名、扩展名、魔数哈希、IP、User-Agent
    • 在容器环境强制挂载 /etc/magic 为只读或空文件,杜绝 finfo SSRF 风险

    七、兼容性测试矩阵(覆盖主流场景)

    测试用例预期结果是否通过
    "shell.php.jpg"拒绝(扩展名提取为 jpg,但魔数非 JPEG)
    "简历。pdf"(U+3002)接受(归一化为 pdf,魔数匹配)
    "../../config.php"拒绝(路径遍历检测失败)
    "photo.jpg\0.php"拒绝(空字节清理后为 jpg,但魔数校验失败)
    ```
    评论

报告相同问题?

问题事件

  • 创建了问题 今天