SecLab
Server-sideĐầy đủ

File upload vulnerabilities

V5CWE-434
01

Là gì

Lỗ hổng upload file là khi server tin những gì client nói về file: tên file, phần mở rộng, Content-Type. Cả ba đều do kẻ tấn công đặt. Hậu quả xấu nhất là thực thi code: một file .aspx/.php/.jsp nằm trong thư mục mà web server chịu chạy, rồi được gọi qua URL.

02

Vì sao bạn quan tâm

Mức liên quan: Bắt buộcKỳ vọng: L2

Upload file là chỗ nhiều họ lỗ hổng gặp nhau, và đó là điều làm nó khó vá hơn vẻ ngoài. Một ô upload avatar mở ra cùng lúc:

  • RCE — file thực thi được đặt trong thư mục web server chịu chạy
  • Path traversal — tên file ../../app/appsettings.json ghi đè cấu hình (xem topic path-traversal)
  • XSS lưu trữ — một .svg hay .html chứa <script>, phục vụ cùng origin (xem topic xss)
  • XXE.svg là XML; .docx là ZIP chứa XML (xem topic xxe)
  • DoS — zip bomb, file 10GB, ảnh 50000×50000 pixel làm hết RAM khi resize
  • Antivirus bypass / lưu trữ malware — hạ tầng của bạn thành nơi phát tán

Nghĩa là không có một bản vá nào: vá được RCE không vá được XSS lưu trữ, và ngược lại. Đây là topic mà danh sách phòng chống dài không phải vì thừa mà vì các mối đe doạ thật sự khác nhau.

Một điểm nữa hay bị bỏ: kiểm ở client là không kiểm gì cả. accept=".jpg" trên <input> là gợi ý cho hộp thoại chọn file, không phải một ràng buộc — request gửi bằng curl không thấy nó.

03

Cơ chế hoạt động

Cơ chế RCE cần hai điều cùng đúng, và bản vá tốt nhất là phá điều kiện thứ hai — vì điều kiện thứ nhất bạn không bao giờ chặn hết được.

Nguồn sơ đồ
flowchart TD    U["POST /api/avatar<br/>filename=x.aspx<br/>Content-Type: image/png"] --> V{Server kiểm gì?}    V -->|"Tin Content-Type<br/>(client đặt)"| S1["Lưu /wwwroot/uploads/x.aspx"]    V -->|"Sinh tên mới + lưu ngoài webroot"| S2["Lưu /var/data/blob/a1b2c3<br/>(không phần mở rộng)"]    S1 --> R["GET /uploads/x.aspx<br/>→ IIS THẤY .aspx → chạy nó"]    R --> B["🔓 RCE"]    S2 --> D["GET /api/files/{id}<br/>→ app đọc blob, đặt header, trả bytes"]    D --> G["✅ Không có đường nào để web server chạy nó"]

Điểm cốt lõi: .aspx chỉ nguy hiểm vì nó nằm ở nơi có thứ chịu chạy nó. Cùng file đó trong /var/data/blob/ là một khối byte vô hại. Nên bản vá mạnh nhất không phải "chặn phần mở rộng nguy hiểm" — mà là bỏ khả năng thực thi khỏi nơi lưu.

Các cách vòng qua một bộ lọc phần mở rộng — bảng này là lý do blocklist thua:

CáchVòng qua cái gì
x.pHp, x.aspXSo sánh phân biệt chữ hoa/thường
x.php.jpgKiểm phần mở rộng ĐẦU tiên (Apache cũ chạy nó)
x.jpg.phpKiểm phần mở rộng bằng Contains(".jpg")
x.php5, .phtml, .asp, .ashx, .cshtmlBlocklist chưa đủ dài
x.php%00.jpgNull byte (parser cũ cắt tại %00)
x. / x.php.Windows bỏ dấu chấm cuối
.htaccess / web.configKhông phải file thực thi — nó cấu hình để file khác thực thi
Magic byte đúng + payload sauGIF89a;<?php …?> là GIF hợp lệ là PHP hợp lệ

Hàng cuối đáng nói riêng: kiểm magic byte không đủ để cho phép thực thi. Nó chứng minh file bắt đầu bằng header của một ảnh, không chứng minh phần còn lại là ảnh.

Mô tả sơ đồ: Sơ đồ nhánh: một POST upload với filename x.aspx và Content-Type image/png. Nhánh sai tin Content-Type do client đặt và lưu file thành /wwwroot/uploads/x.aspx, nên khi gọi GET /uploads/x.aspx thì IIS thấy phần mở rộng .aspx và chạy nó, thành RCE. Nhánh đúng sinh tên mới không phần mở rộng và lưu ngoài webroot vào /var/data/blob, nên file chỉ tới được người dùng qua một endpoint của app đọc blob và tự đặt header — không có gì trong đường đi đó chạy được file.

04

Ví dụ cụ thể

Ô upload avatar. Ba request, ba họ lỗ hổng khác nhau qua cùng một endpoint.

HTTP
# 1 — RCE. Content-Type là lời khai của client, và nó nói dối.POST /api/profile/avatar HTTP/1.1Content-Type: multipart/form-data; boundary=x --xContent-Disposition: form-data; name="file"; filename="shell.aspx"Content-Type: image/png <%@ Page Language="C#" %><% Response.Write(new System.Diagnostics.Process…) %>--x--
HTTP
HTTP/1.1 200 OK{"url":"/uploads/shell.aspx"}      ← file đã ở trong webroot GET /uploads/shell.aspx?cmd=id → uid=0(root)
HTTP
# 2 — XSS lưu trữ qua SVG. Magic byte hợp lệ, phần mở rộng hợp lệ, và vẫn là XSS.filename="avatar.svg"   Content-Type: image/svg+xml <svg xmlns="http://www.w3.org/2000/svg" onload="fetch('/api/keys',{method:'POST'})"/>
HTTP
# 3 — Path traversal dạng GHI. Không cần thực thi gì, chỉ cần ghi đè.filename="../../app/appsettings.Production.json"

Ba payload, ba bản vá khác nhau. Đó là toàn bộ lý do khối 6 có năm lớp.

C#Ba lỗi: tin Content-Type, giữ tên client, và lưu vào webroot.
[HttpPost("/api/profile/avatar")]public async Task<IActionResult> Avatar(IFormFile file, CancellationToken ct){    // ❌ 1 — ContentType tới từ header multipart, do KẺ TẤN CÔNG đặt. Một file    //        shell.aspx gửi kèm "Content-Type: image/png" đi qua dòng này.    if (!file.ContentType.StartsWith("image/"))        return BadRequest("Images only");     // ❌ 2 — FileName cũng của client. Nó mang cả phần mở rộng nguy hiểm    //        (.aspx) lẫn path traversal (../../app/appsettings.json).    var name = file.FileName;     // ❌ 3 — wwwroot LÀ webroot: IIS/Kestrel phục vụ trực tiếp thư mục này, và    //        nó THẤY phần mở rộng .aspx. Đây là dòng biến upload thành RCE.    var path = Path.Combine(_env.WebRootPath, "uploads", name);     await using var stream = File.Create(path);    await file.CopyToAsync(stream, ct);     return Ok(new { url = $"/uploads/{name}" });}
Python`secure_filename` chặn traversal nhưng GIỮ phần mở rộng — và đó là toàn bộ vấn đề.
from werkzeug.utils import secure_filename  @app.post("/api/profile/avatar")def avatar():    f = request.files["file"]     # ❌ secure_filename làm ĐÚNG việc của nó — bỏ "../" — và người ta hay dừng ở đây.    #    Nó KHÔNG bỏ phần mở rộng: "shell.php" đi ra vẫn là "shell.php".    #    Cộng với static folder bên dưới, đó là RCE.    name = secure_filename(f.filename)     f.save(os.path.join(app.static_folder, "uploads", name))     return {"url": f"/static/uploads/{name}"}
05

Chuyện đã xảy ra

CVE-2021-22986 / họ lỗi web shell qua upload. Mẫu lặp lại trong hàng loạt sự cố ransomware 2019–2023: kẻ tấn công upload một web shell .aspx vào thư mục do web server phục vụ, rồi dùng nó làm điểm tựa. Báo cáo của CISA về nhiều nhóm ransomware đều mô tả đúng bước này. Điều đáng học: hầu hết các trường hợp KHÔNG cần vòng qua bộ lọc phần mở rộng nào — không có bộ lọc nào cả, hoặc file được lưu thẳng vào webroot.

GitLab CVE-2021-22205 — CVSS 10.0, bị khai thác hàng loạt. GitLab truyền ảnh upload cho ExifTool để đọc metadata, và ExifTool có lỗi parse định dạng DjVu. Kết quả là RCE không cần xác thực. Đáng nhớ vì bản vá kiểu "chỉ cho ảnh" không cứu được: file đúng là một ảnh, và lỗi nằm ở thứ đọc ảnh. Đây là lý do khối 6 có lớp "cách ly việc xử lý file".

06

Cách phòng chống

Lớp 1

Lưu ngoài webroot, sinh tên mới, phục vụ qua endpoint của app

bắt buộc

Đây là bản vá quan trọng nhất, và nó đóng bốn họ lỗ hổng cùng lúc — RCE, path traversal, liệt kê file, và ghi đè file. Lý do nó mạnh: nó không cố đoán file nào nguy hiểm, nó bỏ đi khả năng nguy hiểm.

Ba việc, và cả ba đều cần:

  1. Sinh tên do server đặt — một UUIDv7, không phần mở rộng. Tên file của client được lưu trong DB như metadata (để đặt Content-Disposition lúc tải về), không bao giờ chạm tới filesystem. Điều này xoá sạch path traversal: không có chuỗi nào của người dùng đi vào đường dẫn.
  2. Lưu ngoài thư mục web server phục vụ/var/data/blob/, hoặc tốt hơn là object storage (S3/R2) với bucket không public. Nếu không có gì phục vụ trực tiếp thư mục đó thì không có URL nào để gọi file, và .aspx chỉ là byte.
  3. Phục vụ qua một endpoint đọc blob theo id, kiểm quyền, rồi đặt header của chúng ta. Đây cũng là chỗ duy nhất áp được phân quyền — một file trong webroot thì ai có URL cũng đọc được.
C# · Layer 1Tên do server sinh, lưu ngoài webroot, decode thật rồi re-encode, ba trần.
/// <summary>/// Bốn quyết định, và mỗi cái đóng một họ lỗ hổng khác nhau://////   1. Tên do SERVER sinh (UUIDv7, không phần mở rộng) → xoá path traversal và///      xoá luôn khả năng ghi đè file có sẵn. Tên của client vào DB làm metadata.///   2. Lưu NGOÀI webroot → không có URL nào để web server chạy file.///   3. DECODE thật rồi RE-ENCODE → file lưu là file ta tạo, không phải file kẻ///      tấn công gửi. Đây là bước xoá polyglot và payload trong EXIF.///   4. Ba trần (kích thước, pixel, timeout) → DoS không cần lỗ hổng nào./// </summary>public sealed class AvatarService{    // Allowlist, không blocklist. Bảng ở khối 3 cho thấy blocklist không bao giờ đủ dài.    private static readonly Dictionary<string, byte[]> MagicBytes = new()    {        ["image/png"]  = [0x89, 0x50, 0x4E, 0x47],        ["image/jpeg"] = [0xFF, 0xD8, 0xFF],        ["image/webp"] = [0x52, 0x49, 0x46, 0x46],   // "RIFF"    };     private const long MaxBytes = 5L * 1024 * 1024;    private const int MaxPixels = 8000 * 8000;        // trần cho decompression bomb    private const int OutputSize = 256;     private readonly IBlobStore _blobs;              // /var/data/blob hoặc R2, KHÔNG phải wwwroot     public AvatarService(IBlobStore blobs) => _blobs = blobs;     public async Task<Guid> StoreAsync(IFormFile file, CancellationToken ct)    {        if (file.Length is 0 or > MaxBytes)            throw new ApplicationGeneralException(ContentErrorsList.INVALID_SOURCE, "File too large");         await using var input = file.OpenReadStream();         // Magic byte: kiểm nội dung, KHÔNG kiểm ContentType của client. Đây chỉ là        // bước chặn sớm cho thông báo lỗi tử tế — bước thật sự chứng minh là decode.        var head = new byte[8];        await input.ReadExactlyAsync(head.AsMemory(0, 8), ct);        input.Position = 0;         if (!MagicBytes.Any(m => head.Take(m.Value.Length).SequenceEqual(m.Value)))            throw new ApplicationGeneralException(ContentErrorsList.INVALID_SOURCE, "Not a supported image");         using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);        cts.CancelAfter(TimeSpan.FromSeconds(10));   // decoder chạy lâu cũng là DoS         // Đọc HEADER trước để biết số pixel, TRƯỚC KHI decode toàn bộ. Một PNG        // 50000×50000 nặng 40KB trên đĩa và cần ~10GB RAM khi decode — trần kích        // thước ở trên cho nó đi qua, và trần này là thứ chặn nó.        var info = await Image.IdentifyAsync(input, cts.Token);        if ((long)info.Width * info.Height > MaxPixels)            throw new ApplicationGeneralException(ContentErrorsList.INVALID_SOURCE, "Image dimensions too large");        input.Position = 0;         // DECODE THẬT. Đây là bước phân biệt "file bắt đầu bằng GIF89a" với "file        // là một ảnh": GIF89a;<?php …?> qua được magic byte và chết ở đây.        using var image = await Image.LoadAsync(input, cts.Token);         image.Mutate(c => c.Resize(new ResizeOptions        {            Size = new Size(OutputSize, OutputSize),            Mode = ResizeMode.Crop,        }));         // RE-ENCODE và bỏ bản gốc. Bước mạnh nhất và hay bị bỏ: mọi thứ không phải        // pixel biến mất — payload trong EXIF, polyglot, <script> trong SVG. File        // lưu xuống là file CHÚNG TA tạo.        var output = new MemoryStream();        await image.SaveAsPngAsync(output, cts.Token);        output.Position = 0;         // Tên do server sinh, KHÔNG phần mở rộng. Không có ký tự nào của client đi        // vào đường dẫn, nên path traversal không có chỗ tồn tại.        var id = UuidV7.NewGuid();        await _blobs.PutAsync($"avatar/{id:N}", output, "image/png", ct);         // Tên của client được lưu ở đây — như DỮ LIỆU, để đặt Content-Disposition        // lúc tải về. Nó không bao giờ chạm tới filesystem.        await _avatars.RecordAsync(id, originalName: file.FileName, "image/png", ct);         return id;    }} // ── Phục vụ file: header do CHÚNG TA đặt ─────────────────────────────────────[HttpGet("/api/files/{id:guid}")]public async Task<IActionResult> Get(Guid id, CancellationToken ct){    var meta = await _avatars.FindAsync(id, ct);    if (meta is null) throw new NotFoundException(ContentErrorsList.FILE_NOT_FOUND);     // nosniff: không có nó, trình duyệt có thể tự đoán kiểu và đoán ra text/html.    Response.Headers["X-Content-Type-Options"] = "nosniff";    // sandbox: kể cả khi có gì lọt qua, nó chạy trong một origin rỗng.    Response.Headers["Content-Security-Policy"] = "sandbox; default-src 'none'";     var stream = await _blobs.GetAsync($"avatar/{id:N}", ct);     // ContentType từ KẾT QUẢ KIỂM của ta, không copy lại từ request lúc upload.    return File(stream, meta.ValidatedContentType);}
Python · Layer 1Cùng bốn nguyên tắc: id do server sinh, ngoài static, verify + re-encode, trần pixel.
import ioimport uuidfrom pathlib import Path from PIL import Image # NGOÀI static_folder. Flask không phục vụ thư mục này, nên không có URL nào tới# được file trực tiếp — và không có gì chạy được nó.BLOB_DIR = Path("/var/data/blob") MAX_BYTES = 5 * 1024 * 1024MAX_PIXELS = 8000 * 8000OUTPUT_SIZE = 256 ALLOWED = {"PNG", "JPEG", "WEBP"}   # định dạng Pillow NHẬN DIỆN, không phải phần mở rộng  class UnsafeUpload(Exception):    pass  def store_avatar(stream, original_name: str) -> str:    data = stream.read(MAX_BYTES + 1)    if len(data) > MAX_BYTES:        raise UnsafeUpload("file too large")     # Pillow có trần pixel riêng và nó ném DecompressionBombError — nhưng đặt tường    # minh vì mặc định của nó đổi giữa các phiên bản, và một trần ta không kiểm soát    # là một trần ta không biết là bao nhiêu.    Image.MAX_IMAGE_PIXELS = MAX_PIXELS     try:        # verify() đọc header và kiểm tính toàn vẹn — đây là bước giết polyglot:        # "GIF89a;<?php …?>" qua được magic byte và chết ở đây.        probe = Image.open(io.BytesIO(data))        probe.verify()        if probe.format not in ALLOWED:            raise UnsafeUpload(f"format not allowed: {probe.format}")         # verify() làm file object không dùng lại được — mở lần hai để decode thật.        img = Image.open(io.BytesIO(data))        img = img.convert("RGB")        img.thumbnail((OUTPUT_SIZE, OUTPUT_SIZE))    except (Image.DecompressionBombError, Image.UnidentifiedImageError, OSError) as e:        raise UnsafeUpload("not a supported image") from e     # RE-ENCODE: mọi thứ không phải pixel biến mất. File lưu là file TA tạo.    out = io.BytesIO()    img.save(out, format="PNG")     # Tên do server sinh, không phần mở rộng. Tên của client vào DB làm metadata.    blob_id = uuid.uuid4().hex    (BLOB_DIR / blob_id).write_bytes(out.getvalue())    record_avatar(blob_id, original_name=original_name, content_type="image/png")     return blob_id
Lớp 1b

Allowlist loại file, kiểm bằng nội dung — không bằng lời khai của client

bắt buộc

Content-Type trong multipart và phần mở rộng trong filename đều do kẻ tấn công đặt. Cả hai là gợi ý, không phải bằng chứng.

Cách kiểm đúng, theo thứ tự:

  1. Allowlist, không blocklist. { image/png, image/jpeg, image/webp } — bảng ở khối 3 cho thấy blocklist không bao giờ đủ dài.
  2. Kiểm magic byte ở đầu file, và đối chiếu với allowlist. Không tin Content-Type.
  3. Giải mã thật sự bằng thư viện ảnh (ImageSharp, Pillow). Đây là bước phân biệt "file bắt đầu bằng GIF89a" với "file là một ảnh": GIF89a;<?php …?> qua được bước 2, không qua được bước 3.
  4. Re-encode và bỏ file gốc. Đây là bước mạnh nhất và hay bị bỏ: decode rồi encode lại thành PNG xoá mọi thứ không phải pixel — payload trong metadata EXIF, polyglot, <script> trong SVG. File ra là file chúng ta tạo, không phải file kẻ tấn công gửi.

SVG là ngoại lệ và phải xử riêng: nó là XML nên nó mang cả XXE lẫn XSS. Chuyển sang PNG rồi bỏ bản gốc; nếu buộc phải giữ SVG thì parse qua SafeXml (xem topic xxe) và phục vụ từ một origin riêng.

Lớp 1c

Trần kích thước, trần số pixel, timeout — trước khi đọc byte nào

bắt buộc

DoS ở đây không cần lỗ hổng nào, chỉ cần một file lớn. Ba trần, và mỗi trần chặn một thứ khác:

  • Kích thước request, cưỡng chế ở tầng framework/proxy chứ không trong handler: RequestSizeLimit trong ASP.NET Core, client_max_body_size trong nginx. Kiểm trong handler là kiểm sau khi đã nhận hết vào RAM hoặc đĩa.
  • Số pixel (width × height), kiểm sau khi đọc header ảnh và trước khi decode. Một PNG 50000×50000 nặng 40KB trên đĩa và cần ~10GB RAM khi resize — đây là "decompression bomb", và trần kích thước file không chặn được nó.
  • Timeout cho toàn bộ bước xử lý. Một ảnh dựng riêng để làm thư viện decode chạy lâu là DoS mà cả hai trần trên đều cho qua.

Với archive: trần tổng kích thước sau khi giải nén và số entry — xem topic path-traversal cho phần zip bomb và Zip Slip.

Lớp 2

Cách ly việc xử lý file, và bỏ khả năng thực thi khỏi nơi lưu

Đây là lớp bắt đúng bài học của GitLab CVE-2021-22205: file đúng là một ảnh, và lỗi nằm ở thư viện đọc ảnh. Không có phép kiểm định dạng nào chặn được nó.

  • Chạy bước decode/convert trong một process hoặc container riêng, không có credential, không có egress, readOnlyRootFilesystem, seccomp mặc định. Nếu ExifTool bị khai thác ở đó thì nó khai thác được một sandbox rỗng.
  • Bỏ khả năng thực thi khỏi nơi lưu, kể cả khi đã lưu ngoài webroot — phòng cho ngày có người thêm một static file handler mới. Mount volume với noexec; trong nginx đặt location /uploads { } không có handler nào cho script; trên IIS bỏ toàn bộ handlers cho thư mục đó.
  • Quét malware nếu file được người khác tải về. Không phải để bảo vệ bạn, mà để hạ tầng của bạn không trở thành nơi phát tán.
Lớp 3

Header đúng khi phục vụ, và origin riêng cho nội dung do người dùng gửi

Lớp này đóng XSS lưu trữ — thứ mà cả bốn lớp trên không chạm tới nếu file được giữ nguyên định dạng.

  • Content-Type do chúng ta đặt từ kết quả kiểm, không phải copy lại từ request. Cộng X-Content-Type-Options: nosniff — không có nó, trình duyệt có thể tự đoán và đoán ra text/html.
  • Content-Disposition: attachment cho mọi loại không cần hiển thị inline. Trình duyệt tải về thay vì render, nên <script> bên trong không chạy.
  • Origin riêng cho nội dung người dùng — usercontent.example.com, không phải example.com/uploads. Đây là biện pháp duy nhất trong danh sách thật sự cách ly XSS lưu trữ: một <script> chạy trên origin đó không đọc được cookie hay localStorage của app. Google và GitHub đều làm đúng thế này.
  • Content-Security-Policy: sandbox trên response phục vụ file cũng thu hẹp thêm.
07

Kiểm chứng đã vá

1. Test đơn vị lấy BẢNG CÁCH VÒNG ở khối 3 làm dữ liệu vào, cộng polyglot và decompression bomb. Khẳng định đúng thứ cần: tên file lưu trên đĩa không chứa gì của client, và file polyglot bị từ chối ở bước decode. Xem tab csharp / test.

2. Chứng minh không có URL nào chạy được file đã upload — phép kiểm quan trọng nhất, và nó là một phép kiểm end-to-end, không phải unit test:

Shell
B=https://staging.example.comprintf '<%%@ Page Language="C#" %%><%% Response.Write("PWNED"); %%>' > shell.aspxID=$(curl -s -F "file=@shell.aspx;type=image/png" $B/api/profile/avatar | jq -r .id) # Upload PHẢI bị từ chối (không decode được thành ảnh):test -z "$ID" || { echo "file không phải ảnh vẫn được nhận"; exit 1; } # Và mọi đường đoán được PHẢI là 404, không phải 200 có chữ PWNED:for p in /uploads/shell.aspx /files/shell.aspx /wwwroot/uploads/shell.aspx; do  curl -s "$B$p" | grep -q PWNED && { echo "chạy được ở $p"; exit 1; }doneexit 0

3. Kiểm header khi phục vụ file (lớp 3):

Shell
H=$(curl -sI "$B/api/files/$ID" | tr -d '\r')echo "$H" | grep -qi 'x-content-type-options: *nosniff' || { echo "thiếu nosniff"; exit 1; }echo "$H" | grep -qi 'content-type: *image/'             || { echo "Content-Type không phải ảnh"; exit 1; }# Và Content-Type PHẢI khớp kết quả kiểm, không khớp lời khai lúc upload:echo "$H" | grep -qi 'content-type: *text/html'          && { echo "phục vụ như HTML"; exit 1; }exit 0

4. Test decompression bomb. Dựng một PNG 50000×50000 (nặng ~40KB) và khẳng định API từ chối trước khi decode, không phải chết vì hết RAM:

Shell
python3 -c "from PIL import Image; Image.new('RGB',(50000,50000)).save('bomb.png')" 2>/dev/null \  || echo "(cần Pillow — hoặc lấy file mẫu từ tests/fixtures)"curl -s -o /dev/null -w '%{http_code}\n' -F "file=@bomb.png" $B/api/profile/avatar  # phải là 4xx

5. Kiểm nơi lưu không thực thi được (lớp 2):

Shell
docker exec app sh -c 'mount | grep /var/data/blob' | grep -q noexec \  || echo "CẢNH BÁO: volume lưu file không có noexec"
C#Bảng cách vòng ở khối 3 là dữ liệu test, cộng polyglot và bomb.
public class AvatarServiceTests{    /// <summary>    /// Mỗi dòng là một hàng của bảng cách vòng ở khối 3. Khẳng định là service TỪ CHỐI —    /// và quan trọng hơn: nó từ chối vì file không decode được thành ảnh, không vì tên    /// nó khớp một blocklist nào. Đổi bản vá sang blocklist thì test polyglot bên dưới đỏ.    /// </summary>    [Theory]    [InlineData("shell.aspx",     "image/png")]    [InlineData("shell.pHp",      "image/png")]    [InlineData("shell.php.jpg",  "image/jpeg")]    [InlineData("shell.jpg.php",  "image/jpeg")]    [InlineData("shell.phtml",    "image/png")]    [InlineData(".htaccess",      "image/png")]    [InlineData("web.config",     "image/png")]    [InlineData("x.php\u0000.jpg", "image/jpeg")]    public async Task Rejects_non_image_regardless_of_name_or_content_type(string name, string contentType)    {        var file = FormFile(name, contentType, "<%@ Page Language=\"C#\" %>");         await Assert.ThrowsAsync<ApplicationGeneralException>(() => _svc.StoreAsync(file, default));    }     /// <summary>    /// POLYGLOT — test quan trọng nhất của bộ này.    ///    /// "GIF89a;" là magic byte GIF hợp lệ, nên file này qua được MỌI bản vá kiểu    /// "kiểm magic byte". Nó chỉ chết ở bước DECODE THẬT, nên test này là thứ duy nhất    /// phân biệt được "kiểm header" với "chứng minh là ảnh".    /// </summary>    [Fact]    public async Task Rejects_polyglot_with_valid_magic_bytes()    {        var polyglot = "GIF89a;<?php system($_GET['c']); ?>";        var file = FormFile("avatar.gif", "image/gif", polyglot);         await Assert.ThrowsAsync<ApplicationGeneralException>(() => _svc.StoreAsync(file, default));    }     /// <summary>    /// Decompression bomb: 40KB trên đĩa, ~10GB RAM khi decode. Trần KÍCH THƯỚC cho    /// nó đi qua — chỉ trần PIXEL chặn được, và trần đó phải kiểm sau Identify() và    /// TRƯỚC Load(). Bỏ dòng MaxPixels ra thì test này không đỏ, nó làm CI hết RAM.    /// </summary>    [Fact]    public async Task Rejects_decompression_bomb()    {        var bomb = TestImages.SolidPng(50_000, 50_000);   // ~40KB        Assert.True(bomb.Length < 100_000, "bomb phải NHỎ, đó là điểm của nó");         var ex = await Assert.ThrowsAsync<ApplicationGeneralException>(            () => _svc.StoreAsync(FormFile("bomb.png", "image/png", bomb), default));         Assert.Contains("dimensions", ex.Message);    }     /// <summary>    /// Không một ký tự nào của client đi vào đường dẫn lưu. Đây là khẳng định đóng    /// path traversal dạng GHI, và nó kiểm ĐƯỜNG DẪN chứ không kiểm tên đã sanitize —    /// một bản vá "làm sạch tên" cũng pass nếu chỉ kiểm tên.    /// </summary>    [Fact]    public async Task Stored_key_contains_nothing_from_the_client()    {        var file = FormFile("../../app/appsettings.json", "image/png", TestImages.SolidPng(8, 8));         var id = await _svc.StoreAsync(file, default);         var key = _blobs.LastKey;        Assert.Equal($"avatar/{id:N}", key);        Assert.DoesNotContain("..", key);        Assert.DoesNotContain("appsettings", key);        // Và tên gốc vẫn được giữ — như DỮ LIỆU trong DB, không trong đường dẫn.        Assert.Equal("../../app/appsettings.json", (await _avatars.FindAsync(id, default))!.OriginalName);    }     /// <summary>SVG bị từ chối: nó là XML, nên nó mang cả XXE lẫn XSS lưu trữ.</summary>    [Fact]    public async Task Rejects_svg()    {        var svg = "<svg xmlns=\"http://www.w3.org/2000/svg\" onload=\"alert(1)\"/>";         await Assert.ThrowsAsync<ApplicationGeneralException>(            () => _svc.StoreAsync(FormFile("a.svg", "image/svg+xml", svg), default));    }     /// <summary>Cặp đôi: ảnh thật vẫn lưu được, và ra là PNG do ta encode.</summary>    [Fact]    public async Task Accepts_real_image_and_re_encodes_it()    {        var jpeg = TestImages.SolidJpeg(512, 512);         var id = await _svc.StoreAsync(FormFile("photo.jpg", "image/jpeg", jpeg), default);         // Vào là JPEG, ra là PNG: chứng minh bước re-encode đã chạy, và đó là bước        // xoá mọi thứ không phải pixel.        Assert.Equal("image/png", _blobs.LastContentType);        var stored = await Image.LoadAsync(await _blobs.GetAsync($"avatar/{id:N}", default));        Assert.Equal(256, stored.Width);    }}
08

Sai lầm thường gặp

"Bản vá"Vì sao không đúng
Kiểm Content-Type của multipartDo client đặt. Nó là lời khai, không phải bằng chứng
accept=".jpg" trên <input>Gợi ý cho hộp thoại chọn file. curl không thấy nó
Blocklist .php, .aspx, .jspBảng ở khối 3: .phtml, .php5, .ashx, .cshtml, .pHp, x.php.jpg, .htaccess… danh sách không kết thúc
Chỉ kiểm magic byteGIF89a;<?php …?> là GIF hợp lệ PHP hợp lệ. Magic byte chứng minh file bắt đầu bằng header ảnh
Allowlist phần mở rộng, giữ tên clientVẫn còn path traversal dạng ghi (../../x.json) và ghi đè file có sẵn
Trần kích thước fileKhông chặn decompression bomb: PNG 50000×50000 nặng 40KB và cần 10GB RAM
Lưu ngoài webroot rồi phục vụ với Content-Type từ requestĐóng RCE, để nguyên XSS lưu trữ: một SVG phục vụ với image/svg+xml vẫn chạy <script>
Đổi tên file nhưng giữ phần mở rộnga1b2c3.aspx trong webroot vẫn là .aspx. Phần mở rộng là thứ web server đọc, không phải phần tên
"Chỉ cho ảnh nên an toàn"GitLab CVE-2021-22205: file đúng là ảnh, lỗi ở thư viện ĐỌC ảnh

Sai lầm về phạm vi: coi upload là một tính năng. Nó là một bề mặt mà nhiều họ lỗ hổng đi qua, và mỗi họ cần một bản vá riêng. Vá RCE rồi tuyên bố xong là bỏ lại XSS lưu trữ và DoS.

Sai lầm về tin cậy: nghĩ file đã upload là "dữ liệu của chúng ta". Nó là dữ liệu của kẻ tấn công nằm trên hạ tầng của bạn, và mỗi lần đọc lại nó là một lần xử lý input không tin cậy.

09

Nguồn tham khảo

Bậc 1Upload files in ASP.NET Core — Security considerations · Microsoft · ASP.NET Core docs · .NET 8
Bậc 1CVE-2021-22205 — GitLab unauthenticated remote code execution · NIST · National Vulnerability Database · CVSS 3.1 10.0
Bậc 1X-Content-Type-Options · MDN · HTTP headers reference · 2025
Bậc 2File upload vulnerabilities · PortSwigger · Web Security Academy
Bậc 2File Upload Cheat Sheet · OWASP · Cheat Sheet Series
Nằm trong lộ trình
Secure Backend DeveloperXem lộ trình

Bình luận

Tham gia thảo luận
Đăng ký để bình luận

Bình luận cần tài khoản đã hoàn thành ít nhất một bài học. Điều kiện đó là thứ giữ cho luồng thảo luận này còn đáng đọc: mỗi ý kiến gắn với một người có thể bị hỏi lại, và reputation tích luỹ theo thời gian.

Đăng kýĐăng nhập

Bạn vẫn đọc được toàn bộ bình luận dưới đây mà không cần tài khoản. Đăng nhập xong bạn sẽ quay lại đúng chỗ này, không phải đầu trang.

Đang tải bình luận…