Skip to content

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.

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.

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 -Command with 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).

.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.

Common Pitfalls

  • Believing UseShellExecute = false by itself prevents injection when FileName is still cmd.exe with /c, or powershell.exe with -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 using ArgumentList - 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 discrete ArgumentList element.
  • Validating input with a regex allowlist, then building the final command by interpolating the validated value into a single Arguments string - validation and safe invocation are separate controls; if that interpolation code path is later modified without re-adding ArgumentList, 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>

Additional Resources