CWE-78: Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection') - C#
Overview
OS Command Injection occurs when an application incorporates untrusted data into an operating system command without proper validation or sanitization. Attackers can execute arbitrary commands on the host operating system.
Primary Defence: Use .NET native APIs (File, Directory, HttpClient, etc.) instead of system commands, or if unavoidable, use ProcessStartInfo.ArgumentList on modern .NET with UseShellExecute = false. Never pass user input to command interpreters such as cmd.exe /c or PowerShell -Command.
Common Vulnerable Patterns
String Concatenation with Process.Start()
// VULNERABLE - Command injection via string concatenation
string filename = Request.QueryString["file"];
Process.Start("cmd.exe", "/c dir " + filename);
// Attack example:
// Input: "file.txt & del /f /q C:\*"
// Result: Deletes all files in C:\
Why this is vulnerable: String concatenation in process arguments allows attackers to inject shell metacharacters (&, |, ;, etc.) that execute arbitrary commands when the shell interprets them.
Using PowerShell with User Input
// VULNERABLE - PowerShell command injection
string script = $"Get-Content {userInput}";
Process.Start("powershell.exe", "-Command " + script);
// Attack example:
// Input: "file.txt; Remove-Item -Recurse C:\"
// Result: Recursively deletes C:\ drive
Why this is vulnerable: PowerShell interprets semicolons and other operators as command separators, allowing attackers to inject additional PowerShell commands that execute with application privileges.
Explicit Command Interpreter Allows Command Injection
// VULNERABLE - cmd.exe interprets shell metacharacters
var psi = new ProcessStartInfo()
{
FileName = "cmd.exe",
Arguments = "/c ping -n 4 " + ipAddress,
UseShellExecute = false
};
Process.Start(psi);
// Attack example:
// Input: "8.8.8.8 && net user hacker password /add"
// Result: Creates a new admin user
Why this is vulnerable: The dangerous boundary is the explicit command interpreter (cmd.exe /c). It parses metacharacters such as &, &&, ||, and |, so concatenated user input can add commands. UseShellExecute controls whether the OS shell is used to start the process or open documents; it is not the same thing as invoking cmd.exe, but setting it to false is still preferred for predictable executable launches, stream redirection, and ArgumentList usage.
Unvalidated Input in Process Arguments
// VULNERABLE - No input validation
string userFile = Request.Form["filepath"];
var psi = new ProcessStartInfo("notepad.exe", userFile);
Process.Start(psi);
// Attack example:
// Input: "C:\Windows\win.ini"
// Result: Opens an unintended file if the path is not constrained
Why this is vulnerable: Passing user-controlled paths to external programs can still be unsafe even without a command shell. The called program may treat the value as an option, open unexpected files, or trigger file-association behavior if shell execution is enabled. Validate the path, constrain it to an expected directory, and prefer native .NET APIs for file handling.
Caller-Chosen Executable (No Shell Involved)
// VULNERABLE - the request decides which program runs
string tool = Request.QueryString["tool"]; // expected: "ping" or "tracert"
string target = Request.QueryString["target"];
var psi = new ProcessStartInfo
{
FileName = tool,
UseShellExecute = false
};
psi.ArgumentList.Add(target);
Process.Start(psi);
// Attack example:
// Input: tool=C:\inetpub\uploads\report.exe, target=x
// Result: runs a binary the attacker uploaded, with the application pool's privileges
Why this is vulnerable: There is no cmd.exe here, and ArgumentList is doing exactly what it promises, which is why this passes a review that is looking for /c. The problem is FileName. Pointing it at a file the attacker uploaded runs their code, and powershell.exe or python.exe with the path of an uploaded script does the same. Without a command interpreter the attacker cannot chain a second command on the same line, but they do not need to. The executable does not have to come from the request, either: a program path read from appsettings.json, a database column or an environment variable is the same finding if anyone outside the deployment can change it.
Secure Patterns
Use .NET Native APIs (PREFERRED - Eliminates Command Injection)
// SECURE - Use .NET APIs instead of OS commands
string[] files = Directory.GetFiles(path);
foreach (string file in files)
{
FileInfo fi = new FileInfo(file);
Console.WriteLine($"{fi.Name} {fi.Length} {fi.LastWriteTime}");
}
// More file operations
string content = File.ReadAllText(filepath); // Instead of "type"
File.Copy(source, dest); // Instead of "copy"
Directory.CreateDirectory(path); // Instead of "mkdir"
File.Delete(filepath); // Instead of "del"
Why this works: These APIs operate on system resources directly, without launching a process or invoking a shell, so there is no command line for special characters or command separators to be parsed in.
Use HttpClient for Network Operations
// SECURE - Use HttpClient instead of curl/wget
using (var client = new HttpClient())
{
var response = await client.GetAsync(url);
string content = await response.Content.ReadAsStringAsync();
}
// For downloads
using (var client = new HttpClient())
using (var stream = await client.GetStreamAsync(url))
using (var fileStream = File.Create(localPath))
{
await stream.CopyToAsync(fileStream);
}
Why this works: HttpClient performs network operations through managed .NET code without invoking curl, wget, or another command-line utility. There is no shell to inject commands into and no process arguments to escape, so a URL carrying metacharacters is handled as data.
Use System.IO.Compression for Archives
// SECURE - Use built-in compression instead of 7z/zip commands
using (ZipArchive archive = ZipFile.OpenRead(zipPath))
{
string extractRoot = Path.GetFullPath(extractPath);
foreach (ZipArchiveEntry entry in archive.Entries)
{
string destinationPath = Path.GetFullPath(Path.Combine(extractRoot, entry.FullName));
if (destinationPath != extractRoot &&
!destinationPath.StartsWith(extractRoot + Path.DirectorySeparatorChar, StringComparison.Ordinal))
{
throw new InvalidOperationException("Unsafe archive entry");
}
entry.ExtractToFile(destinationPath);
}
}
// Creating archives
ZipFile.CreateFromDirectory(sourceDir, zipPath);
Why this works: .NET's compression libraries handle archive operations in managed code without calling 7z.exe, zip, or tar, so a hostile filename inside an archive is never parsed as a command. The full-path boundary check also prevents archive entries from escaping the extraction directory.
Use String/Regex APIs for Text Processing
// SECURE - Use .NET string operations instead of grep/findstr
string content = File.ReadAllText(filepath);
var matches = Regex.Matches(content, pattern);
// Line-by-line processing
var lines = File.ReadLines(filepath)
.Where(line => line.Contains(searchTerm));
Why this works: .NET's string and regex APIs do the same work as grep, findstr, sed, or awk without launching any of them. Processing text in memory through managed code prevents command injection, and unlike the shell utilities it behaves the same on every platform the application runs on.
ProcessStartInfo with Argument List (Modern .NET - If Process Execution Required)
WARNING: This pattern is ONLY for cases where no native .NET API does the work. Exhaust the .NET alternatives above first.
// USE WITH CAUTION - When process execution is unavoidable, use ArgumentList
string ipAddress = Request.QueryString["ip"];
// Validate input first
if (!System.Net.IPAddress.TryParse(ipAddress, out _))
{
throw new ArgumentException("Invalid IP address");
}
// Use ArgumentList - NO SHELL
var psi = new ProcessStartInfo
{
FileName = "ping",
UseShellExecute = false,
RedirectStandardOutput = true,
CreateNoWindow = true
};
psi.ArgumentList.Add("-n");
psi.ArgumentList.Add("4");
psi.ArgumentList.Add(ipAddress); // Arguments are properly escaped
using var process = Process.Start(psi)
?? throw new InvalidOperationException("Failed to start ping");
// Bound the whole thing. Both ReadToEnd() and the no-argument WaitForExit()
// block forever on a child that produces nothing and does not exit.
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
Task<string> stdout = process.StandardOutput.ReadToEndAsync(timeout.Token);
try
{
await process.WaitForExitAsync(timeout.Token);
string output = await stdout;
}
catch (OperationCanceledException)
{
process.Kill(entireProcessTree: true);
throw new TimeoutException("ping did not finish within 30 seconds");
}
Why this works: ArgumentList keeps each value as a separate argument and lets .NET build the platform command line. Because this code does not invoke cmd.exe, PowerShell, bash, or another command interpreter, metacharacters such as &, |, and ; are passed to ping as argument data rather than command separators. Input validation provides defense-in-depth and reduces command-specific argument-injection risk.
Every call here needs a deadline. The usual spelling - ReadToEnd() then WaitForExit() - has the read and wait in the right order for a single redirected stream, and still blocks indefinitely on a child that writes nothing and never exits, because neither call takes a timeout. The request thread is gone until the child is. Pass a CancellationToken to both, and kill the process tree on expiry: Kill() without entireProcessTree: true leaves grandchildren running and holding the redirected handle open. If you redirect StandardError as well, start its read before awaiting exit too - a redirected stream nothing consumes is the pipe-buffer deadlock, and it only shows above the OS buffer.
Map the Requested Tool to a Fixed Invocation
// SECURE - the request picks a key; the application picks the program
public static class Diagnostics
{
private static readonly Dictionary<string, string[]> Tools = new()
{
["ping"] = new[] { Path.Combine(Environment.SystemDirectory, "PING.EXE"), "-n", "4" },
["tracert"] = new[] { Path.Combine(Environment.SystemDirectory, "TRACERT.EXE"), "-d" },
};
public static ProcessStartInfo Diagnostic(string? tool, string? target)
{
if (tool is null || !Tools.TryGetValue(tool, out var invocation))
{
throw new ArgumentException("Unknown tool");
}
if (target is null || !InputValidator.IsValidIPAddress(target))
{
throw new ArgumentException("Invalid IP address");
}
var psi = new ProcessStartInfo
{
FileName = invocation[0],
UseShellExecute = false,
RedirectStandardOutput = true,
CreateNoWindow = true
};
foreach (var arg in invocation.Skip(1))
{
psi.ArgumentList.Add(arg);
}
psi.ArgumentList.Add(target);
return psi;
}
}
Why this works: The caller's string is only ever a dictionary key. The executable and its fixed options come from Tools, so an uploaded path, powershell.exe or cmd.exe finds no entry and is rejected before a process starts. Environment.SystemDirectory makes each entry an absolute path, so neither PATH nor the working directory decides which binary runs. The whole invocation is fixed except for one operand, and IsValidIPAddress() (below) accepts only an address in its canonical form, which cannot begin with - or /. Start the returned ProcessStartInfo with the deadline pattern from the previous example.
ProcessStartInfo with Escaped Arguments (.NET Framework)
WARNING: This pattern is inherently risky. Prefer native .NET APIs. Only use when no alternative exists AND ArgumentList is not available in your target framework.
// RISKY - For legacy third-party tools when ArgumentList not available
string inputFile = Request.QueryString["file"];
string outputFile = Request.QueryString["output"];
// Validate filenames (allowlist approach). \A and \z, not ^ and $: in .NET
// both $ and \Z also match immediately before a trailing newline, so
// '^...$' accepts "report.csv\n" (measured on .NET 10). The first character
// excludes '-' so neither value can be read as an option by the tool.
if (!Regex.IsMatch(inputFile, @"\A[a-zA-Z0-9_.][a-zA-Z0-9._-]*\z") ||
!Regex.IsMatch(outputFile, @"\A[a-zA-Z0-9_.][a-zA-Z0-9._-]*\z"))
{
throw new ArgumentException("Invalid filename");
}
// Example: calling a legacy report generator or third-party tool
var psi = new ProcessStartInfo
{
FileName = @"C:\Tools\LegacyReportGenerator.exe",
Arguments = $"\"{inputFile}\" \"{outputFile}\"", // Quoted to prevent injection
UseShellExecute = false,
RedirectStandardOutput = true,
WorkingDirectory = @"C:\Data"
};
using (var process = Process.Start(psi))
{
// .NET Framework has no WaitForExitAsync, but WaitForExit(int) still takes
// a deadline. Read on another thread so the read cannot outlive it.
var stdout = Task.Run(() => process.StandardOutput.ReadToEnd());
if (!process.WaitForExit(30000))
{
process.Kill();
throw new TimeoutException("Report generator did not finish within 30 seconds");
}
string output = stdout.Result;
}
Why this works: Quoting arguments and setting UseShellExecute = false avoids the OS shell launch path and reduces argument parsing ambiguity for simple cases. The read runs on a separate thread so it drains the pipe while WaitForExit(int) counts down - reading inline first would block until EOF and the timeout below it would never be evaluated. Hardcoding the executable path prevents attackers from choosing a different program. Input validation with allowlisting blocks malformed filenames and command-specific option injection attempts. However, ArgumentList is preferred on modern .NET because manual quoting is easy to get wrong. Note: For file operations, always use .NET APIs like File.ReadAllText() instead.
PowerShell Execution (Use with Extreme Caution)
⛔ STRONGLY DISCOURAGED: PowerShell is an interpreter, so any value it parses can become code. In almost all cases there is a .NET alternative. Only proceed if you have exhausted the other options and have explicit security approval.
If you must use PowerShell:
- Use constrained language mode
- Allowlist commands
- Never use
-Commandwith user input - Prefer PowerShell SDK (System.Management.Automation) over process execution
// ⛔ AVOID IF POSSIBLE - If PowerShell is absolutely necessary
var psi = new ProcessStartInfo
{
FileName = "powershell.exe",
UseShellExecute = false,
RedirectStandardOutput = true
};
// Use -File instead of -Command with hardcoded script
psi.ArgumentList.Add("-NoProfile");
psi.ArgumentList.Add("-NonInteractive");
psi.ArgumentList.Add("-ExecutionPolicy");
psi.ArgumentList.Add("AllSigned");
psi.ArgumentList.Add("-File");
psi.ArgumentList.Add("C:\\Scripts\\approved-script.ps1");
psi.ArgumentList.Add(validatedParameter); // Only pass validated params
using (var process = Process.Start(psi))
{
// ...
}
Why this works: Using -File instead of -Command prevents PowerShell from interpreting user input as code: everything after the script path arrives as a parameter to that script, and ArgumentList passes each one as a separate argument rather than a string PowerShell has to re-parse. The hardcoded script path is what limits which code runs. -NoProfile stops per-user profile scripts from executing first, which is the one part of this invocation an attacker with a foothold on the host could otherwise influence.
AllSigned requires the script to carry a valid Authenticode signature, so an unsigned edit of approved-script.ps1 fails to load. Two things about it are worth knowing before copying this: the script has to actually be signed or it will not run at all, and Microsoft is explicit that execution policy is not a security boundary - it stops accidental execution, not a determined caller, who can pass -EncodedCommand or pipe the script to stdin. Do not substitute Restricted here in the belief that it is the stricter choice: it blocks every script file, so -File cannot run at all and the example does nothing. The controls that carry weight are the hardcoded path, ArgumentList, and constrained language mode. Better still, avoid PowerShell entirely - .NET APIs are safer.
Input Validation (Defense in Depth)
Allowlist Validation
public class InputValidator
{
public static bool IsValidFilename(string filename)
{
// Alphanumeric, underscore, dash, dot - but never a leading dash, or
// the name is read as an option by whatever receives it (CWE-88).
// \A...\z, not ^...$: in .NET both $ and \Z also match immediately
// before a trailing newline, so "^...$" accepts "report.csv\n".
return Regex.IsMatch(filename, @"\A[a-zA-Z0-9_.][a-zA-Z0-9._-]*\z");
}
public static bool IsValidIPAddress(string ip)
{
return System.Net.IPAddress.TryParse(ip, out var parsed)
&& parsed.ToString() == ip;
}
}
ASP.NET Core Model Validation
// Parse, do not pattern-match. A shape-only regex such as
// ^([0-9]{1,3}\.){3}[0-9]{1,3}$ accepts 999.999.999.999, and in .NET it also
// accepts a trailing newline, so it is not an IP validator.
public sealed class IPv4Attribute : ValidationAttribute
{
public override bool IsValid(object value) =>
value is string s
&& IPAddress.TryParse(s, out var parsed)
&& parsed.AddressFamily == AddressFamily.InterNetwork
&& parsed.ToString() == s; // reject non-canonical forms TryParse accepts
}
public class PingRequest
{
[Required]
[IPv4(ErrorMessage = "Invalid IP address")]
public string IpAddress { get; set; }
}
[HttpPost("ping")]
public IActionResult Ping([FromBody] PingRequest request)
{
if (!ModelState.IsValid)
return BadRequest(ModelState);
// IpAddress parsed as IPv4, so it cannot begin with '-' or carry a newline
var psi = new ProcessStartInfo("ping");
psi.ArgumentList.Add("-n");
psi.ArgumentList.Add("4");
psi.ArgumentList.Add(request.IpAddress);
// ... execute
}
Why the attribute rather than a pattern: [RegularExpression] is the obvious place to put this, and the obvious pattern is wrong three times over. Measured on .NET 10, ^([0-9]{1,3}\.){3}[0-9]{1,3}$ accepts 999.999.999.999 (it has no idea an octet stops at 255), "8.8.8.8\n" ($ matches before a trailing newline) and 010.1.1.1. Parse, do not shape-check: a parse either produces the type or fails.
The round-trip comparison is the part that looks redundant and is not, because TryParse is more permissive than it reads. Measured on the same runtime, it accepts 010.1.1.1 and returns 8.1.1.1 - the leading zero is read as octal - and accepts the three-part form 8.8.8, returning 8.8.0.8. Both succeed, and both give you an address the caller did not ask for. Comparing parsed.ToString() against the original string is what rejects them.
InputValidator.IsValidIPAddress above applies the same round-trip check to IPv4 and IPv6. It requires the exact text returned by .NET's ToString(), so alternative IPv6 spellings, including uppercase or expanded forms, are also rejected. This is an input-format policy; ArgumentList and avoiding a command interpreter provide the command-injection protection.
Considerations
Removing the interpreter does not finish the finding. ArgumentList stops cmd.exe and PowerShell from parsing the value. It does not
stop the program you launched from parsing it. A filename of --to-command=...
reaches tar as an option, and no interpreter was involved. Before closing a
CWE-78 finding, ask what the target program does with a value beginning with -
or /, and either reject those values or place -- ahead of the user-controlled
arguments where the program supports it. What is left is
CWE-88, and the scanner usually goes quiet either way
once the interpreter is gone.
The program itself is part of the judgement. ProcessStartInfo("ping") with a
validated IP and ProcessStartInfo("python.exe") with a script argument have the
same shape and very different exposure: the second hands its argument to an
interpreter, so any value is code. The same applies to .bat and .cmd wrappers,
below, and to tools that accept a command inside an option (ssh -o ProxyCommand,
git -c core.sshCommand).
The examples on this page write ping without a path, for readability.
Process.Start with UseShellExecute = false resolves a bare name against
PATH and against the working directory as well - measured on .NET 10 /
Windows 11, a bare name ran from the working directory. Pin the absolute path in
production, as the mapping-table example does with Environment.SystemDirectory.
What a bare name risks is CWE-426, and the
generic page covers when it matters.
.bat and .cmd targets put the shell back. Windows has no argv array at the system-call level: CreateProcess takes a single
command-line string and the child re-parses it. ArgumentList builds that string
correctly for a normal executable - measured on .NET 10 / Windows 11, an argument
of x"&echo INJECTED& arrives at a native .exe intact as one argument.
Point the same call at a batch file and it does not. cmd.exe parses the command
line for a .bat or .cmd, so the same argument executes echo INJECTED. .NET
does not escape for cmd.exe here and does not refuse the call; Node.js
(CVE-2024-27980) and PHP (CVE-2024-1874) shipped runtime fixes for the same defect
class, .NET did not.
If a deployment shells out through a batch wrapper, treat that wrapper as a shell:
call the real executable directly, or validate the arguments against cmd.exe
parsing rules rather than against the C runtime's. ArgumentList alone is not
enough.
Where to put least privilege. Run the process as a restricted principal at the host level - a Windows service account, a container runAsUser, or an AppContainer - rather than through ProcessStartInfo.UserName/Password. Those properties are Windows-only and throw PlatformNotSupportedException elsewhere, and Password takes a SecureString, which Microsoft documents as not recommended for new development and which does not encrypt on non-Windows platforms. Either way this bounds the damage; it does not close the finding.
Testing
- Normal input:
IsValidFilename("report.csv")andIsValidIPAddress("127.0.0.1")returntrue, andDiagnostic("ping", "127.0.0.1")started as in theArgumentListexample exits 0 with ping output. A pattern that refuses everything passes every assertion below, so the accept is what distinguishes a fix from a broken validator. - Boundary input:
IsValidFilename("")returnsfalse."..","."and a 300-character name all returntrue- the character class allows dots and has no length limit, so it is not a path check. Because it also rejects/and\, one validated name cannot reach a file outside the directory it is resolved against:..names that directory's parent itself, and what the tool does with a directory is the tool's business. Assert that..and.are accepted anyway, so nobody reads the validator as more than it is, and add a length limit if the tool needs one. Where a name can carry separators, test containment separately - see CWE-22 - C#. - Anchoring:
IsValidFilename("report.csv\n")returnsfalse. The same pattern written with^...$, or with\A...\Z, returnstrueon .NET 10; only\A...\zrefuses, so this assertion is what separates the three.IsValidIPAddress("8.8.8.8\n")returnsfalseas well. - Canonical addresses:
IsValidIPAddressand[IPv4]both reject"010.1.1.1","8.8.8"and"134744072", each of whichIPAddress.TryParsealone accepts - as 8.1.1.1, 8.8.0.8 and 8.8.8.8.[IPv4]also rejects"::1", whichIsValidIPAddressaccepts. - Argument injection:
"-debug"is rejected byIsValidFilename,IsValidIPAddressand[IPv4]before it reaches the process. It contains no shell metacharacter, so none of the payloads below covers it. - Program choice:
Diagnostic(@"C:\inetpub\uploads\report.exe", "127.0.0.1"), and the same call with"cmd.exe","PING"ornull, throwsArgumentExceptionbefore any process starts.Diagnostic("ping", "127.0.0.1")returns aFileNameequal toPath.Combine(Environment.SystemDirectory, "PING.EXE")and anArgumentListof-n,4,127.0.0.1. Assert on those rather than on output, because a call that ran the wrong binary can still exit 0. - Batch wrapper: if any call site targets a
.bator.cmdfile, assert that an argument ofx"&echo INJECTED&does not produceINJECTED.ArgumentListpasses this against a native.exeand fails against a batch wrapper, which is the whole point of the check. - 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
- Believing
UseShellExecute = falseby itself prevents injection whenFileNameis stillcmd.exewith/c, orpowershell.exewith-Command- the flag controls how the .NET process-launch mechanism starts the process, not whether the target interpreter parses shell metacharacters in the arguments you hand it. - Writing an
EscapeCommandLineArgument-style quoting helper instead of usingArgumentList- hand-rolled quoting only covers the specific Windows argv-parsing convention it was written for and silently breaks on edge cases (trailing backslashes before quotes, mixed quote characters); it is not equivalent to keeping each argument as a discreteArgumentListelement. - Validating input with a regex allowlist, then building the final command by interpolating the validated value into a single
Argumentsstring - validation and safe invocation are separate controls; if that interpolation code path is later modified without re-addingArgumentList, the shell-parsing risk returns even though the input is still "validated."
Dependencies and Installation
.NET Version Requirements
ProcessStartInfo.ArgumentList needs .NET Core 2.1 or later, which includes every
supported .NET version. It does not exist on .NET Framework, where the only option
is the Arguments string and the quoting rules it implies - that is the case the
"Escaped Arguments" pattern above covers, and it is a reason to move the code to a
supported .NET rather than a reason to write a quoting helper.
NuGet Packages
<!-- For .NET Framework projects needing compression -->
<PackageReference Include="System.IO.Compression" />
<PackageReference Include="System.IO.Compression.ZipFile" />
<!-- For HTTP operations in older frameworks -->
<PackageReference Include="System.Net.Http" />
Configuration (Optional - Security Hardening)
<!-- web.config or app.config -->
<configuration>
<system.diagnostics>
<trace autoflush="true">
<listeners>
<add name="processMonitor"
type="System.Diagnostics.TextWriterTraceListener"
initializeData="process-audit.log" />
</listeners>
</trace>
</system.diagnostics>
</configuration>