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\", soStartsWith()check passes - When
File()is called, Windows resolves the..sequences to the actual pathC:\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 (
OrdinalIgnoreCaseon Windows,Ordinalelsewhere) - 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
- CWE-35: Path Equivalence
- Microsoft Path Class Documentation - GetFullPath(), Combine()
- Microsoft File Class Documentation - Exists(), GetAttributes()
- OWASP Path Traversal