FlawOpen

flawopen.com/path-traversal/Csharp

● CWE-22 · High
FlawOpen Security Research

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.

💡 Plain English Explainer (ELI5)

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.Combine discards everything before it: Path.Combine("/srv/uploads", "/etc/hostname") returns /etc/hostname, and on Windows C:\Windows\win.ini does the same. .. segments are kept, so Combine never confines a path.
StartsWith without a separator
full.StartsWith("/srv/uploads") also accepts /srv/uploads-old/secret.txt, and the default culture-sensitive comparison is wrong for paths. Compare against the base plus Path.DirectorySeparatorChar with StringComparison.Ordinal, or OrdinalIgnoreCase on 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: GetFileInfo returns a not-found result for absolute sub-paths or ones that contain .., and hidden or dot files are excluded by default. UseStaticFiles and Results.File with its PhysicalPath build on it.
ExtractToFile vs ExtractToDirectory
ZipArchiveEntry.ExtractToFile(path) writes to whatever path you give it, so joining entry.FullName onto the destination is Zip Slip. ZipFile.ExtractToDirectory checks every entry and throws IOException when one would land outside the destination.

Framework mappings

  • CWE reference for the weakness described in this guide: CWE-22

Step-by-Step Attack Flow

Step 1

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.

Step 2

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.

Step 3

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.

Step 4

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.

UNPATCHED FLAW
// 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);
    }
}
HARDENED SECURE PATCH
// 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

Sources