首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
您现在的位置: 程式碼之家 >> 編程語言 >  >> 網頁程式設計 >> PHP 程式設計 >> PHP 相關知識

Web 文件上傳漏洞系列(三):安全文件上傳完整實現代碼與防禦深度

     編輯:PHP 相關知識

承接前兩篇漏洞分析,本文給出生產級安全上傳完整實現:隨機文件名、MIME/魔數雙重驗證、圖片二次渲染、存儲目錄權限隔離、Web 服務器解析禁用、錯誤碼處理、大小限制、日志記錄。

核心防禦策略回顧

層級措施關鍵點
應用層隨機文件名 + 白名單後綴消除文件名注入、雙重擴展名風險
應用層MIME 類型 + 魔數 (fileinfo) 驗證防偽裝文件,不信任 $_FILES['type']
應用層圖片二次渲染 (GD/Imagick)破壞圖片數據段隱藏的 Shell 代碼
存儲層上傳目錄掛載 noexec,nodev,nosuid系統級禁止執行權限
Web服務器上傳目錄禁用腳本解析即使寫入 PHP 也無法被解析執行

完整上傳處理類 (PHP 7.4+)

<?php
declare(strict_types=1);

class SecureUploader {
    private string $saveDir;
    private array $allowedExt;
    private array $allowedMime;
    private int $maxSize;
    private string $urlPrefix;

    public function __construct(
        string $saveDir,
        string $urlPrefix = '/uploads/',
        array $allowedExt = ['jpg', 'jpeg', 'png', 'gif', 'webp'],
        array $allowedMime = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
        int $maxSize = 5 * 1024 * 1024 // 5MB
    ) {
        $this->saveDir = rtrim($saveDir, '/') . '/';
        $this->urlPrefix = rtrim($urlPrefix, '/') . '/';
        $this->allowedExt = array_map('strtolower', $allowedExt);
        $this->allowedMime = $allowedMime;
        $this->maxSize = $maxSize;

        if (!is_dir($this->saveDir)) {
            mkdir($this->saveDir, 0750, true); // 僅擁有者可寫
        }
    }

    public function upload(array $file): array {
        // 1. 錯誤碼檢查
        if ($file['error'] !== UPLOAD_ERR_OK) {
            return $this->err($this->errorMsg($file['error']));
        }

        // 2. 大小限制
        if ($file['size'] > $this->maxSize) {
            return $this->err('文件大小超過限制 (' . $this->formatSize($this->maxSize) . ')');
        }

        // 3. 臨時文件存在性
        if (!is_file($file['tmp_name']) || !is_readable($file['tmp_name'])) {
            return $this->err('臨時文件不可讀');
        }

        // 4. 後綴白名單 (從原始名提取,僅作參考)
        $origName = basename($file['name']);
        $ext = strtolower(pathinfo($origName, PATHINFO_EXTENSION) ?? '');
        if ($ext === '' || !in_array($ext, $this->allowedExt, true)) {
            return $this->err('不允許的文件後綴');
        }

        // 5. MIME + 魔數雙重驗證 (核心!)
        $finfo = finfo_open(FILEINFO_MIME_TYPE);
        $mime = finfo_file($finfo, $file['tmp_name']);
        finfo_close($finfo);
        if (!in_array($mime, $this->allowedMime, true)) {
            return $this->err('文件內容類型不合法 (檢測: ' . $mime . ')');
        }

        // 6. 圖片二次渲染 (可選但強烈建議)
        if (str_starts_with($mime, 'image/')) {
            $rendered = $this->rerenderImage($file['tmp_name'], $mime);
            if ($rendered === false) {
                return $this->err('圖片二次渲染失敗,疑似異常文件');
            }
            $file['tmp_name'] = $rendered; // 使用渲染後的臨時文件
        }

        // 7. 生成隨機文件名
        $newName = bin2hex(random_bytes(16)) . '.' . $ext; // 32字符十六進制
        $destPath = $this->saveDir . $newName;

        // 8. 移動文件 (原子操作)
        if (!move_uploaded_file($file['tmp_name'], $destPath)) {
            return $this->err('文件保存失敗 (權限/磁盤空間?)');
        }

        // 9. 設置文件權限 (僅讀)
        chmod($destPath, 0640);

        // 10. 記錄日志
        $this->log($origName, $newName, $mime, $file['size']);

        return [
            'success' => true,
            'filename' => $newName,
            'path' => $destPath,
            'url' => $this->urlPrefix . $newName,
            'mime' => $mime,
            'size' => $file['size'],
        ];
    }

    private function rerenderImage(string $srcPath, string $mime): string|false {
        $tmp = sys_get_temp_dir() . '/rerender_' . bin2hex(random_bytes(8));
        $result = false;
        switch ($mime) {
            case 'image/jpeg':
                $img = @imagecreatefromjpeg($srcPath);
                if ($img) $result = imagejpeg($img, $tmp, 90);
                break;
            case 'image/png':
                $img = @imagecreatefrompng($srcPath);
                if ($img) $result = imagepng($img, $tmp, 6);
                break;
            case 'image/gif':
                $img = @imagecreatefromgif($srcPath);
                if ($img) $result = imagegif($img, $tmp);
                break;
            case 'image/webp':
                $img = @imagecreatefromwebp($srcPath);
                if ($img) $result = imagewebp($img, $tmp, 90);
                break;
        }
        if (isset($img)) imagedestroy($img);
        return $result ? $tmp : false;
    }

    private function errorMsg(int $code): string {
        return match ($code) {
            UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => '文件超過大小限制',
            UPLOAD_ERR_PARTIAL => '文件僅部分上傳',
            UPLOAD_ERR_NO_FILE => '未選擇文件',
            UPLOAD_ERR_NO_TMP_DIR => '缺少臨時目錄',
            UPLOAD_ERR_CANT_WRITE => '寫入磁盤失敗',
            UPLOAD_ERR_EXTENSION => '被擴展阻止',
            default => '未知上傳錯誤',
        };
    }

    private function formatSize(int $bytes): string {
        $units = ['B', 'KB', 'MB', 'GB'];
        $i = 0;
        while ($bytes >= 1024 && $i < count($units)-1) { $bytes /= 1024; $i++; }
        return round($bytes, 2) . ' ' . $units[$i];
    }

    private function err(string $msg): array {
        error_log('[UPLOAD] ' . $msg);
        return ['success' => false, 'msg' => $msg];
    }

    private function log(string $orig, string $new, string $mime, int $size): void {
        error_log(sprintf('[UPLOAD] OK | orig=%s | new=%s | mime=%s | size=%s', $orig, $new, $mime, $this->formatSize($size)));
    }
}

// ===== 使用示例 =====
$uploader = new SecureUploader(
    saveDir: '/var/www/myapp/uploads/', // 必須不可 Web 直接訪問,或配置 Nginx 禁用解析
    urlPrefix: '/uploads/'
);

if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['avatar'])) {
    $result = $uploader->upload($_FILES['avatar']);
    header('Content-Type: application/json');
    echo json_encode($result);
    exit;
}
?>

Nginx / Apache 禁用上傳目錄腳本解析 (必做!)

# Nginx
location /uploads/ {
    # 禁止所有腳本執行
    location ~ \.php$ { return 403; }
    # 或直接禁用 PHP 引擎
    # fastcgi_pass ""; # 置空
}

# Apache (.htaccess 或 httpd.conf)

    SetHandler default-handler
    # 或
    
        Require all denied
    
    Options -ExecCGI
    AddHandler cgi-script .php .phtml .php3 .php4 .php5 .php7 .phps

Linux 掛載參數加固 (存儲層)

/etc/fstab:
/dev/sdb1  /var/www/myapp/uploads  ext4  defaults,noexec,nodev,nosuid  0  2
# noexec: 禁止執行二進制文件/腳本
# nodev:  禁止設備文件
# nosuid: 忽略 SUID/SGID 位

常見問題 FAQ

問題解答
上傳大文件超時/失敗調大 `upload_max_filesize`、`post_max_size`、`max_execution_time`、`max_input_time`;Nginx `client_max_body_size`;考慮分片上傳。
finfo 檢測不準確保安裝 `file` 命令與 `magic.mgc` 魔數庫;極少數異常文件可能誤判,二次渲染可補償。
二次渲染丟失 EXIF/透明度業務允許則保留原圖(存非 Web 目錄),前端顯示用渲染版;或用 Imagick 保留更多元數據。
Windows 無 noexec 掛載依賴 Web 服務器禁用解析 + 應用層隨機名 + 非 Web 根目錄存儲三重保障。
  1. 上一篇文章:
  2. 下一篇文章:
Copyright © 程式碼之家 All Rights Reserved