FlawOpen

flawopen.com/command-injection/Csharp

● CWE-78 · Critical
FlawOpen Security Research

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.

💡 Plain English Explainer (ELI5)

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 to false on .NET Core and later but true on .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; in git log, it separates revisions from paths. These markers are not interchangeable. Validate that the value is a revision before use.

Framework mappings

Step-by-Step Attack Flow

Step 1

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.

Step 2

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.

Step 3

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.

Step 4

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

UNPATCHED FLAW
// 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 });
}
HARDENED SECURE PATCH
// 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

Sources