文件上傳是 Web 應用高風險功能點。本文提供一個生產級上傳處理流程:MIME 類型白名單 + 擴展名二次驗證 + 大小限制 + 隨機文件名防覆蓋 + 目錄權限隔離 + 完整錯誤碼處理,並解析常見繞過手法與防禦措施。
<?php
/**
* 安全文件上傳處理
*
* @param array $file $_FILES['field'] 子數組
* @param array $config 配置項
* @return array ['success'=>bool, 'message'=>string, 'path'=>string|null]
*/
function secureUpload(array $file, array $config = []): array
{
// 默認配置
$cfg = array_merge([
'allowedMimes' => [
'image/gif',
'image/jpeg',
'image/png',
'image/webp',
],
'allowedExts' => ['gif', 'jpg', 'jpeg', 'png', 'webp'],
'maxSize' => 2 * 1024 * 1024, // 2MB
'uploadDir' => __DIR__ . '/uploads',
'randomName' => true,
], $config);
// 1. 檢查上傳錯誤碼
if ($file['error'] !== UPLOAD_ERR_OK) {
$msg = match ($file['error']) {
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 => '被 PHP 擴展阻止',
default => '未知上傳錯誤',
};
return ['success' => false, 'message' => $msg, 'path' => null];
}
// 2. 文件大小二次檢查(防範客戶端繞過)
if ($file['size'] > $cfg['maxSize']) {
return ['success' => false, 'message' => '文件體積超過限制(' . ($cfg['maxSize']/1024/1024) . 'MB)', 'path' => null];
}
// 3. 擴展名檢查(從原文件名提取,轉小寫)
$origName = $file['name'];
$ext = strtolower(pathinfo($origName, PATHINFO_EXTENSION));
if (!in_array($ext, $cfg['allowedExts'], true)) {
return ['success' => false, 'message' => "不允許的文件類型:.$ext", 'path' => null];
}
// 4. MIME 類型檢查(使用 finfo,比 $_FILES['type'] 可靠)
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($file['tmp_name']);
if (!in_array($mime, $cfg['allowedMimes'], true)) {
return ['success' => false, 'message' => "檢測到非法文件類型:$mime", 'path' => null];
}
// 5. 可選:圖片二次驗證(getimagesize 確認真實圖片)
if (str_starts_with($mime, 'image/')) {
$imgInfo = @getimagesize($file['tmp_name']);
if ($imgInfo === false) {
return ['success' => false, 'message' => '文件不是有效的圖片', 'path' => null];
}
// 可選:限制圖片尺寸
// if ($imgInfo[0] > 2000 || $imgInfo[1] > 2000) { ... }
}
// 6. 確保上傳目錄存在且不可執行
if (!is_dir($cfg['uploadDir'])) {
@mkdir($cfg['uploadDir'], 0755, true);
}
// 防止腳本執行:.htaccess / nginx 配置禁止 php 執行(此處略)
// 7. 生成安全文件名
if ($cfg['randomName']) {
$safeName = bin2hex(random_bytes(16)) . '.' . $ext; // 32位隨機十六進制
} else {
// 保留原名但過濾危險字符
$base = preg_replace('/[^a-zA-Z0-9._-]/', '_', pathinfo($origName, PATHINFO_FILENAME));
$safeName = $base . '.' . $ext;
}
$destPath = $cfg['uploadDir'] . DIRECTORY_SEPARATOR . $safeName;
// 8. 移動文件(move_uploaded_file 會檢查是否為真實上傳文件)
if (!move_uploaded_file($file['tmp_name'], $destPath)) {
return ['success' => false, 'message' => '文件保存失敗(權限/磁盤空間)', 'path' => null];
}
// 9. 設置文件權限(僅讀/寫,不可執行)
@chmod($destPath, 0644);
return ['success' => true, 'message' => '上傳成功', 'path' => $destPath];
}
// ========== 使用示例 ==========
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$result = secureUpload($_FILES['avatar'] ?? ['error' => UPLOAD_ERR_NO_FILE]);
header('Content-Type: application/json');
echo json_encode($result, JSON_UNESCAPED_UNICODE);
exit;
}
?>
<form method="post" enctype="multipart/form-data">
<input type="file" name="avatar" accept="image/*" required>
<button type="submit">上傳頭像</button>
</form>
| 風險 | 攻擊手法 | 防禦措施 |
|---|---|---|
| 腳本上傳執行 | 上傳 shell.php.jpg、.htaccess、雙擴展名、Apache 解析漏洞 | 1. 隨機文件名 2. 目錄禁用腳本執行 3. MIME+擴展名雙驗證 4. 文件內容檢測 |
| MIME 偽裝 | 修改 Content-Type header、在圖片末尾追加 PHP 代碼 | 使用 finfo 讀取文件魔數、getimagesize 驗證圖片結構 |
| 大小繞過 | 客戶端修改 HTML max-size、攔截請求修改 | 服務端強制檢查 $file['size']、PHP upload_max_filesize/post_max_size |
| 路徑遍歷 | 文件名包含 ../../etc/passwd | 隨機文件名、basename()、嚴格過濾路徑分隔符 |
| 覆蓋關鍵文件 | 上傳同名文件覆蓋配置/腳本 | 隨機文件名、目錄隔離、權限最小化 |
| DoS(磁盤填滿) | 大量並發上傳大文件 | 限制單文件/總大小、頻率限制、異步處理隊列、磁盤配額 |
location /uploads/ {
location ~ \.php$ { return 403; }
}
SetHandler default-handler
php_flag engine off
Options -ExecCGI
AddHandler cgi-script .php .php3 .php4 .php5 .phtml .pl .py .jsp .asp .sh .cgi
Require all denied
clamscan)或雲端 API(VirusTotal)在移動前掃描臨時文件。imagick/gd)。安全文件上傳的核心原則是「不信任任何客戶端數據」:隨機化文件名、雙重類型驗證、目錄執行權限隔離、完整錯誤碼處理。本文代碼已在多個項目中驗證,可直接作為基礎組件接入項目,並根據業務擴展異步、掃毒、雲存儲等能力。