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\", soStartsWith()check passes - When
File()is called, Windows resolves the..sequences toC:\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; useStringComparison.Ordinalonce 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
- CWE-41: Improper Resolution of Path Equivalence
- Microsoft Path Class Documentation - GetFullPath(), Combine()
- Microsoft File Class Documentation - Exists(), GetAttributes()
- OWASP Path Traversal