Skip to content

CWE-77: Command Injection - C# / .NET

Overview

Command injection in .NET occurs when applications construct system commands using untrusted input. The Process class and related APIs can invoke shells, allowing attackers to execute arbitrary commands.

Primary Defence: Avoid shell execution entirely by using ProcessStartInfo with UseShellExecute = false and passing arguments as an array instead of concatenating strings, validate all user input against strict allowlists (e.g., alphanumeric patterns), use parameterized approaches like built-in .NET libraries for specific tasks instead of shelling out, and implement least privilege by running processes with minimal permissions to prevent command injection attacks.

Common Vulnerable Patterns

Process.Start() with String Concatenation

// VULNERABLE - User input in command
using System.Diagnostics;

string ipAddress = Request.QueryString["ip"];
Process.Start("cmd.exe", "/c ping " + ipAddress);

// Attack: ip=8.8.8.8 & whoami
// Executes: ping 8.8.8.8 & whoami

ProcessStartInfo with UseShellExecute

// VULNERABLE - Shell execution enabled
string fileName = Request.Form["file"];

var startInfo = new ProcessStartInfo
{
    FileName = "cmd.exe",
    Arguments = "/c type " + fileName,
    UseShellExecute = true  // DANGEROUS!
};

Process.Start(startInfo);

// Attack: file=data.txt & del /F /Q *.*

PowerShell with User Input

// VULNERABLE - PowerShell command 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\*

Command String Concatenation

// VULNERABLE - String interpolation in commands
string host = Request.Form["host"];

Process.Start(new ProcessStartInfo
{
    FileName = "ping",
    Arguments = $"{host} -n 4"
});

// Attack: host=8.8.8.8 && netsh firewall set opmode mode=disable

Secure Patterns

Use .NET Native APIs (Primary Defense)

// SECURE - Use .NET libraries instead of system commands

using System;
using System.Net;
using System.Net.NetworkInformation;
using System.IO;
using System.Text.RegularExpressions;

// Instead of: Process.Start("ping", host)
public class NetworkHelper
{
    public static bool IsHostReachable(string hostname)
    {
        // Validate hostname
        if (!Regex.IsMatch(hostname, @"^[a-zA-Z0-9.-]+$"))
        {
            throw new ArgumentException("Invalid hostname");
        }

        try
        {
            using (var ping = new Ping())
            {
                var reply = ping.Send(hostname, 5000);
                return reply.Status == IPStatus.Success;
            }
        }
        catch (PingException)
        {
            return false;
        }
    }
}

// Instead of: Process.Start("cmd.exe", "/c del " + file)
public class FileHelper
{
    public static void DeleteFile(string fileName)
    {
        // Validate filename
        if (!Regex.IsMatch(fileName, @"^[a-zA-Z0-9_.-]+$"))
        {
            throw new ArgumentException("Invalid filename");
        }

        string safePath = Path.Combine(@"C:\SafeDirectory", fileName);

        // Ensure file is within safe directory
        string fullPath = Path.GetFullPath(safePath);
        if (!fullPath.StartsWith(@"C:\SafeDirectory\"))
        {
            throw new ArgumentException("Path traversal attempt");
        }

        File.Delete(fullPath);
    }
}

// Instead of: Process.Start("curl", url)
public class HttpHelper
{
    public static async Task<string> FetchUrlAsync(string url)
    {
        using (var client = new HttpClient())
        {
            client.Timeout = TimeSpan.FromSeconds(10);
            return await client.GetStringAsync(url);
        }
    }
}

// Instead of: Process.Start("powershell", "-Command Compress-Archive")
public class ArchiveHelper
{
    public static void CreateZipArchive(string[] files, string archivePath)
    {
        using (var archive = System.IO.Compression.ZipFile.Open(
            archivePath, 
            System.IO.Compression.ZipArchiveMode.Create))
        {
            foreach (string file in files)
            {
                // Validate each filename
                if (!Regex.IsMatch(file, @"^[a-zA-Z0-9_.\\/-]+$"))
                {
                    continue;
                }

                archive.CreateEntryFromFile(file, Path.GetFileName(file));
            }
        }
    }
}

Why this works:

  • Native .NET APIs eliminate shell execution: Ping.Send() uses ICMP sockets, HttpClient uses managed HTTP, ZipFile uses native compression
  • No attack surface for shell metacharacters: Bypassing shell removes risk of ;, &, |, backticks, $()
  • Regex validation provides defense-in-depth: Hostname/filename patterns block path traversal and command separators
  • Path canonicalization prevents directory traversal: Path.Combine + Path.GetFullPath + .StartsWith() ensures files stay in safe directory
  • Cross-platform and type-safe: Managed libraries work consistently across OS, avoiding shell-specific escaping quirks
  • Better error handling: Exceptions provide clear error information vs parsing command output

Process with Argument Escaping

// SECURE - Disable shell execution and validate inputs
using System.Diagnostics;
using System.Text.RegularExpressions;

public class SecureProcessExecutor
{
    public static string ExecutePing(string ipAddress)
    {
        // Validate IP address format
        string ipPattern = @"^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}" +
                          @"(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$";

        if (!Regex.IsMatch(ipAddress, ipPattern))
        {
            throw new ArgumentException("Invalid IP address");
        }

        var startInfo = new ProcessStartInfo
        {
            FileName = "ping",
            Arguments = $"{ipAddress} -n 4",
            UseShellExecute = false,  // CRITICAL: No shell
            RedirectStandardOutput = true,
            CreateNoWindow = true
        };

        using (var process = Process.Start(startInfo))
        {
            return process.StandardOutput.ReadToEnd();
        }
    }

    public static string ExecuteNslookup(string domain)
    {
        // Validate domain name
        if (!Regex.IsMatch(domain, @"^[a-zA-Z0-9.-]+$"))
        {
            throw new ArgumentException("Invalid domain name");
        }

        if (domain.Length > 253)
        {
            throw new ArgumentException("Domain name too long");
        }

        var startInfo = new ProcessStartInfo
        {
            FileName = "nslookup",
            Arguments = domain,
            UseShellExecute = false,
            RedirectStandardOutput = true,
            RedirectStandardError = true,
            CreateNoWindow = true
        };

        using (var process = Process.Start(startInfo))
        {
            process.WaitForExit(5000);

            if (!process.HasExited)
            {
                process.Kill();
                throw new TimeoutException("Command timed out");
            }

            return process.StandardOutput.ReadToEnd();
        }
    }
}

Why this works:

  • UseShellExecute = false bypasses shell: Directly invokes executables via CreateProcess(), preventing metacharacter interpretation
  • Arguments treated as literals: With shell disabled, ;, &, |, backticks, $() are strings, not commands
  • Strict regex validation blocks injection: IP pattern ensures only valid IPs; domain pattern prevents separators/traversal
  • Redirects prevent UI leaks: RedirectStandardOutput + CreateNoWindow avoid interactive prompts or visual pop-ups
  • Timeout prevents DoS: WaitForExit(5000) + Kill() stops slow-resolving domains or infinite loops
  • Length limits prevent buffer overflow: Domain ≤ 253 chars per RFC 1035 protects older DNS utilities

Input Validation Class

// SECURE - Comprehensive input validation
using System.Text.RegularExpressions;

public static class InputValidator
{
    private static readonly Regex HostnamePattern = 
        new Regex(@"^[a-zA-Z0-9.-]+$", RegexOptions.Compiled);

    private static readonly Regex IpPattern = 
        new Regex(@"^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}" +
                 @"(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$", 
                 RegexOptions.Compiled);

    private static readonly Regex FilenamePattern = 
        new Regex(@"^[a-zA-Z0-9_.-]+$", RegexOptions.Compiled);

    private static readonly Regex NumberPattern = 
        new Regex(@"^[0-9]+$", RegexOptions.Compiled);

    public static string ValidateHostname(string input)
    {
        if (string.IsNullOrWhiteSpace(input))
        {
            throw new ArgumentException("Hostname cannot be empty");
        }

        if (input.Length > 253)
        {
            throw new ArgumentException("Hostname too long");
        }

        if (!HostnamePattern.IsMatch(input))
        {
            throw new ArgumentException("Invalid hostname format");
        }

        return input;
    }

    public static string ValidateIPAddress(string input)
    {
        if (!IpPattern.IsMatch(input))
        {
            throw new ArgumentException("Invalid IP address format");
        }

        return input;
    }

    public static string ValidateFilename(string input)
    {
        if (string.IsNullOrWhiteSpace(input))
        {
            throw new ArgumentException("Filename cannot be empty");
        }

        if (input.Contains("..") || input.Contains("/") || input.Contains("\\"))
        {
            throw new ArgumentException("Path traversal attempt detected");
        }

        if (!FilenamePattern.IsMatch(input))
        {
            throw new ArgumentException("Invalid filename format");
        }

        return input;
    }

    public static int ValidateNumber(string input)
    {
        if (!NumberPattern.IsMatch(input))
        {
            throw new ArgumentException("Invalid number format");
        }

        return int.Parse(input);
    }
}

Why this works:

  • Centralized validation: Compiled regex (RegexOptions.Compiled) improves performance for repeated validations; ensures consistency across all command execution paths
  • Allowlist validation: Only alphanumerics, hyphens, dots for hostnames blocks shell metacharacters (&, |, ;, >, <, backticks, $(), newlines)
  • Security checks: Path traversal detection (Contains(".."), /, \\), length limits (≤253 chars) prevent attacks and DoS; null/whitespace checks prevent empty strings
  • Number validation: ^[0-9]+$ + int.Parse prevents arithmetic injection ($(expr 1 + 1)), ensures predictable numeric arguments (ports, counts, timeouts)
  • Testable design: Fail-fast exceptions enable unit testing validation logic separately from command execution

Safe Process Execution Wrapper

// SECURE - Complete safe execution pattern
using System.Diagnostics;
using System.Collections.Generic;

public class SafeCommandExecutor
{
    // Allowlist of permitted commands
    private static readonly Dictionary<string, string> AllowedCommands = 
        new Dictionary<string, string>
    {
        { "ping", @"C:\Windows\System32\ping.exe" },
        { "nslookup", @"C:\Windows\System32\nslookup.exe" },
        { "tracert", @"C:\Windows\System32\tracert.exe" }
    };

    public static string ExecuteCommand(string command, params string[] arguments)
    {
        // Verify command is allowed
        if (!AllowedCommands.ContainsKey(command))
        {
            throw new ArgumentException($"Command not allowed: {command}");
        }

        var startInfo = new ProcessStartInfo
        {
            FileName = AllowedCommands[command],
            UseShellExecute = false,
            RedirectStandardOutput = true,
            RedirectStandardError = true,
            CreateNoWindow = true
        };

        // Add validated arguments
        foreach (string arg in arguments)
        {
            startInfo.ArgumentList.Add(arg);
        }

        using (var process = new Process { StartInfo = startInfo })
        {
            process.Start();

            // Set timeout
            if (!process.WaitForExit(30000))
            {
                process.Kill();
                throw new TimeoutException("Command execution timeout");
            }

            return process.StandardOutput.ReadToEnd();
        }
    }
}

Why this works:

  • Command allowlist: Dictionary with absolute paths (C:\Windows\System32\ping.exe) prevents PATH manipulation; ContainsKey ensures only permitted commands run
  • ArgumentList isolation: ArgumentList.Add() (.NET Core 2.1+) passes arguments as string array - each treated as literal regardless of spaces/special characters
  • Direct invocation: UseShellExecute = false ensures direct process invocation without shell interpretation
  • DoS prevention: 30-second timeout + Kill() prevents infinite hangs (e.g., ping -t); RedirectStandardError captures errors without exposing to users
  • Reusable wrapper: Defense-in-depth combining allowlisting, argument isolation, timeouts, and error handling for audit and policy enforcement
// VULNERABLE Controller Action
using Microsoft.AspNetCore.Mvc;
using System.Diagnostics;

public class DiagnosticsController : Controller
{
    [HttpGet]
    public IActionResult Ping(string host)
    {
        // VULNERABLE
        var result = Process.Start("cmd.exe", $"/c ping {host}");
        return Ok();
    }
}

// SECURE Controller Action
using Microsoft.AspNetCore.Mvc;
using System.Net.NetworkInformation;

public class DiagnosticsController : Controller
{
    [HttpGet]
    public IActionResult Ping(string host)
    {
        try
        {
            // Validate input
            host = InputValidator.ValidateHostname(host);

            // Use .NET API instead of system command
            using (var ping = new Ping())
            {
                var reply = ping.Send(host, 5000);

                return Ok(new
                {
                    Success = reply.Status == IPStatus.Success,
                    RoundtripTime = reply.RoundtripTime,
                    Address = reply.Address?.ToString()
                });
            }
        }
        catch (ArgumentException ex)
        {
            return BadRequest(ex.Message);
        }
        catch (PingException ex)
        {
            return StatusCode(500, "Ping failed: " + ex.Message);
        }
    }
}

Common .NET Command Injection Scenarios

Network Diagnostics

// VULNERABLE
string host = Request.Query["host"];
Process.Start("tracert", host);

// SECURE - Use Traceroute alternative or validate strictly
public async Task<string> TraceRoute(string hostname)
{
    hostname = InputValidator.ValidateHostname(hostname);

    var startInfo = new ProcessStartInfo
    {
        FileName = @"C:\Windows\System32\tracert.exe",
        Arguments = $"-h 30 {hostname}",
        UseShellExecute = false,
        RedirectStandardOutput = true,
        CreateNoWindow = true
    };

    using (var process = Process.Start(startInfo))
    {
        if (!process.WaitForExit(60000))
        {
            process.Kill();
            throw new TimeoutException();
        }

        return await process.StandardOutput.ReadToEndAsync();
    }
}

File Conversion

// VULNERABLE
string file = Request.Form["file"];
Process.Start("magick", $"convert {file} output.pdf");

// SECURE - Use .NET image processing library
using ImageMagick;

public void ConvertImage(string inputFile)
{
    string safeFile = InputValidator.ValidateFilename(inputFile);
    string fullPath = Path.Combine(@"C:\Uploads", safeFile);

    using (var image = new MagickImage(fullPath))
    {
        image.Format = MagickFormat.Pdf;
        image.Write("output.pdf");
    }
}

Verification

After implementing the recommended secure patterns, verify the fix through multiple approaches:

  • Manual testing: Submit malicious payloads relevant to this vulnerability and confirm they're handled safely without executing unintended operations
  • Code review: Confirm all instances use the secure pattern (parameterized queries, safe APIs, proper encoding) with no string concatenation or unsafe operations
  • Static analysis: Use security scanners to verify no new vulnerabilities exist and the original finding is resolved
  • Regression testing: Ensure legitimate user inputs and application workflows continue to function correctly
  • Edge case validation: Test with special characters, boundary conditions, and unusual inputs to verify proper handling
  • Framework verification: If using a framework or library, confirm the recommended APIs are used correctly according to documentation
  • Authentication/session testing: Verify security controls remain effective and cannot be bypassed (if applicable to the vulnerability type)
  • Rescan: Run the security scanner again to confirm the finding is resolved and no new issues were introduced

Additional Resources