承接前兩篇漏洞分析,本文給出生產級安全上傳完整實現:隨機文件名、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 根目錄存儲三重保障。 |