SecLab

File upload vulnerabilities

V5CWE-434
01

What it is

File upload vulnerabilities are what happens when the server trusts what the client says about a file: the filename, the extension, the Content-Type. The attacker chooses all three. The worst outcome is code execution: an .aspx/.php/.jsp file landing in a directory the web server will execute, then fetched by URL.

02

Why you should care

Relevance: CoreExpected: L2

File upload is where several vulnerability families meet, and that is what makes it harder than it looks. One avatar upload field opens all of these at once:

  • RCE — an executable file placed in a directory the web server will execute
  • Path traversal — a filename of ../../app/appsettings.json overwriting config (see the path-traversal topic)
  • Stored XSS — an .svg or .html carrying <script>, served same-origin (see the xss topic)
  • XXE.svg is XML; .docx is a ZIP of XML (see the xxe topic)
  • DoS — zip bombs, a 10GB file, a 50000×50000 pixel image exhausting RAM on resize
  • Malware hosting — your infrastructure becomes the distribution point

Which means there is no single fix: what closes RCE does not close stored XSS, and vice versa. This is a topic where the long defence list is not padding — the threats genuinely differ.

One more thing commonly missed: client-side validation is no validation. accept=".jpg" on an <input> is a hint for the file picker, not a constraint — a request sent with curl never sees it.

03

How the attack works

The RCE mechanism needs two things to be true, and the best fix breaks the second — because you will never fully block the first.

Diagram source
flowchart TD    U["POST /api/avatar<br/>filename=x.aspx<br/>Content-Type: image/png"] --> V{What does the server check?}    V -->|"Trusts Content-Type<br/>(client-set)"| S1["Saves /wwwroot/uploads/x.aspx"]    V -->|"New name + stored outside webroot"| S2["Saves /var/data/blob/a1b2c3<br/>(no extension)"]    S1 --> R["GET /uploads/x.aspx<br/>→ IIS SEES .aspx → executes it"]    R --> B["🔓 RCE"]    S2 --> D["GET /api/files/{id}<br/>→ app reads blob, sets headers, returns bytes"]    D --> G["✅ Nothing can execute it"]

The key point: .aspx is dangerous only because it sits somewhere that will execute it. The same file in /var/data/blob/ is an inert blob of bytes. So the strongest fix is not "block dangerous extensions" — it is removing executability from where you store.

Ways past an extension filter — the table that explains why blocklists lose:

TrickWhat it defeats
x.pHp, x.aspXCase-sensitive comparison
x.php.jpgChecking the FIRST extension (older Apache executes it)
x.jpg.phpChecking the extension with Contains(".jpg")
x.php5, .phtml, .asp, .ashx, .cshtmlA blocklist that is not long enough
x.php%00.jpgNull byte (older parsers truncate at %00)
x. / x.php.Windows strips trailing dots
.htaccess / web.configNot executable itself — it configures other files to be
Correct magic bytes plus payloadGIF89a;<?php …?> is a valid GIF and valid PHP

The last row deserves its own note: magic-byte checking is not enough to permit execution. It proves the file starts with an image header, not that the rest of it is an image.

Diagram description: Branching diagram: an upload POST with filename x.aspx and Content-Type image/png. The wrong branch trusts the client-set Content-Type and saves the file as /wwwroot/uploads/x.aspx, so a GET to /uploads/x.aspx makes IIS see the .aspx extension and execute it, giving RCE. The right branch generates a new extensionless name and stores it outside the webroot in /var/data/blob, so the file only reaches users through an app endpoint that reads the blob and sets headers itself — nothing in that path can execute it.

04

Concrete example

An avatar upload field. Three requests, three different vulnerability families through one endpoint.

HTTP
# 1 — RCE. Content-Type is the client's claim, and it lies.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"}      ← the file is now inside the webroot GET /uploads/shell.aspx?cmd=id → uid=0(root)
HTTP
# 2 — Stored XSS via SVG. Valid magic bytes, valid extension, still 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, write form. No execution needed, only an overwrite.filename="../../app/appsettings.Production.json"

Three payloads, three different fixes. That is the entire reason block 6 has five layers.

C#Three bugs: trusting Content-Type, keeping the client name, and writing into the 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` stops traversal but KEEPS the extension — which is the whole problem.
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

What happened in the wild

The web-shell-via-upload pattern. A repeating step across ransomware incidents from 2019 to 2023: the attacker uploads an .aspx web shell into a directory the web server serves, then uses it as a foothold. CISA advisories on several ransomware groups describe exactly this step. What is worth learning: most cases did NOT need to bypass an extension filter — there was no filter, or the file was written straight into the webroot.

GitLab CVE-2021-22205 — CVSS 10.0, mass exploited. GitLab passed uploaded images to ExifTool to read metadata, and ExifTool had a DjVu parsing flaw. The result was unauthenticated RCE. Memorable because an "images only" fix does not help: the file genuinely was an image, and the bug was in the thing reading the image. This is why block 6 has a layer about isolating file processing.

06

How to defend

Layer 1

Store outside the webroot, generate a new name, serve through an app endpoint

mandatory

This is the most important fix, and it closes four families at once — RCE, path traversal, file enumeration, and file overwrite. Why it is strong: it does not try to guess which files are dangerous, it removes the capability.

Three things, all required:

  1. Generate a server-chosen name — a UUIDv7, no extension. The client's filename is stored in the DB as metadata (to set Content-Disposition on download) and never touches the filesystem. That erases path traversal entirely: no user string enters a path.
  2. Store outside any directory the web server serves/var/data/blob/, or better, object storage (S3/R2) with a non-public bucket. If nothing serves that directory directly, there is no URL to fetch the file, and .aspx is just bytes.
  3. Serve through an endpoint that reads the blob by id, checks authorisation, then sets our headers. This is also the only place authorisation can be applied — a file in the webroot is readable by anyone with the URL.
C# · Layer 1Server-generated name, stored outside the webroot, real decode then re-encode, three caps.
/// <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 1The same four rules: server-generated id, outside static, verify + re-encode, pixel cap.
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
Layer 1b

Allowlist the type, validate by content — not by the client's claim

mandatory

The multipart Content-Type and the extension in filename are both attacker-chosen. Both are hints, not evidence.

How to validate, in order:

  1. Allowlist, not blocklist. { image/png, image/jpeg, image/webp } — the block 3 table shows a blocklist is never long enough.
  2. Check the magic bytes at the head of the file against that allowlist. Do not trust Content-Type.
  3. Actually decode it with an image library (ImageSharp, Pillow). This is the step that separates "the file starts with GIF89a" from "the file is an image": GIF89a;<?php …?> passes step 2 and fails step 3.
  4. Re-encode and discard the original. The strongest step and the one usually skipped: decoding and re-encoding to PNG strips everything that is not a pixel — EXIF metadata payloads, polyglots, <script> in SVG. The stored file is one we produced, not one the attacker sent.

SVG is the exception and needs its own handling: it is XML, so it carries both XXE and XSS. Transcode to PNG and drop the original; if you must keep SVG, parse it through SafeXml (see the xxe topic) and serve it from a separate origin.

Layer 1c

Size cap, pixel cap, timeout — before reading a single byte

mandatory

DoS here needs no vulnerability, only a large file. Three caps, each stopping something different:

  • Request size, enforced at the framework or proxy layer rather than in the handler: RequestSizeLimit in ASP.NET Core, client_max_body_size in nginx. Checking in the handler is checking after it is all in RAM or on disk.
  • Pixel count (width × height), checked after reading the image header and before decoding. A 50000×50000 PNG is 40KB on disk and needs ~10GB of RAM to resize — that is a decompression bomb, and a file-size cap does not stop it.
  • A timeout on the whole processing step. An image crafted to make the decoder run slowly is a DoS that both caps above allow through.

For archives: cap the total uncompressed size and the entry count — see the path-traversal topic for zip bombs and Zip Slip.

Layer 2

Isolate file processing, and remove execution from where you store

This is the layer that absorbs the GitLab CVE-2021-22205 lesson: the file genuinely was an image, and the bug was in the library reading it. No format check stops that.

  • Run the decode/convert step in a separate process or container with no credentials, no egress, readOnlyRootFilesystem, and default seccomp. If ExifTool is exploited there, it is exploited inside an empty sandbox.
  • Remove execution from the storage location, even though it is outside the webroot — insurance for the day somebody adds a new static file handler. Mount the volume noexec; in nginx give location /uploads { } no script handler; on IIS strip all handlers for that directory.
  • Scan for malware if others download the file. Not to protect you, but so your infrastructure does not become the distribution point.
Layer 3

Correct response headers, and a separate origin for user content

This layer closes stored XSS — which none of the four layers above touch if you keep the original format.

  • Set Content-Type from your validation result, never copied from the request. Add X-Content-Type-Options: nosniff — without it the browser may sniff, and it may sniff text/html.
  • Content-Disposition: attachment for everything that need not render inline. The browser downloads instead of rendering, so an embedded <script> never runs.
  • A separate origin for user content — usercontent.example.com, not example.com/uploads. This is the only control on the list that genuinely isolates stored XSS: a <script> running on that origin cannot read your app's cookies or localStorage. Google and GitHub both do exactly this.
  • Content-Security-Policy: sandbox on file responses narrows it further.
07

Verifying the fix

1. A unit test taking the BYPASS TABLE from block 3 as input data, plus a polyglot and a decompression bomb. Assert the right things: the on-disk name contains nothing from the client, and the polyglot is rejected at the decode step. See the csharp / test tab.

2. Prove no URL can execute an uploaded file — the most important check here, and it is end-to-end, not a 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) # The upload MUST be rejected (it does not decode as an image):test -z "$ID" || { echo "a non-image was accepted"; exit 1; } # And every guessable path MUST 404, not 200 containing PWNED:for p in /uploads/shell.aspx /files/shell.aspx /wwwroot/uploads/shell.aspx; do  curl -s "$B$p" | grep -q PWNED && { echo "executes at $p"; exit 1; }doneexit 0

3. Check the response headers when serving files (layer 3):

Shell
H=$(curl -sI "$B/api/files/$ID" | tr -d '\r')echo "$H" | grep -qi 'x-content-type-options: *nosniff' || { echo "nosniff missing"; exit 1; }echo "$H" | grep -qi 'content-type: *image/'             || { echo "Content-Type is not an image"; exit 1; }# And Content-Type MUST match your validation, not the upload claim:echo "$H" | grep -qi 'content-type: *text/html'          && { echo "served as HTML"; exit 1; }exit 0

4. Test the decompression bomb. Build a 50000×50000 PNG (~40KB) and assert the API rejects it before decoding, rather than dying on memory:

Shell
python3 -c "from PIL import Image; Image.new('RGB',(50000,50000)).save('bomb.png')" 2>/dev/null \  || echo "(needs Pillow — or take the fixture from tests/fixtures)"curl -s -o /dev/null -w '%{http_code}\n' -F "file=@bomb.png" $B/api/profile/avatar  # must be 4xx

5. Check the storage location cannot execute (layer 2):

Shell
docker exec app sh -c 'mount | grep /var/data/blob' | grep -q noexec \  || echo "WARNING: the blob volume is not mounted noexec"
C#Block 3's bypass table as test data, plus a polyglot and a 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

Common mistakes

The "fix"Why it is wrong
Check the multipart Content-TypeClient-set. It is a claim, not evidence
accept=".jpg" on the <input>A hint for the file picker. curl never sees it
Blocklist .php, .aspx, .jspThe block 3 table: .phtml, .php5, .ashx, .cshtml, .pHp, x.php.jpg, .htaccess… the list does not end
Only check magic bytesGIF89a;<?php …?> is a valid GIF and valid PHP. Magic bytes prove the file starts with an image header
Allowlist the extension, keep the client's nameStill leaves write-form path traversal (../../x.json) and overwriting existing files
A file-size capDoes not stop a decompression bomb: a 50000×50000 PNG is 40KB and needs 10GB of RAM
Store outside the webroot, serve with the request's Content-TypeCloses RCE, leaves stored XSS untouched: an SVG served as image/svg+xml still runs <script>
Rename the file but keep the extensiona1b2c3.aspx in the webroot is still .aspx. The extension is what the web server reads, not the stem
"Images only, so we are safe"GitLab CVE-2021-22205: the file genuinely was an image, the bug was in the library READING it

The scoping mistake: treating upload as a feature. It is a surface several vulnerability families pass through, and each needs its own fix. Closing RCE and declaring victory leaves stored XSS and DoS behind.

The trust mistake: thinking an uploaded file is "our data". It is the attacker's data living on your infrastructure, and every subsequent read of it is another pass at handling untrusted input.

09

References

Tier 1Upload files in ASP.NET Core — Security considerations · Microsoft · ASP.NET Core docs · .NET 8
Tier 1CVE-2021-22205 — GitLab unauthenticated remote code execution · NIST · National Vulnerability Database · CVSS 3.1 10.0
Tier 1X-Content-Type-Options · MDN · HTTP headers reference · 2025
Tier 2File upload vulnerabilities · PortSwigger · Web Security Academy
Tier 2File Upload Cheat Sheet · OWASP · Cheat Sheet Series
Part of path
Secure Backend DeveloperView path

Comments

Join the discussion
Sign up to comment

Commenting needs an account with at least one completed lesson. That condition is what keeps this thread worth reading: every point belongs to someone who can be asked back, and reputation accrues over time.

Sign upSign in

You can still read every comment below without an account. Signing in brings you back to this exact spot, not to the top of the page.

Loading comments…