CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal') - C#
Overview
Path Traversal (also known as Directory Traversal) occurs when an application uses user-supplied input to construct file paths without validating where the result resolves. 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() plus Path.GetRelativePath() and reject rooted or parent-relative results, which keeps the resolved path lexically within the intended directory - provided untrusted users cannot create symlinks or junctions under it.
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 lets an attacker supply sequences like "../../web.config" to read files outside the intended uploads folder.
Path.Combine with 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 Through Native Interop
[DllImport("libc", SetLastError = true)]
private static extern int open(string pathname, int flags);
string filename = Request.QueryString["file"];
int fd = open("uploads/" + filename + ".pdf", 0); // VULNERABLE - marshalled as a C string
// Attack: ?file=malicious.exe%00
// The marshalled buffer is null-terminated at the injected byte
// The native call opens "uploads/malicious.exe", not "uploads/malicious.exe.pdf"
Why this is vulnerable: A .NET string can hold a \0, but a C API receives a null-terminated buffer, so everything after the first null is discarded once the string is marshalled. An extension appended in managed code is invisible to the native call, which defeats extension allowlisting. Managed System.IO APIs do not behave this way - File.ReadAllBytes, FileStream and Path.GetFullPath reject embedded nulls with ArgumentException on .NET Framework and on modern .NET alike - so the truncation only appears where a path crosses into unmanaged code, whether through your own DllImport or a native library wrapper. Reject \0 in user-supplied path segments before use, and derive the extension from a server-side allowlist rather than appending it to input.
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 (Path.IsPathRooted(rel) ||
rel == ".." ||
rel.StartsWith(".." + Path.DirectorySeparatorChar) ||
rel.StartsWith(".." + Path.AltDirectorySeparatorChar))
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 the uploads directory not writable by untrusted users, no planted symlink or junction can redirect the resolved path either.
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 (Path.IsPathRooted(rel) ||
rel == ".." ||
rel.StartsWith(".." + Path.DirectorySeparatorChar) ||
rel.StartsWith(".." + Path.AltDirectorySeparatorChar))
{
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 is rooted or begins with
.., the target is outside the allowed directory and is rejected. - This blocks both relative traversal (
..\..\) and absolute path injection by ensuring the resolved path lexically stays under the trusted base directory. Path.GetRelativePath()compares using the platform's own case rules - case-insensitive on Windows, case-sensitive on Linux - so the code needs no hand-written case-sensitive or case-insensitive base-path prefix comparison.- Containment here is lexical.
Path.GetFullPath()resolves.and..textually but does not follow symlinks or junctions, so the guarantee holds only as far as the base directory cannot contain a link that points out of it.
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 (Path.IsPathRooted(rel) ||
rel == ".." ||
rel.StartsWith(".." + Path.DirectorySeparatorChar) ||
rel.StartsWith(".." + Path.AltDirectorySeparatorChar))
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 is rooted or begins with
.., the target is outside the allowed directory and is rejected. FileInfois then used only for filesystem operations and metadata (e.g., existence, streaming).- The containment check is lexical, so a symlink or junction anywhere under the base directory can make the access resolve outside it - including a linked parent directory, which the
FileInfofor a perfectly ordinary file says nothing about.FileInfo.ResolveLinkTarget(returnFinalTarget: true)(.NET 6+) reports only whether this object is a link, so it does not settle the question on its own. - 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 (Path.IsPathRooted(rel) ||
rel == ".." ||
rel.StartsWith(".." + Path.DirectorySeparatorChar) ||
rel.StartsWith(".." + Path.AltDirectorySeparatorChar))
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
CreateNewto 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 (Path.IsPathRooted(rel) ||
rel == ".." ||
rel.StartsWith(".." + Path.DirectorySeparatorChar) ||
rel.StartsWith(".." + Path.AltDirectorySeparatorChar))
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 resolved path is canonicalized and validated for lexical containment using
Path.GetRelativePath(), avoiding fragile string-prefix checks. - Requests that lexically resolve outside the base directory are rejected before accessing the filesystem. As everywhere else on this page, a symlink or junction under
C:\uploadscan make the access resolve outside it. - 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 prevents unsafe reuse of the name elsewhere, such as in a local cache path.
- 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 likefile.pdf.exeare 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.
Common Pitfalls
- Hand-rolling the containment comparison and choosing the case rules yourself -
StringComparison.Ordinalrejects legitimate paths on Windows, where the filesystem is case-insensitive, whileOrdinalIgnoreCaseon Linux treats/uploadsand/Uploadsas the same directory when the OS does not.Path.GetRelativePath()applies the platform's own rules, which is the reason to prefer it over a comparison you write yourself. - Checking containment with
fullPath.StartsWith(baseDir)as a raw string without a trailing separator -C:\uploads-secret\..\..\windows\system32shares theC:\uploadsprefix even though it resolves outside the intended directory; usePath.GetRelativePath()(as shown above) instead of a manual string comparison. - Reading the containment check as proof of where the file physically is -
Path.GetFullPath()resolves.and..as text and does not follow symlinks, junctions or other reparse points, so a link sitting inside the base directory and pointing atC:\Windowspasses every check on this page. Checking the leaf is not enough either - a link can be any ancestor component of the path, andFileInfo.ResolveLinkTarget()answers only for the object it is called on. The control that holds is the storage location: deny untrusted write and create access to the base directory, and do not rely on symbolic-link privilege controls alone - a junction needs no special privilege to create, only write access. - Validating
Request.Pathor the raw query string before ASP.NET's URL-decoding is applied, then using the decoded value (which is what routing and model binding actually hand to your code) for the file operation - the value that was checked and the value that gets opened are not the same string.
Migration Considerations
- Identify file operations: Search for
File.,FileInfo,FileStream,Path.Combine - Trace user input: Find where filename/path parameters come from
- Implement indirect references: Map IDs to filenames where possible
- Add canonical path validation: Use
Path.GetFullPath()and validate - Sanitize filenames: Use
Path.GetFileName()to strip directories - Test with payloads:
../, absolute paths, UNC paths