Start the process with ProcessStartInfo.ArgumentList and UseShellExecute = false - psi.ArgumentList.Add("--"); psi.ArgumentList.Add(name) - so no shell parses the arguments, and the '--' makes a leading-dash operand data, not an option. Never run /bin/sh -c with a built string. Choose a format from an allowlist, never from free text.
- Find every Process.Start / ProcessStartInfo whose FileName is a shell or whose Arguments string is built from request data, and every call with UseShellExecute = true.
- Set FileName to the program, UseShellExecute = false, and add each argument to ProcessStartInfo.ArgumentList; do not build an Arguments string and do not invoke a shell.
- Add a literal "--" to the ArgumentList before the first user-controlled operand, so a value starting with '-' is an operand, not an option.
- For any argument that selects the command, a subcommand, a flag or an enumerated option value, map the caller's choice through an allowlist (a Dictionary) and throw ArgumentException (returned as 400) for anything else.
- Add regression tests with this card's payloads (separators, substitution, newline, and a leading-dash option) that assert a marker file was never created, next to functional tests for names with spaces, apostrophes and quotes.
- Do not run the Development environment in production; log the tool's stderr on the server and return a generic 500 so its text does not help an attacker.
The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.
The vulnerable code
public string Greet(string name)
{
// "/bin/sh -c <string>" hands the whole line to the shell: every metacharacter
// in name is live, and ; | && $( ) backticks and newlines start new work.
var psi = new ProcessStartInfo("/bin/sh");
psi.ArgumentList.Add("-c");
psi.ArgumentList.Add($"{_tool} {name}");
return ProcessRunner.Run(psi);
}With IMPL=vulnerable, 16 of 16 tests fail (10 exploit tests).
The fix
public string Greet(string name)
{
// Each element of ArgumentList is passed to the program as one argument, with
// UseShellExecute = false (set in ProcessRunner): no shell parses them. "--"
// makes everything after it an operand, so a leading-dash name is not an option.
var psi = new ProcessStartInfo(_tool);
psi.ArgumentList.Add("--");
psi.ArgumentList.Add(name);
return ProcessRunner.Run(psi);
}With IMPL=fixed, 16 of 16 tests pass.
The fix: format allowlist
// The format picks part of the command itself, which a value can never do
// safely, so map the caller's choice onto a fixed flag value from an allowlist.
private static readonly IReadOnlyDictionary<string, string> FormatFlags = new Dictionary<string, string>
{
["text"] = "text",
["json"] = "json",
};
public string Report(string name, string format)
{
if (!FormatFlags.TryGetValue(format, out var flag))
{
throw new ArgumentException("unsupported format");
}
var psi = new ProcessStartInfo(_tool);
psi.ArgumentList.Add("--format");
psi.ArgumentList.Add(flag);
psi.ArgumentList.Add("--");
psi.ArgumentList.Add(name);
return ProcessRunner.Run(psi);
}With IMPL=fixed, 16 of 16 tests pass.
A fix that looks safe but is not
// The "obvious" shell separators. Note what is missing: $ ( ) and backticks
// (command substitution), and the leading dash of an injected option.
private static readonly Regex Blocked = new(@"[;|&<>\n]", RegexOptions.CultureInvariant);
private string ShellQuote(string value)
{
if (Blocked.IsMatch(value))
{
throw new ArgumentException("illegal character in input");
}
return $"\"{value}\"";
}
public string Greet(string name)
{
var psi = new ProcessStartInfo("/bin/sh");
psi.ArgumentList.Add("-c");
psi.ArgumentList.Add($"{_tool} {ShellQuote(name)}");
return ProcessRunner.Run(psi);
}With IMPL=wrong, 7 of 16 tests fail (5 exploit tests).
The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.
/// <summary>Exploit: each payload must stay data; the marker file must never be created.</summary>
public sealed class ExploitTests(GreetApiFactory factory) : GreetApiTests(factory)
{
// OS command injection: a shell metacharacter in the name must stay data.
[Fact]
public Task SemicolonDoesNotStartASecondCommand() =>
AssertNoMarker("/greeting", [("name", "Alice; touch MARKER")]);
[Fact]
public Task PipeDoesNotStartASecondCommand() =>
AssertNoMarker("/greeting", [("name", "Alice | touch MARKER")]);
[Fact]
public Task AndChainDoesNotStartASecondCommand() =>
AssertNoMarker("/greeting", [("name", "Alice && touch MARKER")]);
[Fact]
public Task CommandSubstitutionDoesNotRun() =>
AssertNoMarker("/greeting", [("name", "Alice$(touch MARKER)")]);
[Fact]
public Task BackticksDoNotRun() =>
AssertNoMarker("/greeting", [("name", "Alice`touch MARKER`")]);
[Fact]
public Task NewlineDoesNotStartASecondCommand() =>
AssertNoMarker("/greeting", [("name", "Alice\ntouch MARKER")]);
// Argument injection: a name that starts with a dash must not pose as an option.
[Fact]
public Task LeadingOutputOptionCannotWriteAFile() =>
AssertNoMarker("/greeting", [("name", "--output=MARKER")]);
[Fact]
public Task LeadingShortOptionCannotWriteAFile() =>
AssertNoMarker("/greeting", [("name", "-oMARKER")]);
[Fact]
public Task OptionInjectedThroughFormatIsRejected() =>
AssertNoMarker("/report", [("name", "Alice Martin"), ("format", "text --output=MARKER")]);
[Fact]
public Task LeadingDashNameIsAnOperandNotAnOption() =>
AssertNoMarker("/report", [("name", "--output=MARKER"), ("format", "text")]);
}- ProcessStartInfo.Arguments (the single string) is re-parsed into argv by a platform rule and is where injection lives; ArgumentList passes each element verbatim. Do not set both.
- UseShellExecute = true runs the value through the OS shell (cmd.exe or the shell handler) and re-opens the hole; keep it false, which is the default when you redirect output.
- ArgumentList still runs one program, but without "--" an operand that starts with '-' is parsed as an option. Argument injection (CWE-88) needs no shell.
- There is no built-in shell-quoting helper and hand-quoting is exactly the mistake in WrongGreetService; pass a list, do not escape.
- Reading StandardOutput and StandardError to the end one after another can deadlock if the tool fills a pipe; read one asynchronously, or keep outputs small as this card does.
- Minimal-API binding gives one string per query key; a repeated key or a non-string needs explicit handling.
- Allowlist by value, not by a regex on the raw input: a blocklist of shell metacharacters still lets --output= and -o through, because a dash is not a metacharacter.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs csharp-aspnetcore does this for you).
- Against your own endpoint, send name=Alice;touch /tmp/pwned and name=--output=/tmp/pwned and confirm /tmp/pwned is never created.
- Do not rely on static analysis here: Semgrep CE has no rule for System.Diagnostics command injection, and CA3006's taint analyzer did not activate in this SDK build (verified with a single-method source-to-sink probe), so the regression tests are the control.
What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | (none) | No findings. Scanning src/Greet with the p/csharp ruleset produced 0 results (0 errors). Semgrep CE ships no command-injection rule for System.Diagnostics.Process/ProcessStartInfo, so Semgrep is not a control for this card; the regression tests are. Recorded as an object with no rule id because there is no matching registry rule to cite.Checked: Semgrep 1.180.0 (CE) |
| CodeQL | cs/command-line-injection | Not executed. The C# model treats ProcessStartInfo.FileName/Arguments and Process.Start as command-injection sinks and MapGet handler parameters as remote sources; on a full build it would report VulnerableGreetService and WrongGreetService (the /bin/sh -c string), not the ArgumentList form in FixedGreetService.Checked: github/codeql main at b5b8165; not executed |
| .NET code analysis | CA3006Review code for process command injection vulnerabilities | Did not fire on this card. CA3006 is a taint rule, and the taint-based CA3xxx analyzers did not activate in this SDK build: a probe with the HTTP source (request.Query) and the Process.Start sink in a single method produced no CA3006 even with the rule forced to warning via .editorconfig. Only code-quality rules (CA1032, CA1515, CA1822, CA1859) appeared. So CA3006 is not a control for this card as configured; the regression tests are. (The rule is documented to flag exactly this shell-from-web-input pattern when its dataflow is available.)Checked: .NET SDK 10.0.107 with -p:EnableNETAnalyzers=true -p:AnalysisModeSecurity=All |
| Component | Version |
|---|---|
| Language | C# 14 |
| Runtime | .NET 10.0.12 (SDK 10.0.112) |
| Framework | ASP.NET Core 10 minimal API |
| System.Diagnostics.Process (built in) | .NET 10 |
| Microsoft.AspNetCore.Mvc.Testing | 10.0.12 |
| xunit.v3 | 4.0.1 (Microsoft Testing Platform v2) |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the command-injection/csharp-aspnetcorefolder.
dotnet restore tests/Greet.Tests --locked-mode# Expected: every test passes
IMPL=fixed dotnet test --project tests/Greet.Tests --no-restore --results-directory reports --report-xunit-junit --report-xunit-junit-filename junit.xml
# Expected: an exploit test fails
IMPL=vulnerable dotnet test --project tests/Greet.Tests --no-restore --results-directory reports --report-xunit-junit --report-xunit-junit-filename junit.xml
# Expected: a test fails
IMPL=wrong dotnet test --project tests/Greet.Tests --no-restore --results-directory reports --report-xunit-junit --report-xunit-junit-filename junit.xmlEach run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs command-injection/csharp-aspnetcore runs all three modes and checks the expected results.
Verified
- Machine
- macOS 27.0 (Darwin 27.0.0, arm64)
- Toolchain
- dotnetSdk 10.0.112
- dotnetRuntime 10.0.12
- mvcTesting 10.0.12
- xunitV3 4.0.1
- shell /bin/sh (dash-compatible POSIX shell)
- Matrix
- fixed: 16 of 16 tests pass; vulnerable: 16 of 16 tests fail (10 exploit tests); wrong: 7 of 16 tests fail (5 exploit tests)
- Dependency freshness
- Microsoft.AspNetCore.Mvc.Testing 10.0.12 (2026-09-08) is the current 10.0.x patch and over 14 days old. xunit.v3 4.0.1 (2026-09-12) is used rather than 4.0.2 (2026-10-09, one day old): 4.0.2 is a bugfix, not a security release, so the 14-day rule prefers 4.0.1.
Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.
References
- OWASP OS Command Injection Defense Cheat Sheet
- .NET API: ProcessStartInfo.ArgumentList
- .NET API: ProcessStartInfo.UseShellExecute
- ASP.NET Core: Minimal APIs parameter binding
- CA3006: Review code for process command injection vulnerabilities
- CWE-78: OS Command Injection
- CWE-88: Argument Injection
- CAPEC-88: OS Command Injection
- CAPEC-6: Argument Injection
Attack patterns: