Skip to content

CWE-41: Improper Resolution of Path Equivalence - C#

Overview

Path equivalence vulnerabilities in C#/ASP.NET applications occur when code passes user input to Path.Combine() and then validates the combined string with a comparison. Path.Combine() concatenates paths without resolving traversal sequences, so the check runs on a string the file system later resolves somewhere else, and .. sequences or junction points reach files outside the intended directory.

Primary Defence: Use Path.GetFullPath() to canonicalize user-supplied target paths, resolving ., .., and normalizing separators. Validate that filenames contain no path separators (/ or \\) or invalid filename characters, verify containment with Path.GetRelativePath() by rejecting rooted or parent-relative results, check file existence with File.Exists(), and confirm it's not a directory using File.GetAttributes() before access. If you need to defend against symlink/junction escapes, add a reparse-point check before serving the file.

Common Vulnerable Patterns

Missing Canonicalization Before Comparison

// VULNERABLE - Missing Canonicalization Before Comparison
[HttpGet("document/{name}")]
public IActionResult GetDocument(string name)
{
    string basePath = @"C:\App\Documents\";
    string fullPath = Path.Combine(basePath, name);

    // Comparison runs on the un-normalized string - can be bypassed
    if (fullPath.StartsWith(basePath))
    {
        // Attack on Windows: name=..\..\Windows\System32\config\SAM
        // fullPath becomes: C:\App\Documents\..\..\Windows\System32\config\SAM
        // After normalization: C:\Windows\System32\config\SAM
        // StartsWith check uses string before normalization!
        return File(fullPath, "application/octet-stream");
    }
    return Forbid();
}

Why this is vulnerable:

  • Path.Combine() only concatenates paths - it does NOT resolve .. or . sequences or canonicalize
  • The combined path "C:\App\Documents\..\..\Windows\System32\config\SAM" literally starts with "C:\App\Documents\", so StartsWith() check passes
  • When File() is called, Windows resolves the .. sequences to C:\Windows\System32\config\SAM - the check ran on the non-canonical string, but the access uses the canonical one
  • StartsWith(string) also defaults to a culture-sensitive comparison; use StringComparison.Ordinal once the path has been canonicalized
  • No symlink or junction point resolution

Secure Patterns

Full Path Canonicalization with Relative Path Containment

using System.IO;

private static readonly string ALLOWED_DIR = 
    Path.GetFullPath(@"C:\App\Documents\");

[HttpGet("document/{name}")]
public IActionResult GetDocument(string name)
{
    if (string.IsNullOrWhiteSpace(name))
    {
        return BadRequest("Document name required");
    }

    // Prevent path separators or invalid filename characters in name
    if (name.Contains("/") || name.Contains("\\") ||
        name.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 ||
        Path.GetFileName(name) != name)
    {
        return BadRequest("Invalid document name");
    }

    // Combine paths
    string requestedPath = Path.Combine(ALLOWED_DIR, name);

    // Canonicalize to full absolute path
    string canonicalPath;
    try
    {
        canonicalPath = Path.GetFullPath(requestedPath);
    }
    catch (Exception)
    {
        return BadRequest("Invalid path");
    }

    // Verify containment using a path-aware relative path check
    string rel = Path.GetRelativePath(ALLOWED_DIR, canonicalPath);
    if (Path.IsPathRooted(rel) ||
        rel == ".." ||
        rel.StartsWith(".." + Path.DirectorySeparatorChar) ||
        rel.StartsWith(".." + Path.AltDirectorySeparatorChar))
    {
        return Forbid();
    }

    // Verify file exists
    if (!System.IO.File.Exists(canonicalPath))
    {
        return NotFound();
    }

    // Verify it's a file, not a directory
    FileAttributes attr = System.IO.File.GetAttributes(canonicalPath);
    if (attr.HasFlag(FileAttributes.Directory))
    {
        return BadRequest("Path is a directory");
    }

    // Optional: reject symlinks/junctions if you must prevent reparse-point escapes
    if (attr.HasFlag(FileAttributes.ReparsePoint))
    {
        return Forbid();
    }

    return PhysicalFile(canonicalPath, "application/octet-stream");
}

Why this works:

  • Path.GetFullPath() canonicalizes the path, resolving ., .., and normalizing separators
  • Uses path-aware relative containment checks instead of raw string-prefix checks
  • Rejects filenames with path separators or invalid filename characters to prevent traversal and alternate data stream tricks
  • Validates file exists and is not a directory before serving
  • All security checks use canonicalized paths; add a reparse-point check when symlink/junction escapes are in scope

Additional Resources