flawopen.com/path-traversal/Csharp
Path Traversal in C# (System.IO, ASP.NET Core)
Path.Combine accepts rooted paths, prefix checks can confuse sibling folders, and ZIP entry paths can escape an extraction root. Learn how ASP.NET Core file providers scope ordinary paths, why symlinks and mutable roots break that assumption, and how server-issued IDs and bounded archive extraction reduce risk.
Imagine a fenced loading yard with a staffed gate. Path.GetFullPath() can normalize an address, but Path.Combine() lets a rooted second path replace the yard address; a string StartsWith check can also mistake /srv/uploads-old for /srv/uploads. PhysicalFileProvider is a file clerk, not the fence: a symlink or ReparsePoint can lead outside unless the root and every parent stay link-free and service-controlled. Accept server-issued file IDs, not visitor-supplied paths. For ZIPs, discard entry names and write under fresh Guid names with CreateNew; cap compressed and expanded size, and use a memory-limited worker because ZipArchive builds its entry list before your count check.
Core Concepts & Subsystem Terms
Path.Combine() with a rooted name- If a later argument is rooted,
Path.Combinediscards everything before it:Path.Combine("/srv/uploads", "/etc/hostname")returns/etc/hostname, and on WindowsC:\Windows\win.inidoes the same...segments are kept, soCombinenever confines a path. StartsWith without a separatorfull.StartsWith("/srv/uploads")also accepts/srv/uploads-old/secret.txt, and the default culture-sensitive comparison is wrong for paths. Compare against the base plusPath.DirectorySeparatorCharwithStringComparison.Ordinal, orOrdinalIgnoreCaseon Windows.Path.GetFullPath(path, basePath)- Resolves
.and..against an explicit base and returns an absolute path without touching the disk. It does not follow symbolic links, so keep link creation out of upload folders. PhysicalFileProvider- Serves files from one directory:
GetFileInforeturns a not-found result for absolute sub-paths or ones that contain.., and hidden or dot files are excluded by default.UseStaticFilesandResults.Filewith itsPhysicalPathbuild on it. ExtractToFile vs ExtractToDirectoryZipArchiveEntry.ExtractToFile(path)writes to whatever path you give it, so joiningentry.FullNameonto the destination is Zip Slip.ZipFile.ExtractToDirectorychecks every entry and throwsIOExceptionwhen one would land outside the destination.
Framework mappings
- CWE reference for the weakness described in this guide: CWE-22
Step-by-Step Attack Flow
Request value reaches a file API
A minimal API or controller binds file from the query string, or an uploaded ZIP's entry.FullName, and passes it to Path.Combine, File.ReadAllText or ExtractToFile.
The value leaves the intended folder
It is rooted, contains ..\ or ../ segments, or names a sibling folder with the same prefix. The benign test value ../../etc/hostname illustrates the read case.
The runtime opens the resolved path
Results.File treats a rooted path as a physical file, and File.ReadAllText or ExtractToFile passes the path straight to the operating system.
The attacker reads or overwrites files
Reads expose appsettings.json, connection strings and keys; archive writes can replace assemblies, Razor views or configuration that the app loads later.
Source Code: Flaw vs. Secure Implementation
The hardened path accepts server-issued UUIDs and opens the stream before returning a response, so request values never become filesystem paths. PhysicalFileProvider scopes ordinary paths but is not a security sandbox: a symlink or reparse point below its root can expose an outside target. The code therefore requires a link-free root and ancestors, regular files, and no untrusted mutation while serving. Archive labels are checked then discarded; output uses UUIDs and has compressed-size, per-entry, total-byte, and entry-count checks. ZipArchive materializes its central-directory entry collection before the count check, so that count limit does not bound parser allocation; constrain archive upload size and process hostile archives in a memory-limited worker. A failed extraction can leave earlier files, so stage then publish or clean up on error. The archive destination and every parent must remain service-owned and non-writable by untrusted principals for the entire check-and-create operation; otherwise the path-based setup can race.
// Program.cs: each endpoint lets the request pick a path outside UploadDir
using System.IO;
using System.IO.Compression;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
var app = WebApplication.Create(args);
const string UploadDir = "/srv/uploads";
// a rooted file name replaces UploadDir; ../ walks out of it
app.MapGet("/download", (string file) =>
Results.File(Path.Combine(UploadDir, file)));
// StartsWith without a separator accepts /srv/uploads-old/...
app.MapGet("/preview", (string file) =>
{
var full = Path.GetFullPath(Path.Combine(UploadDir, file));
if (!full.StartsWith(UploadDir))
return Results.NotFound();
return Results.Text(File.ReadAllText(full));
});
app.Run();
// an entry named ../../app/appsettings.json is written outside dest
static void ImportArchive(string zipPath, string dest)
{
using var archive = ZipFile.OpenRead(zipPath);
foreach (var entry in archive.Entries)
{
if (string.IsNullOrEmpty(entry.Name)) continue;
var target = Path.Combine(dest, entry.FullName);
Directory.CreateDirectory(Path.GetDirectoryName(target)!);
entry.ExtractToFile(target);
}
}
// Program.cs: server IDs, opened streams, no-link roots, and bounded extraction
using System;
using System.IO;
using System.IO.Compression;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.FileProviders;
const long MaxArchiveBytes = 25_000_000;
const long MaxEntryBytes = 10_000_000;
const long MaxTotalBytes = 100_000_000;
const int MaxEntries = 1000;
// Check the configured directory and every ancestor for a symlink or reparse point.
static string RequirePlainDirectory(string path)
{
var full = Path.GetFullPath(path);
for (DirectoryInfo? current = new DirectoryInfo(full); current is not null; current = current.Parent)
{
if ((current.Attributes & FileAttributes.ReparsePoint) != 0)
throw new IOException("storage paths must not contain links or reparse points");
}
return full;
}
static bool IsFileId(string value) => Guid.TryParseExact(value, "N", out _);
static bool IsRegularFile(IFileInfo info)
{
if (!info.Exists || info.IsDirectory || info.PhysicalPath is not { } path) return false;
try
{
var attributes = File.GetAttributes(path);
return (attributes & (FileAttributes.ReparsePoint | FileAttributes.Directory)) == 0;
}
catch (IOException) { return false; }
catch (UnauthorizedAccessException) { return false; }
}
var app = WebApplication.Create(args);
var uploadDir = RequirePlainDirectory("/srv/uploads");
var files = new PhysicalFileProvider(uploadDir);
// Only accept server-issued UUIDs, never client filenames or arbitrary paths.
app.MapGet("/download", (string file) =>
{
if (!IsFileId(file)) return Results.NotFound();
var info = files.GetFileInfo(file);
if (!IsRegularFile(info)) return Results.NotFound();
// Open the stream now; do not return a checked path for a later open.
return Results.File(info.CreateReadStream(), "application/octet-stream");
});
app.MapGet("/preview", (string file) =>
{
if (!IsFileId(file)) return Results.NotFound();
var info = files.GetFileInfo(file);
if (!IsRegularFile(info)) return Results.NotFound();
using var reader = new StreamReader(info.CreateReadStream());
var chars = new char[2000];
var count = reader.Read(chars, 0, chars.Length);
return Results.Text(new string(chars, 0, count));
});
app.Run();
static bool IsFlatArchiveLabel(string value) =>
Regex.IsMatch(value, "^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$", RegexOptions.CultureInvariant)
&& value != "." && value != "..";
// The spool and destination parent must be private and immutable while this method runs.
void ImportArchive(string zipPath, string dest)
{
var source = new FileInfo(zipPath);
if (!source.Exists || source.Length > MaxArchiveBytes)
throw new InvalidDataException("archive input too large");
var fullDest = Path.GetFullPath(dest);
var parent = Path.GetDirectoryName(fullDest)
?? throw new InvalidDataException("invalid destination");
RequirePlainDirectory(parent);
if (Directory.Exists(fullDest) || File.Exists(fullDest))
throw new IOException("destination must be a new directory");
Directory.CreateDirectory(fullDest);
var root = RequirePlainDirectory(fullDest);
using var archive = ZipFile.OpenRead(zipPath);
if (archive.Entries.Count > MaxEntries)
throw new InvalidDataException("too many archive entries");
long total = 0;
foreach (var entry in archive.Entries)
{
// Reject directories and nested paths; the client label is never an output name.
if (entry.FullName != entry.Name || !IsFlatArchiveLabel(entry.FullName))
throw new InvalidDataException("unsafe archive entry");
if (entry.Length > MaxEntryBytes || entry.Length > MaxTotalBytes - total)
throw new InvalidDataException("archive expansion limit exceeded");
var target = Path.Combine(root, Guid.NewGuid().ToString("N"));
using var input = entry.Open();
using var output = new FileStream(target, FileMode.CreateNew, FileAccess.Write,
FileShare.None, 81920, FileOptions.SequentialScan);
CopyBounded(input, output, ref total);
}
}
void CopyBounded(Stream source, Stream destination, ref long total)
{
var buffer = new byte[81920];
long entryBytes = 0;
int read;
while ((read = source.Read(buffer, 0, buffer.Length)) != 0)
{
if (read > MaxEntryBytes - entryBytes || read > MaxTotalBytes - total)
throw new InvalidDataException("archive expansion limit exceeded");
entryBytes += read;
total += read;
destination.Write(buffer, 0, read);
}
}
Engineering & System Hardening Checklist
- ✓Run CodeQL cs/path-injection and cs/zipslip in CI; treat request values and archive labels as untrusted.
- ✓Accept only server-issued UUIDs; never convert a client filename or path into a storage path.
- ✓Require the upload root and every ancestor to contain no symlinks or reparse points; protect the tree from untrusted mutation and store only regular files.
- ✓Open the file stream before returning the response; do not validate a path and pass it to a later file-open operation.
- ✓For archives, discard validated labels and write under UUIDs with CreateNew; cap compressed input, per-entry and total bytes, and entry count. Note that ZipArchive materializes its entry collection before the count check.
- ✓Process hostile archives in a memory-limited worker; stage extraction and publish after validation, or remove partial files on failure. Use a fresh destination with no link or reparse point in its path. Keep the archive destination parent and all ancestors service-owned and non-writable by untrusted principals throughout extraction.
Sources
- CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')
- .NET API — Path.Combine remarks
- .NET API — PhysicalFileProvider
- .NET API — ZipFile.ExtractToDirectory
- CodeQL — cs/path-injection
- CodeQL — cs/zipslip
- OWASP — Path Traversal
- .NET — Best practices for ZIP and TAR archives
- ASP.NET Core — File providers and PhysicalFileProvider limitations