ASP.NET Core
Path.Combine discards everything before an absolute path. An application that carefully builds a safe base directory and then combines user input onto it has built nothing.
Path.Combine
// Path.Combine discards ALL preceding arguments as soon as one is rooted.
Path.Combine("/app/data", "reports/q1.pdf") // "/app/data/reports/q1.pdf"
Path.Combine("/app/data", "/etc/passwd") // "/etc/passwd" <- base gone
Path.Combine(@"C:\app\data", @"C:\Windows\win.ini") // "C:\Windows\win.ini"
// So this is not a traversal bug. It is a "the base directory evaporated"
// bug, and it needs no ../ at all — which means every filter looking for
// .. sees nothing wrong.
var path = Path.Combine(_root, Request.Query["file"]);
return PhysicalFile(path, "application/octet-stream");
// ?file=/etc/passwd -> reads /etc/passwd
// ?file=..%2f..%2fsecret -> also works, the ordinary way
// Python's os.path.join has exactly the same behaviour. See
// /guide/python-inclusion — these two are the only runtimes on the
// cheatsheet that do this.The fix
// The parameter is a key. Nothing to combine, nothing to canonicalise.
private static readonly Dictionary<string, string> Reports = new()
{
["q1"] = "q1-2026.pdf",
["q2"] = "q2-2026.pdf",
};
[HttpGet("report")]
public IActionResult Get(string id)
{
if (!Reports.TryGetValue(id ?? "", out var file))
return NotFound();
return PhysicalFile(Path.Combine(_root, file), "application/pdf");
}The execute-shaped sinks
ASP.NET Core has no include(). The closest equivalents resolve a view name to a template, and the framework compiles and runs that template.
Returning a request-derived view name. return View(Request.Query["page"]) lets the caller choose which Razor view renders. The view engine searches its configured locations, so this cannot generally leave the views directory, but it can reach partials and layouts that were never meant to be rendered directly — occasionally including ones that expect a model they will not get, which turns into an information-disclosing exception.
Server.Execute() is classic ASP.NET (Framework), not Core. If you are testing a Framework application it is a genuine execute sink and worth looking for.
Razor runtime compilation. With AddRazorRuntimeCompilation() enabled, .cshtml files are compiled on demand from disk. Combine that with a write primitive anywhere under the views path and you have code execution. It is a development convenience that occasionally ships to production.
In practice, .NET inclusion findings are read primitives. Rate them as disclosure unless you can demonstrate one of the above.
Windows-specific notes
// Both separators are accepted, so a filter checking only for '/' or only
// for '\' catches half the payloads.
// ..\..\..\Windows\win.ini
// ../../../Windows/win.ini
// Alternate Data Streams. A check on the extension sees ".txt"; the
// filesystem opens the stream.
// file.txt:hidden
// web.config::$DATA <- returns the file contents
// Reserved device names still exist. Opening one can hang the request
// rather than error, which is an availability issue as much as a
// disclosure one.
// CON PRN AUX NUL COM1..COM9 LPT1..LPT9
// Trailing dots and spaces are stripped by the Win32 layer, so
// "web.config." and "web.config " both open web.config — after passing a
// check that compared against "web.config" exactly.
// GetFullPath normalises all of the above, which is the argument for
// canonicalising before checking rather than pattern-matching the input.What to grep for
# The footgun itself
grep -rn 'Path.Combine' --include='*.cs' . | grep -iE 'request|query|form|route|param'
# Read sinks
grep -rnE 'File\.(ReadAllText|ReadAllBytes|OpenRead|Open)\(|new (Stream|File)Reader\(' \
--include='*.cs' . | grep -iE 'request|query|param'
# Returning a file by path
grep -rn 'PhysicalFile\|FileStreamResult\|return File(' --include='*.cs' .
# View names from request data
grep -rnE 'return View\(' --include='*.cs' . | grep -iE 'request|query|param'
# Framework-only execute sink
grep -rn 'Server.Execute\|Server.Transfer' --include='*.cs' .
# The correct calls, to confirm they are there
grep -rn 'GetFullPath\|PhysicalFileProvider' --include='*.cs' .Related
os.path.join throws away the base directory the moment the second argument is absolute. No ../ required, and no filter looking for one will see it.
The servlet container normalises the dispatcher path. It does not normalise your File. That gap is where the traversal lives.
Traversal works because of specific, boring rules about how a string becomes a file. Knowing them turns guessing at ../ counts into arithmetic.
An allowlist is the correct fix, so most of them are not really allowlists. Here is how to tell, and what a prefix does and does not stop.
One fix works and the rest are mitigations. Knowing which is which is the difference between a remediation that holds and a config change that gets reported again next year.