CWE-77: Command Injection - C# / .NET
Overview
Command injection in .NET occurs when code constructs a system command from untrusted input and hands it to Process.Start() or ProcessStartInfo with a shell as the program to run. cmd.exe /c, powershell.exe -Command, and any other shell wrapper interpret metacharacters (&, |, ;, backticks, $()) in the argument string, letting an attacker chain in arbitrary commands.
Primary defense: avoid shell execution entirely by using .NET's own libraries for the task (networking, file I/O, compression) instead of shelling out. When a system command genuinely cannot be avoided, use ProcessStartInfo with UseShellExecute = false and ArgumentList, never a concatenated Arguments string, plus strict allowlist validation and least-privilege execution.
Common Vulnerable Patterns
cmd.exe /c with a concatenated argument string
// VULNERABLE - the launched program is a shell, so it parses what it is given
string ipAddress = Request.QueryString["ip"];
Process.Start("cmd.exe", "/c ping " + ipAddress);
// Attack: ip = "8.8.8.8 & whoami"
Why this is vulnerable: everything after /c is a command line for cmd.exe to parse, so the metacharacters in ipAddress are syntax rather than data - &, &&, || and | as operators, and ^ as the escape character that lets a payload smuggle any of them past a naive filter. & alone is enough - it needs no valid first command and no quoting. The fix people reach for first is UseShellExecute = false, and it changes nothing here: that setting controls whether .NET asks the operating system shell to open the target, and the shell doing the parsing in this code is cmd.exe itself, named explicitly as the program to run. The same applies to moving the string into ArgumentList - each element then arrives intact, but the element is still a command line and cmd.exe still parses it. What removes the weakness is launching ping.exe directly and dropping cmd.exe from the call.
powershell.exe -Command with an interpolated string
// VULNERABLE - -Command takes a script, so interpolation is script injection
string domain = Request.QueryString["domain"];
var psi = new ProcessStartInfo
{
FileName = "powershell.exe",
Arguments = $"-Command Resolve-DnsName {domain}"
};
Process.Start(psi);
// Attack: domain = "example.com; Remove-Item -Recurse C:\Temp\*"
Why this is vulnerable: -Command tells PowerShell that the rest of the line is a script to parse and execute, so ;, |, &, $(...) and newlines in domain are all live. PowerShell makes this worse than cmd.exe rather than better: the injected code runs in a full scripting environment with .NET reflection, Invoke-Expression, and network cmdlets available, so a single statement is enough to fetch and run a payload without touching disk. Escaping is not a workable defence here because PowerShell's quoting rules differ from the C runtime rules that .NET applies when it builds the command line, so the string the developer escapes is not always the string PowerShell parses. If a PowerShell script genuinely has to run, use -File script.ps1 with ArgumentList entries for its parameters - -File binds the remaining arguments as parameter values instead of parsing them as code.
Secure Patterns
Use .NET Native APIs (Primary Defense)
Replace the shelled-out command with the equivalent managed library - there is then no shell to inject into at all:
using System.Net.NetworkInformation;
using System.Text.RegularExpressions;
// \A and \z, not ^ and $: see "Validate Input" below.
// The first character cannot be '-', so the value can never be read as a flag.
private static readonly Regex Hostname =
new(@"\A[a-zA-Z0-9][a-zA-Z0-9.-]*\z", RegexOptions.Compiled);
// Instead of: Process.Start("ping", host)
public static bool IsHostReachable(string hostname)
{
if (!Hostname.IsMatch(hostname))
throw new ArgumentException("Invalid hostname");
using var ping = new Ping();
return ping.Send(hostname, 5000).Status == IPStatus.Success;
}
// Instead of: Process.Start("magick", "convert " + file + " output.pdf") -> use ImageMagick / System.Drawing
// Instead of: Process.Start("curl", url) -> use HttpClient
// Instead of: Process.Start("powershell", "-Command Compress-Archive") -> use System.IO.Compression.ZipFile
Why this works: Ping, HttpClient, and ZipFile talk to the OS directly through managed APIs - there is no intermediate shell to parse metacharacters, so ;, &, |, and backticks in the input are inert.
The deeper reason to prefer this over escaping is that it removes the weakness rather than managing it. Escaping has to be correct at every call site, forever: one refactor back to a concatenated Arguments string, one UseShellExecute = true copied from an older sample, and the vulnerability returns. A native API has no shell to inject into, so there is no rule for a future maintainer to get wrong. This matters especially on Windows, because argument quoting there is applied by each program's own parser rather than by the OS, so there is no single escaping rule that is correct for every target executable.
It also avoids inheriting vulnerabilities from the CLI tool itself. ImageTragick (CVE-2016-3714) let a crafted image reach ImageMagick's delegate handling and execute shell commands - the injection happened inside the tool, past any escaping the calling application did. Using a managed imaging library removes that exposure entirely.
Use ArgumentList, Never a Concatenated Arguments String (When a Command Is Unavoidable)
Some tasks (calling a vendor CLI, tracert, a signing tool) have no .NET library equivalent. When that's the case:
using System.Diagnostics;
public class SafeCommandExecutor
{
// Allowlist of permitted commands, resolved to absolute paths (blocks PATH manipulation)
private static readonly Dictionary<string, string> AllowedCommands = new()
{
{ "ping", @"C:\Windows\System32\ping.exe" },
{ "nslookup", @"C:\Windows\System32\nslookup.exe" },
};
public static async Task<string> ExecuteAsync(
string command, string[] arguments, CancellationToken ct = default)
{
if (!AllowedCommands.TryGetValue(command, out var path))
throw new ArgumentException($"Command not allowed: {command}");
var startInfo = new ProcessStartInfo
{
FileName = path,
UseShellExecute = false, // CRITICAL: no shell, no metacharacter interpretation
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
};
foreach (var arg in arguments)
{
// An argument beginning with '-' is an option to the program, not a
// value. ArgumentList delivers it faithfully either way (CWE-88).
if (arg.StartsWith('-'))
throw new ArgumentException($"Argument may not start with '-': {arg}");
startInfo.ArgumentList.Add(arg); // each argument stays a single literal token
}
using var process = Process.Start(startInfo)
?? throw new InvalidOperationException("Failed to start process");
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(TimeSpan.FromSeconds(30));
// Start both reads BEFORE waiting. A redirected pipe nobody drains fills
// up, the child blocks in write(), and the wait below times out on work
// that was perfectly legitimate.
Task<string> stdout = process.StandardOutput.ReadToEndAsync(timeout.Token);
Task<string> stderr = process.StandardError.ReadToEndAsync(timeout.Token);
try
{
await process.WaitForExitAsync(timeout.Token);
if (process.ExitCode != 0)
throw new InvalidOperationException(
$"{command} exited {process.ExitCode}: {await stderr}");
return await stdout;
}
catch (OperationCanceledException)
{
process.Kill(entireProcessTree: true);
throw new TimeoutException("Command execution timed out");
}
}
}
Why this works: UseShellExecute = false invokes the executable directly via CreateProcess() instead of through cmd.exe, so there is no shell present to interpret ;, &, |, or backticks - they are passed through as literal argument text. ArgumentList keeps each argument as a separate token regardless of what characters it contains, which string-built Arguments cannot guarantee. The command allowlist (resolved to absolute paths) stops an attacker from substituting an entirely different executable, and the timeout prevents a hung or slow-resolving command from becoming a denial of service.
Read the pipes before waiting, not after. The obvious spelling of this method - WaitForExit(30000) and then StandardOutput.ReadToEnd() - is the documented Process deadlock and it is invisible in testing, because the child has to outrun the OS pipe buffer before anything goes wrong. Measured on .NET 10: a child writing 200 KB to stdout with both pipes redirected and neither drained never exits, WaitForExit(8000) returned false after 8020 ms, and the process was killed with its output lost. A one-line child returns instantly and the same code looks correct. Starting ReadToEndAsync on both streams first, then awaiting exit, drains as the child writes. Redirecting a stream you never read is the same bug with no symptom until volume arrives, so redirect StandardError only if something consumes it.
Validate Input (Defense in Depth)
Even with the shell disabled, validate before use - reject anything outside an allowlist pattern (an anchored hostname pattern, digits-only for numeric arguments, no ..///\ in filenames). This is a supplementary control, not a substitute for UseShellExecute = false and ArgumentList.
Anchor with \A and \z, not ^ and $. In .NET, $ matches immediately before a final newline as well as at the end of the input, so Regex.IsMatch("evil.com\n", @"^[a-zA-Z0-9.-]+$") returns true - measured on .NET 10 - and an allowlist written to be strict admits a value carrying a control character. \A[a-zA-Z0-9.-]+\z returns false for the same input. (\Z is not the fix: like $, it tolerates the trailing newline.) The same pattern in Java is safe through matches() and unsafe in Python through re.match - so a pattern is not portable just because the syntax is.
Reject a leading hyphen. [a-zA-Z0-9.-]+ includes -, so -debug passes and ArgumentList hands it to the program as an option rather than a value. Anchor the first character to something that cannot introduce a flag (\A[a-zA-Z0-9][a-zA-Z0-9.-]*\z), and pass -- before the positional arguments where the command supports it. This is CWE-88, and UseShellExecute = false does not touch it.
Framework-Specific Guidance
ASP.NET Core Controller Actions
// VULNERABLE
[HttpGet]
public IActionResult Ping(string host)
{
var result = Process.Start("cmd.exe", $"/c ping {host}");
return Ok();
}
// SECURE
[HttpGet]
public IActionResult Ping(string host)
{
if (!Hostname.IsMatch(host)) // \A[a-zA-Z0-9][a-zA-Z0-9.-]*\z
return BadRequest("Invalid hostname");
using var ping = new Ping();
var reply = ping.Send(host, 5000);
return Ok(new { Success = reply.Status == IPStatus.Success, reply.RoundtripTime });
}
Route model binding does not sanitize for command injection - a bound string parameter is still untrusted input at the point it reaches Process.Start.
Considerations
The first question is whether a subprocess is needed at all. Most findings of this kind are a shell call standing in for a library the platform already ships - fetching a URL, unpacking an archive, resizing an image. Replacing the call removes the weakness rather than containing it, and usually removes error handling and portability problems with it. That is a rewrite, so weigh it against hardening the existing call; but a hardened subprocess still runs another program with your privileges, and the library does not.
The timeouts in the examples are placeholders for a decision you have to make. A subprocess with no bound can hang a request thread indefinitely, so one is needed - but the right value comes from what the command legitimately does. Too short and normal work fails under load; too long and an attacker who can influence the input has a cheap way to exhaust your workers. Bound the output as well as the time: a command that returns unbounded data to an in-memory buffer is a denial of service whether or not the arguments were validated.
An allowlist is only as good as its most permissive entry. Restricting which command may run is worth doing, but a permitted command that itself takes a path, a URL, or a format string moves the problem one level down rather than solving it. Prefer allowing a fixed set of complete invocations over allowing a program and validating its arguments separately.
Testing
- Normal input:
IsHostReachable("example.com")and a permittedExecuteAsynccall both succeed. A pattern that rejects everything passes every assertion below, so the accept is what tells a working validator from a broken one. - Boundary input: an empty string, a 300-character hostname, and a filename containing
..are all rejected. - Anchoring:
"evil.com\n"is rejected.^...$accepts it and\A...\Zaccepts it; only\A...\zrefuses, so this assertion is what separates the three. - Argument injection:
"-debug"is rejected before it reaches the process. It contains no shell metacharacter, so none of the payloads below covers it. - Output volume: a command writing more than a pipe buffer's worth of output returns its output rather than timing out. Use a chatty command - a one-line child passes against the deadlocking version too.
- Malicious input:
8.8.8.8 & whoami,`whoami`,$(Get-Process), and; Remove-Item -Recurse C:\Temp\*are all rejected or treated as a single literal argument, never executed.
Common Pitfalls
ProcessStartInfo.Argumentseven withUseShellExecute = false: Disabling the shell stops the OS shell from parsing metacharacters, but the child process still receives a single command-line string that it (or the C runtime) splits into arguments itself. Building that string via concatenation can still let an attacker break out of a quoted argument. UseArgumentListinstead ofArguments- it keeps each argument as a separate token regardless of content.FileNameset to an interpreter, not the target program: SettingFileName = "cmd.exe"orFileName = "powershell.exe"still hands the argument string to a shell/interpreter even whenUseShellExecute = false- that flag only controls whether Windows usesShellExecuteExto launch the process, not whether the launched process is itself a shell.; & | $()in the arguments is still parsed bycmd.exe/PowerShell's own grammar.- Assuming the legacy
Process.Start(string, string)overload is safer: This older two-string overload still requires the caller to build and manually escape the arguments string - it offers no protection overProcessStartInfo.Arguments. PreferProcess.Start(ProcessStartInfo)withArgumentList.