CWE-434: Unrestricted Upload of File with Dangerous Type - C
Overview
ASP.NET Core receives uploaded files as IFormFile (or IFormFileCollection) bound to an action method parameter. The two properties most often misused for security decisions are IFormFile.FileName and IFormFile.ContentType - both come from the multipart request body and are set entirely by the client. A request can claim ContentType: image/png and a FileName ending in .png while the actual bytes are something else entirely; ASP.NET Core does not verify either value against the file's real content.
What an Uploaded File Can Actually Do on ASP.NET Core
Guidance written for the older stack often says an uploaded .aspx or .ashx file becomes a web shell. That is true of ASP.NET on .NET Framework behind IIS classic, where aspnet_isapi maps those extensions to handlers; it is not true of ASP.NET Core. There is no WebForms page compiler and no .ashx handler pipeline, and UseStaticFiles() serves bytes without executing them. Measured on .NET 10 with UseStaticFiles() and wwwroot/uploads/ containing shell.aspx, shell.ashx and shell.cshtml, all three requests returned 404 - the static file middleware has no content type for those extensions and declines to serve them at all. So if a finding on an ASP.NET Core project is written up as "attacker uploads .aspx, gets RCE", the write-up is describing a different platform.
What does happen on ASP.NET Core, in rough order of severity:
- A file written inside the content root that the runtime later loads. A managed assembly picked up by a plugin loader, or a
.cshtmlview whenAddRazorRuntimeCompilation()is enabled. Runtime compilation recompiles views that appear after startup: droppingViews/Home/Late.cshtmlinto a running .NET 10 MVC app returned200with the view's C# evaluated. This is the code-execution path, and it needs the upload destination to be inside the app's own tree. - Stored XSS through a file served back with an attacker-chosen content type.
x.htmlandx.svgwritten underwwwrootare served astext/htmlandimage/svg+xmlrespectively, so script in them runs in the application's origin. This is the common one, and it does not need any execution on the server. - Path traversal in the filename writing outside the upload directory, overwriting configuration, a view, or an assembly the application already loads.
- Anything a downstream consumer does with the file - an image resizer, a virus scanner, an archive extractor, a document converter, or a workstation that later opens it.
The safe approach is the same either way: read the file's actual bytes from IFormFile.OpenReadStream(), check them against an allowlist of expected file signatures (magic bytes), generate the whole storage filename server-side, and write to a directory outside wwwroot and outside the content root. Bound upload size with FormOptions.MultipartBodyLengthLimit and [RequestSizeLimit] before the framework buffers the whole request body.
Common Vulnerable Patterns
Trusting ContentType and the FileName Extension
[HttpPost("upload")]
public async Task<IActionResult> Upload(IFormFile file)
{
// VULNERABLE - ContentType is a client-supplied request header, not verified content
var allowedTypes = new[] { "image/png", "image/jpeg" };
if (!allowedTypes.Contains(file.ContentType))
return BadRequest("Invalid file type");
// VULNERABLE - the extension is read from the client-supplied FileName
var ext = Path.GetExtension(file.FileName);
var savePath = Path.Combine(_env.WebRootPath, "uploads", file.FileName);
await using var stream = new FileStream(savePath, FileMode.Create);
await file.CopyToAsync(stream);
return Ok();
}
// Attack: multipart part sends Content-Type: image/png and filename="payload.html"
// but the body bytes are a page full of script. Both client-controlled fields pass
// validation because nothing inspects the actual file content.
Why this is vulnerable: ContentType and FileName are read straight out of the multipart headers the client sent; an attacker controls both independently of the file's real bytes. Nothing here rejects the request, and because the extension also comes from FileName, the attacker chooses the content type the file will be served back with.
Saving Into wwwroot
// VULNERABLE - saves under wwwroot, which UseStaticFiles() serves directly
var savePath = Path.Combine(_env.WebRootPath, "uploads", file.FileName);
Why this is vulnerable: If the upload directory sits inside wwwroot, anything written there is reachable by URL, and UseStaticFiles() picks the response Content-Type from the extension the attacker supplied. /uploads/payload.html comes back as text/html and /uploads/payload.svg as image/svg+xml, both of which execute script in the application's origin - a stored XSS with a same-origin session attached. Kestrel will not execute the file (an uploaded .aspx, .ashx or .cshtml under wwwroot returns 404 on .NET 10, because the static file middleware has no content type for it), so the usual "upload a web shell" write-up does not apply here. The exception is a destination inside the app's content root rather than wwwroot, which is a code-execution path if the runtime loads what lands there.
Path Traversal via FileName
// VULNERABLE - the client-supplied FileName is used as-is in the save path
var savePath = Path.Combine(uploadDir, file.FileName);
// Attack: FileName = "..\\..\\Views\\Shared\\_Layout.cshtml"
// Path.Combine does not strip ".." segments, so the write can land outside uploadDir
Why this is vulnerable: Path.Combine concatenates path segments but does not resolve or validate the result. A FileName containing .. segments can walk the resulting path outside the intended upload directory. Overwriting an existing file is the damage: a view, an appsettings.json, or an assembly the application already loads. This is also a route by which an upload reaches code execution on ASP.NET Core, because it puts attacker bytes inside the content root rather than in a directory the app only reads back as data.
Secure Patterns
Validate File Signature, Generate Filename, Store Outside wwwroot
using System.IO;
using System.Linq;
// SECURE - allowlist maps known file signatures (magic bytes) to safe extensions
private static readonly Dictionary<string, byte[]> Signatures = new()
{
[".png"] = new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A },
[".jpg"] = new byte[] { 0xFF, 0xD8, 0xFF },
};
// SECURE - outside wwwroot, so UseStaticFiles() can never serve it directly
private static readonly string UploadDir = "/var/app-data/uploads";
[HttpPost("upload")]
[RequestSizeLimit(5 * 1024 * 1024)] // SECURE - bound request size before buffering
public async Task<IActionResult> Upload(IFormFile file)
{
if (file is null || file.Length == 0)
return BadRequest("No file uploaded");
await using var stream = file.OpenReadStream();
var header = new byte[8];
// SECURE - ReadAtLeast, not a single ReadAsync: one Read may return fewer bytes
// than asked for, and a short read would silently weaken the comparison below
var read = await stream.ReadAtLeastAsync(header, header.Length, throwOnEndOfStream: false);
// SECURE - detect the real type from the leading bytes, not ContentType/FileName
var match = Signatures.FirstOrDefault(sig =>
read >= sig.Value.Length && header.Take(sig.Value.Length).SequenceEqual(sig.Value));
if (match.Key is null)
return BadRequest("Unsupported file type");
// SECURE - server-generated storage name; the client-supplied FileName is never
// used to build a filesystem path. "N" gives 32 hex characters with no separators,
// so the name matches the download action's pattern below - note that
// Path.GetRandomFileName() would not, because it returns an 8.3 name containing a dot
var storedName = Guid.NewGuid().ToString("N") + match.Key;
var targetPath = Path.Combine(UploadDir, storedName);
stream.Seek(0, SeekOrigin.Begin);
Directory.CreateDirectory(UploadDir);
await using var output = new FileStream(targetPath, FileMode.CreateNew);
await stream.CopyToAsync(output);
return Ok(new { id = storedName });
}
Why this works: The type decision is made from bytes the client cannot influence without also producing a file that genuinely starts with those bytes; the signature allowlist keeps ContentType and FileName out of the decision entirely. Guid.NewGuid().ToString("N") produces the entire storage filename, so a crafted FileName (double extension, traversal sequence, null byte) never reaches the filesystem API - and because the extension comes from match.Key, which is the detected signature, the stored name and the validated content can never disagree. Writing outside wwwroot means even a file that slipped past validation is not served, so it cannot be fetched back as text/html, and FileMode.CreateNew fails rather than silently overwriting if the generated name were ever to collide.
Two details are easy to get wrong here and neither shows up in a rejection test. A single stream.ReadAsync(header) is allowed to return fewer bytes than requested; with the read >= sig.Value.Length guard above, a 3-byte read would still match the 3-byte JPEG signature while never matching the 8-byte PNG one, so a legitimate PNG would be rejected rather than an error raised. And the storage name has to be a name the download action below will accept: Path.GetRandomFileName() returns an 8.3 name such as aaknnuc3.24u, so appending .png gives aaknnuc3.24u.png - a name with a dot in the middle, which no single-extension pattern will match. Run end to end, that combination uploads successfully and then returns 400 on every download, and it passes every malicious-input test on the way. Uploading is not the whole test; upload a real PNG and then fetch it back.
Serve Uploaded Files Back Safely
[HttpGet("files/{id}")]
[Authorize]
public async Task<IActionResult> Download(string id)
{
// SECURE - id is validated against the exact format the upload action generates.
// \z, not $: in .NET $ also matches immediately before a trailing newline
if (!Regex.IsMatch(id, @"^[a-fA-F0-9]{32}\.(png|jpg)\z"))
return BadRequest();
if (!await _uploads.UserCanAccessAsync(User, id))
return Forbid();
var path = Path.Combine(UploadDir, id);
if (!System.IO.File.Exists(path))
return NotFound();
var stream = System.IO.File.OpenRead(path);
Response.Headers.XContentTypeOptions = "nosniff";
return File(stream, "application/octet-stream", id); // forces download, not inline render
}
Why this works: Serving as application/octet-stream with the file streamed through an authorized action - rather than a static file link - means the browser downloads rather than renders the content, which prevents a stored file from being executed as HTML/SVG/script in the victim's browser. This is the control that matters on ASP.NET Core, because the realistic upload attack is a same-origin script rather than a server-side shell. The id is validated against the exact format the upload action generates, so it can only ever resolve to a file the application itself created - which means the two actions have to agree about that format, and a change to either one has to be tested against the other.
Framework-Specific Guidance
ASP.NET Core Request Size Limits
// SECURE - Program.cs: bound multipart body size globally before it is buffered
builder.Services.Configure<FormOptions>(options =>
{
options.MultipartBodyLengthLimit = 5 * 1024 * 1024; // 5 MB
});
// SECURE - per-action limit, useful when different endpoints need different caps
[HttpPost("upload")]
[RequestFormLimits(MultipartBodyLengthLimit = 5 * 1024 * 1024)]
[RequestSizeLimit(5 * 1024 * 1024)]
public async Task<IActionResult> Upload(IFormFile file) { /* ... */ }
Why this works: Configuring the limit at the FormOptions/RequestSizeLimit level rejects oversized requests during model binding, before the full file is read into memory or written to disk. A size check written inside the action body runs too late to stop that: by the time it executes, the framework has already buffered the body.
Testing
- Normal inputs (the assertion that fails most often): upload a genuine PNG, take the
idfrom the response, andGET /files/{id}. Expect200and the same bytes back. A400here means the two actions disagree about the stored-name format and every legitimate download is broken, while every rejection test below still passes. - Double extension: upload a file named
invoice.pdf.pngwith real PDF bytes and with real PNG bytes; confirm acceptance follows the detected signature, and that the stored name ends in the extension the signature implies rather than anything fromFileName. - MIME-type spoofing: send
Content-Type: image/pngin the multipart part with HTML or script bytes as the body; confirm the request is rejected becauseContentTypeis never consulted. - Path traversal: set
FileNameto..\..\Views\Shared\_Layout.cshtml(and the URL-encoded equivalent); confirm the stored path never resolves outside the configured upload directory and that the existing file is untouched. - Served-back content type: request a stored file and assert the response carries
Content-Type: application/octet-streamandX-Content-Type-Options: nosniff, not a type derived from the uploaded name. This is the assertion that covers the stored-XSS path, which is the one that is actually live on ASP.NET Core. - Oversized file: upload a file larger than
MultipartBodyLengthLimit; confirm the request is rejected with413/400before the full body is read. - Rescan: if the finding came from a scanner, re-run it against the fixed endpoint to confirm the finding no longer reproduces.
Common Pitfalls
- Validating
ContentTypein addition to the extension, but still trusting both: checking thatContentTypeand theFileNameextension "agree" only confirms the client sent two consistent lies - neither field is derived from the actual bytes, so a forged pair with malicious content still passes. - Reading the signature after the stream has already been consumed: if
CopyToAsyncruns before the signature check, or the stream position isn't reset withSeek(0, SeekOrigin.Begin)after reading the header, the file that gets saved is not the one that was validated. - Storing outside
wwwrootbut still building the path fromFileName: moving the directory out of the static-file root stops the file being served back, but aFileNamecontaining..can still traverse to an unintended location on disk if the stored name is not fully server-generated - including back intowwwroot, or into the content root where a runtime-compiled view or a loadable assembly would be picked up. - Carrying an IIS-classic threat model onto ASP.NET Core: blocking
.aspx,.ashxand.aspuploads is the standard advice and it defends against nothing here, because ASP.NET Core has no handler for those extensions. Spending the extension denylist on them while.html,.svg,.xhtmland.xmlare still accepted and served fromwwwrootleaves the live sink open. Use an allowlist keyed on the detected signature instead, which does not require guessing which extensions matter.
Dependencies and Installation
No third-party package is required - System.IO provides everything shown above. For a broader, maintained file-signature database than a hand-written dictionary, a package such as MimeDetective on NuGet can be used instead; if adopted, keep it current like any other dependency.