Blog

Notes on the .Net ecosystem, Angular, Software Architecture, Machine Learning and CyberSecurity

All posts
Command Injection: when the user's data becomes a system command
  • Security
  • Command Injection
  • .NET

Command Injection: when the user's data becomes a system command

The two previous articles showed the user's data becoming a database command and page markup. Command Injection is the same family, with the most dangerous destination of the three: the operating system shell.

The difference in severity is large. A SQL Injection gives you what the database account can reach. A Command Injection gives you what the application process can reach, which normally includes reading any file it can read, opening network connections, and installing whatever it wants inside the container.

Command Injection: the shell also interprets text

The shell is a command interpreter, and like every interpreter in this series, it receives text and decides what to do with it. Some characters in that text are not content, they are structure.

The semicolon separates two commands. The double ampersand chains them. The pipe connects the output of one to the input of another. And a dollar sign with parentheses runs something and puts the result in its place. When you build the command line by concatenating user data, those characters become available to whoever typed it.

var startInfo = new ProcessStartInfo("/bin/sh", "-c \"ping -c 1 " + host + "\"");

Process.Start(startInfo);

With host holding example.com, the expected ping runs. With host holding example.com; id, the shell receives this:

ping -c 1 example.com; id

And it does exactly what was written: it runs the ping, and then runs the second command. The id in the example is harmless on purpose, it only prints which user is running the process. The point is that any command the system accepts fits in that position.

Why escaping is not the answer here

In SQL you had an elegant way out, the parameter. In HTML you had encoding. The temptation is to look for the equivalent here and start filtering out semicolons, ampersands, pipes and dollar signs.

Do not do that. The list of characters with meaning in a shell is long, it changes between different shells, and it involves combinations that are not obvious. You will remember the semicolon and forget the line break. You will block one form and let the backtick through. Every item you miss is the whole flaw back again.

And there is a better way out, which avoids the fight entirely.

ArgumentList: the argument travels separately from the command

The problem started when you built a line of text for the shell to interpret. If there is no line of text to interpret, there is no interpretation to hijack.

.NET lets you pass the arguments as a list rather than as a string:

var startInfo = new ProcessStartInfo("ping") { UseShellExecute = false };

startInfo.ArgumentList.Add("-c");
startInfo.ArgumentList.Add("1");
startInfo.ArgumentList.Add(host);

Process.Start(startInfo);

Here the operating system receives the executable name and a vector of already separated arguments. There is no shell in the middle, no text being split, and therefore no separator for the user to exploit. If host holds example.com; id, ping receives all of that as if it were a machine name, tries to resolve it, and fails.

Notice it is the same idea as the parameter in SQL: the data never ends up in the same text as the command. The API changes, the principle does not.

Two things matter in that snippet. The first is UseShellExecute = false, which guarantees the process is created directly, without going through an interpreter. The second is using ArgumentList instead of Arguments: the Arguments property takes a single string, and going back to it is going back to the problem.

When you do not need a process at all

It is worth taking a step back before the technical part: a good share of the Command Injection cases that exist in production did not need to call an external program.

The ping example is the clearest case. .NET has this in the standard library:

var reply = await new Ping().SendPingAsync(host, 2000);

return Ok(reply.Status);

No process, no shell, no argument to escape. The vulnerability disappears along with the external call. Whenever you are about to invoke a command line utility, it is worth checking whether an API exists for it, because one frequently does.

When the command itself comes from the user

There is a case where even ArgumentList does not solve it: when what the user chooses is not the argument, but which command to run.

Process.Start("git", subcommand);

Passing that as a list of arguments stops them from chaining a second command, but does not stop them from choosing a subcommand you did not want to expose. Here the defense is the same as the two previous articles, the allowed list:

string[] allowedSubcommands = ["status", "log", "diff"];

if (!allowedSubcommands.Contains(subcommand))
    return BadRequest();

var startInfo = new ProcessStartInfo("git") { UseShellExecute = false };
startInfo.ArgumentList.Add(subcommand);

Three articles, three different vulnerabilities, and the same rule showing up for the third time: when the data needs to take part in the structure of the command, and cannot be treated as a value, you compare it against a list of what is accepted and use the value from your list.

Why it goes unnoticed

Command Injection is rarer than the two before it, and for that very reason it gets reviewed less.

Most web applications never call an external process. When the need shows up (converting a file, generating a PDF, resizing an image, calling a tool that has no library equivalent), it tends to be solved by one person, under pressure, and the fastest way to build that line is by concatenating.

And there is the detail that the code works perfectly. That ProcessStartInfo with a shell is a common line and does exactly what it promises. The problem is not that it has a defect, it is that it offers power nobody asked for.

What this becomes in training

Same as the previous articles, every pattern goes in as a pair: the version with the line built by concatenation labelled as Command Injection, and the same feature with ArgumentList labelled as safe.

This is one of the two categories where the classifier did best in the whole series: when it flags Command Injection it is right every time, and it finds almost every case. It is worth the hypothesis that the difference between the two versions here is unusually visible: one has a string with quotes and backslashes inside it, the other has three consecutive Add calls. Those are quite distinct shapes, and shape is what the model sees best.

The next article closes the injection group with Path Traversal, where the user's data becomes a command to nothing at all, and still manages to read whichever file it wants.

Sources