Skip to content

CWE-78: 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.

Remediation Strategy

PRIMARY FIX - Avoid System Calls

Use .NET native APIs instead of executing commands

  • This eliminates the vulnerability entirely
  • Do NOT use Process.Start() if a .NET API exists

SECONDARY FIX - Use ArgumentList Property

Modern .NET or carefully constructed Arguments strings with UseShellExecute = false

  • WARNING: Only use if Priority 1 is not possible
  • Must disable shell execution

Defense in Depth - Input Validation

Allowlist permitted characters

  • Required in addition to Priority 1 or 2
  • Never use validation alone

Additional Hardening - Least Privilege

Run as low-privilege user

  • Apply alongside other fixes

Decision Tree

Need to execute OS command?
├─ Is there a .NET API alternative? (File, Directory, HttpClient, etc.)
│  ├─ YES → Use .NET API (Priority 1) - PREFERRED SOLUTION
│  └─ NO → Continue
├─ Can you use ProcessStartInfo.ArgumentList?
│  ├─ YES → Use ArgumentList with UseShellExecute=false (Priority 2)
│  └─ NO → Use escaped arguments with UseShellExecute=false (Priority 2)
└─ For ALL solutions:
   ├─ Add input validation (Priority 3)
   └─ Apply least privilege (Priority 4)

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: Using .NET's built-in APIs completely eliminates command injection vulnerabilities by avoiding OS command execution entirely. These APIs operate directly on system resources without invoking shells, preventing attackers from injecting malicious commands through special characters or command separators.

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 other command-line utilities. This eliminates the attack surface entirely - there's no shell to inject commands into, no process arguments to escape, and no possibility of command chaining through metacharacters.

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 commands. By processing files directly through the framework, you eliminate shell invocation and prevent attackers from injecting commands through malicious filenames or archive paths. 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 provide powerful text processing capabilities without invoking grep, findstr, sed, or awk commands. Processing text in-memory through managed code prevents command injection while offering better performance and cross-platform compatibility than shell utilities.

ProcessStartInfo with Argument List (Modern .NET - If Process Execution Required)

WARNING: Avoid executing OS commands if at all possible. This pattern is ONLY for cases where no native .NET API exists for the required functionality. Always exhaust all .NET alternatives 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))
{
    string output = process.StandardOutput.ReadToEnd();
    process.WaitForExit();
}

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.

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)
if (!Regex.IsMatch(inputFile, @"^[a-zA-Z0-9._-]+$") || 
    !Regex.IsMatch(outputFile, @"^[a-zA-Z0-9._-]+$"))
{
    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))
{
    string output = process.StandardOutput.ReadToEnd();
    process.WaitForExit();
}

Why this works: Quoting arguments and setting UseShellExecute = false avoids the OS shell launch path and reduces argument parsing ambiguity for simple cases. 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 execution introduces significant security risk. In almost all cases, there is a better .NET alternative. Only proceed if you have exhausted all other options and have explicit security approval.

If you absolutely 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("-ExecutionPolicy");
psi.ArgumentList.Add("Restricted");
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. The hardcoded script path and restricted execution policy limit what can be executed. ArgumentList ensures parameters are passed as data, not executable commands. However, avoid PowerShell entirely if possible - .NET APIs are safer.

Argument Escaping Utility (When ArgumentList Not Available)

WARNING: Manual escaping is error-prone and has been the source of many vulnerabilities. Use native APIs instead. This is a LAST RESORT only.

For .NET Framework when ArgumentList property is not available.

// ERROR-PRONE - Escape utility for direct process arguments (avoid if possible)
public static string EscapeCommandLineArgument(string argument)
{
    // Escape according to common Windows process-argument quoting rules.
    if (string.IsNullOrEmpty(argument))
        return "\"\"";

    // Check if escaping needed
    if (!Regex.IsMatch(argument, @"[^\w@%+=:,./\\-]"))
        return argument;

    // Escape special characters
    argument = Regex.Replace(argument, @"(\\*)" + "\"", @"$1$1\""");
    argument = Regex.Replace(argument, @"(\\+)$", @"$1$1");

    return "\"" + argument + "\"";
}

// Usage
string safeArg = EscapeCommandLineArgument(userInput);
var psi = new ProcessStartInfo("program.exe", safeArg);
psi.UseShellExecute = false;
Process.Start(psi);

Why this works: This utility quotes backslashes and double quotes for common Windows process argument parsing. It is not a general cmd.exe escaping routine and should not be used to build cmd.exe /c or PowerShell -Command strings. Combined with UseShellExecute = false, it can reduce parsing ambiguity for direct executable launches, but this is error-prone. Prefer ArgumentList or avoiding process execution entirely.

Input Validation (Defense in Depth)

Allowlist Validation

public class InputValidator
{
    public static bool IsValidFilename(string filename)
    {
        // Only allow alphanumeric, underscore, dash, dot
        return Regex.IsMatch(filename, @"^[a-zA-Z0-9._-]+$");
    }

    public static bool IsValidIPAddress(string ip)
    {
        return System.Net.IPAddress.TryParse(ip, out _);
    }
}

ASP.NET Core Model Validation

public class PingRequest
{
    [Required]
    [RegularExpression(@"^([0-9]{1,3}\.){3}[0-9]{1,3}$", 
        ErrorMessage = "Invalid IP address")]
    public string IpAddress { get; set; }
}

[HttpPost("ping")]
public IActionResult Ping([FromBody] PingRequest request)
{
    if (!ModelState.IsValid)
        return BadRequest(ModelState);

    // Input is validated - safe to use with ArgumentList
    var psi = new ProcessStartInfo("ping");
    psi.ArgumentList.Add("-n");
    psi.ArgumentList.Add("4");
    psi.ArgumentList.Add(request.IpAddress);

    // ... execute
}

Security Best Practices

Principle of Least Privilege

var psi = new ProcessStartInfo
{
    FileName = "ping",
    UserName = "restricted_user", // Run as low-privilege user
    Domain = "DOMAIN",
    UseShellExecute = false,
    RedirectStandardOutput = true
};

// Set password securely
using (var password = GetPasswordFromSecureStore())
{
    psi.Password = password;
    using (var process = Process.Start(psi))
    {
        // ...
    }
}

Dependencies and Installation

.NET Version Requirements

Modern .NET (Recommended):

  • Full support for ArgumentList property
  • Modern security features
  • Best command injection protection

.NET Framework 4.5+:

  • Use argument escaping instead of ArgumentList
  • Requires manual escaping logic
  • Consider upgrading to a modern .NET version with ArgumentList

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