Skip to content

CWE-35: Path Equivalence - C#

Overview

Path equivalence vulnerabilities in C#/ASP.NET applications occur when Path.Combine() is used with user input followed by string comparison validation, allowing attackers to bypass restrictions through .. sequences, case variations (on Windows), or junction points. Path.Combine() concatenates paths without resolving traversal sequences, making pre-canonicalization string checks ineffective.

Primary Defence: Use Path.GetFullPath() to canonicalize all user-supplied paths (resolving ., .., and normalizing separators), validate filenames don't contain path separators (/ or \\) or invalid filename characters, verify the canonical path starts with the allowed directory using StartsWith() with the OS-appropriate comparison, 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

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

    // Case-insensitive comparison on Windows - 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 the actual path C:\Windows\System32\config\SAM
  • Security validation happens on non-canonical string, but file access uses the canonical (resolved) path
  • No symlink or junction point resolution

Secure Patterns

Full Path Canonicalization with Ordinal Comparison

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 canonical path starts with allowed directory
    // Use OS-appropriate comparison (Windows is case-insensitive)
    StringComparison pathComparison =
        OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
    if (!canonicalPath.StartsWith(ALLOWED_DIR, pathComparison))
    {
        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 path, resolving ., .., and normalizing separators
  • Uses OS-appropriate case comparison (OrdinalIgnoreCase on Windows, Ordinal elsewhere)
  • Rejects filenames with path separators or invalid filename characters to prevent traversal and ADS 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