flawopen.com/command-injection/Csharp
OS Command Injection in C# (System.Diagnostics.Process)
Passing input through /bin/sh -c, splicing it into the single ProcessStartInfo.Arguments string, or letting the request name the program lets a request run code on your .NET server (CWE-78). Why ArgumentList is the fix, how a quote character breaks out of Arguments, and how to cap the process with a CancellationToken.
A stage manager receives a cue sheet with one field for a performer and another for a line of dialogue. If the manager reads the whole sheet as one script, punctuation inside the dialogue can become a new stage direction. ProcessStartInfo.Arguments is a parsed command string; ArgumentList keeps each value in its own argument slot. But a fixed argument list still must not let a request choose the executable: use a code-defined tool allow-list.
Core Concepts & Subsystem Terms
ProcessStartInfo.ArgumentList- A collection where each entry is exactly one argument. The runtime quotes and escapes every entry when it builds the command line, so a
"or space inside a value cannot split it. Available since .NET Core 2.1. ProcessStartInfo.Arguments (one string)- A single command-line string that is split back into arguments with the Windows quoting rules (also on Linux and macOS). A
"in user text closes the quote, and whatever follows becomes new arguments or options. /bin/sh -c and cmd.exe /c- Starting a shell hands the whole string to a command interpreter that parses
;,&,|and$( ). Any user text in that string can start extra commands. UseShellExecute- When
true, the file name is opened through the operating system shell (file associations, URLs) instead of being executed directly, and output cannot be redirected. It defaults tofalseon .NET Core and later buttrueon .NET Framework, so set it explicitly. Argument injection and Git option boundaries- Separate argv entries prevent shell parsing, but the target program still parses its own options. For an untrusted Git revision, use
git rev-parse --verify --end-of-options "$revision": this ends option parsing.--has command-specific operand or path semantics; ingit log, it separates revisions from paths. These markers are not interchangeable. Validate that the value is a revision before use.
Framework mappings
- CWE reference for the weakness described in this guide: CWE-78
- OWASP Top 10:2025 — A05:2025 Injection
Step-by-Step Attack Flow
Request value reaches the helper
A C# controller or minimal API binds host, refName, tool, or file and passes it to a helper that calls Process.Start.
The value becomes part of a command
/bin/sh -c parses a concatenated host as shell syntax; interpolating refName into ProcessStartInfo.Arguments lets quotes alter parsed arguments; passing tool to Process.Start lets the caller select the program.
The shell or the program interprets it
The shell treats operators as instructions, Git or another utility may parse an unexpected option, or Windows starts an attacker-selected executable. ArgumentList avoids string splitting but does not make an untrusted executable or argument authorized.
The attacker runs code on the server
The program runs with the web service identity and its reachable files, credentials, and network. Fix the boundary with a fixed executable or allow-list, validated values, UseShellExecute = false, separate ArgumentList entries, and a timeout.
Source Code: Flaw vs. Secure Implementation
// Tools.cs: each method lets request data change what runs
using System.Diagnostics;
namespace Example.Tools;
public static class Tools
{
// sh -c parses the concatenated string, ; and all
public static Process? Ping(string host) =>
Process.Start("/bin/sh", "-c \"ping -c 1 " + host + "\"");
// a " in refName closes the quote and adds new arguments
public static Process? CommitLog(string refName) =>
Process.Start(new ProcessStartInfo("git")
{
Arguments = $"log \"{refName}\"",
UseShellExecute = false,
});
// the request chooses which program is executed
public static Process? Optimize(string tool, string file) =>
Process.Start(tool, new[] { file });
}
// Tools.cs: ArgumentList, validation, a lookup table and a timeout
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
namespace Example.Tools;
public static partial class Tools
{
[GeneratedRegex("^[A-Za-z0-9][A-Za-z0-9.-]{0,252}$")]
private static partial Regex HostPattern();
[GeneratedRegex("^[A-Za-z0-9][A-Za-z0-9._/-]{0,99}$")]
private static partial Regex RefPattern();
private static readonly Dictionary<string, string> Optimizers = new()
{
["png"] = "/usr/bin/optipng",
["jpg"] = "/usr/bin/jpegoptim",
};
// no shell, one ArgumentList entry per argument, 5-second limit
private static async Task<string> RunAsync(string fileName, IEnumerable<string> args)
{
var info = new ProcessStartInfo(fileName)
{
UseShellExecute = false,
RedirectStandardOutput = true,
};
foreach (var arg in args)
{
info.ArgumentList.Add(arg);
}
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
using var process = Process.Start(info)!;
try
{
var output = await process.StandardOutput.ReadToEndAsync(timeout.Token);
await process.WaitForExitAsync(timeout.Token);
return output;
}
catch (OperationCanceledException)
{
process.Kill(entireProcessTree: true);
throw new TimeoutException("command timed out");
}
}
// ping runs directly; host is one validated argument
public static Task<string> PingAsync(string host) =>
HostPattern().IsMatch(host)
? RunAsync("ping", ["-c", "1", host])
: throw new ArgumentException("invalid host name", nameof(host));
// --end-of-options makes Git treat refName as a revision
public static Task<string> CommitLogAsync(string refName) =>
RefPattern().IsMatch(refName)
? RunAsync("git", ["log", "--end-of-options", refName, "--"])
: throw new ArgumentException("invalid ref", nameof(refName));
// the format selects a fixed absolute path
public static Task<string> OptimizeAsync(string format, string file) =>
Optimizers.TryGetValue(format, out var tool) && !file.StartsWith('-')
? RunAsync(tool, [file])
: throw new ArgumentException("unsupported format or file name");
}
Engineering & System Hardening Checklist
- ✓Enable Security Code Scan or an equivalent analyzer and treat SCS0001 (command injection) as a build error; flag every assignment to
ProcessStartInfo.Argumentsthat uses concatenation or interpolation. - ✓Start processes with
UseShellExecute = false, a fixed program name or absolute path, and oneArgumentList.Addper argument; never start/bin/sh -corcmd.exe /cwith request data. - ✓Map any user choice of tool through a dictionary defined in code, and validate every argument against a strict pattern that requires a letter or digit first.
- ✓Reject option-like values by default. For an untrusted Git revision, call
git rev-parse --verify --end-of-options "$revision"; use--only with the operand or path meaning documented by that exact command. - ✓Bound each process with a
CancellationTokenSourcetimeout and callKill(entireProcessTree: true)when it fires.
Sources
- CWE-78: Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')
- .NET API — ProcessStartInfo.ArgumentList
- .NET API — ProcessStartInfo.UseShellExecute
- Security Code Scan — SCS0001: Command Injection
- Git — git rev-parse --end-of-options
- OWASP OS Command Injection Defense Cheat Sheet
- POSIX Utility Syntax Guidelines
- Git — Command-Line Interface Conventions
- Git — git log