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.
Vì sao bạn quan tâm
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.jsonghi đè cấu hình (xem topic path-traversal) - XSS lưu trữ — một
.svghay.htmlchứa<script>, phục vụ cùng origin (xem topic xss) - XXE —
.svglà XML;.docxlà 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ó.
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.
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ách | Vòng qua cái gì |
|---|---|
x.pHp, x.aspX | So sánh phân biệt chữ hoa/thường |
x.php.jpg | Kiểm phần mở rộng ĐẦU tiên (Apache cũ chạy nó) |
x.jpg.php | Kiểm phần mở rộng bằng Contains(".jpg") |
x.php5, .phtml, .asp, .ashx, .cshtml | Blocklist chưa đủ dài |
x.php%00.jpg | Null byte (parser cũ cắt tại %00) |
x. / x.php. | Windows bỏ dấu chấm cuối |
.htaccess / web.config | Không phải file thực thi — nó cấu hình để file khác thực thi |
| Magic byte đúng + payload sau | GIF89a;<?php …?> là GIF hợp lệ và 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.
Ví dụ cụ thể
Ô upload avatar. Ba request, ba họ lỗ hổng khác nhau qua cùng một endpoint.
# 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/1.1 200 OK{"url":"/uploads/shell.aspx"} ← file đã ở trong webroot GET /uploads/shell.aspx?cmd=id → uid=0(root)# 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'})"/># 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.
[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}" });}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}"}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".
Cách phòng chống
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:
- 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-Dispositionlú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. - 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à.aspxchỉ là byte. - 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.
/// <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);}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_idAllowlist loại file, kiểm bằng nội dung — không bằng lời khai của client
bắt buộcContent-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ự:
- 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. - Kiểm magic byte ở đầu file, và đối chiếu với allowlist. Không tin
Content-Type. - 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. - 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.
Trần kích thước, trần số pixel, timeout — trước khi đọc byte nào
bắt buộcDoS ở đâ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:
RequestSizeLimittrong ASP.NET Core,client_max_body_sizetrong 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.
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 đặtlocation /uploads { }không có handler nào cho script; trên IIS bỏ toàn bộhandlerscho 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.
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-Typedo chúng ta đặt từ kết quả kiểm, không phải copy lại từ request. CộngX-Content-Type-Options: nosniff— không có nó, trình duyệt có thể tự đoán và đoán ratext/html.Content-Disposition: attachmentcho 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ảiexample.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: sandboxtrên response phục vụ file cũng thu hẹp thêm.
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:
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 03. Kiểm header khi phục vụ file (lớp 3):
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 04. 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:
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à 4xx5. Kiểm nơi lưu không thực thi được (lớp 2):
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"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); }}Sai lầm thường gặp
| "Bản vá" | Vì sao không đúng |
|---|---|
Kiểm Content-Type của multipart | Do 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, .jsp | Bả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 byte | GIF89a;<?php …?> là GIF hợp lệ và 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 client | Vẫn còn path traversal dạng ghi (../../x.json) và ghi đè file có sẵn |
| Trần kích thước file | Khô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ộng | a1b2c3.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.
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.
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…