Blog

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

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

SQL Injection: when the user's data becomes a command

In the previous article I introduced NeuralGuard and the five vulnerabilities it recognizes. Now the fundamentals begin, one per article, and the first is the oldest of the list.

SQL Injection was publicly described in 1998. Almost thirty years later it still shows up in new applications, written by capable people. It is worth understanding why.

SQL Injection: the data that becomes a command

Every database query is text. You write that text, the database reads it and runs it. And that is where the whole flaw lives: when you build that text by gluing into it something the user typed, you stopped sending a command with a piece of data, and started sending a command the user helps to write.

The database has no way of telling the difference. It receives a single piece of text, and there is no marking saying "this part I wrote, that part came from outside". To the database, it is all command.

Here is what happens with a search by name:

var sql = "SELECT * FROM Users WHERE Name = '" + name + "'";

using var command = new SqlCommand(sql, connection);
using var reader = command.ExecuteReader();

With name holding Maria, the text that reaches the database is what you expected:

SELECT * FROM Users WHERE Name = 'Maria'

Now with name holding ' OR 1=1 --:

SELECT * FROM Users WHERE Name = '' OR 1=1 --'

The quote the user typed closed the quote you opened. What came after stopped being text and became logic. And 1=1 is true for every row in the table, so the search for a specific name became a listing of every user.

Those two dashes at the end are not decoration. They start a comment in SQL, and they exist to discard the quote left over at the end of your query. Without them the text would have an unclosed quote and the database would reject it as a syntax error.

There is a variant that does without the comment, and you will find it everywhere: ' OR '1'='1. It works because the quote that would cause the problem is reused to close the comparison, leaving the text balanced on its own. Same idea, written in a way that survives when comments are blocked.

The same door serves to read other tables, change data and, depending on the permissions of the account the application uses, quite a bit more than that.

Why it goes unnoticed

Three reasons, and none of them is carelessness.

The first is that the code works. Search for Maria and Maria shows up. It passes the test, passes the demo, passes production for months. The flaw is not a defect in behaviour, it is extra behaviour nobody asked for.

The second is that concatenation is the natural way to build text. Nobody writes an unsafe query to save effort. They write it because joining strings is what you do with strings from your first day of programming.

The third is that the data does not always look like user data. In the example above the variable is called name and came from a route parameter, so it is easy to be suspicious. But when the value crossed three layers, became a property on an object and arrived there as filter.Reference, its origin is out of sight. The code that concatenates does not know where the value came from.

Parameter: the data travels separately from the command

The fix is not escaping the quotes. Escaping is you trying to guess every possible way for the data to look like a command, and that is a fight you do not win.

The fix is to stop mixing the two things. You send the command with a placeholder, and you send the data separately:

var sql = "SELECT * FROM Users WHERE Name = @name";

using var command = new SqlCommand(sql, connection);
command.Parameters.AddWithValue("@name", name);

using var reader = command.ExecuteReader();

Now the database receives two separate things: a command, which it analyzes and plans, and a value, which it slots in after it has already decided what the query does. If the value is ' OR 1=1 --, it is looked up as a name. The database goes looking for a user literally called ' OR 1=1 --, and does not find one.

Notice the difference is not syntactic, it is architectural. There is no longer a point where the data could become a command, because they never end up in the same piece of text.

The variations that fool you

The + example is the classic portrait of SQL Injection, but it has several disguises. All of the below are the same flaw:

var byInterpolation = $"SELECT * FROM Orders WHERE Reference = {reference}";
var byFormat = string.Format("DELETE FROM Orders WHERE Reference = {0}", reference);
var byBuilder = new StringBuilder("SELECT * FROM Orders WHERE 1 = 1")
    .Append(" AND Reference = '").Append(reference).Append("'")
    .ToString();

Interpolation is the most dangerous of the three, because it looks like a parameter. That {reference} has the same shape as a placeholder, but the one who substitutes it is C#, before the database sees anything. By the time the text reaches the database, the value is already inside it, as command.

Entity Framework has a pair of methods that illustrates the difference nicely:

context.Database.ExecuteSqlRaw("DELETE FROM Orders WHERE Reference = '" + reference + "'");

context.Database.ExecuteSqlInterpolated($"DELETE FROM Orders WHERE Reference = {reference}");

The first is vulnerable. The second, despite looking like a plain interpolation, is safe: ExecuteSqlInterpolated receives the interpolated string before it becomes text, and turns each hole into a parameter. Two nearly identical lines with opposite fates, which gives you an idea of how easy this is to miss in code review.

The case a parameter does not solve

There is one place where the recipe does not apply: a parameter works for a value, not for the name of a thing. You cannot parameterize the name of a column, of a table, or the direction of an ordering.

So a listing that lets the user choose the order falls outside the standard solution:

var sql = "SELECT * FROM Orders ORDER BY " + orderBy;

Here the fix is different: instead of trusting what arrived, you compare it against what is allowed, and you use the value from your list, never the user's.

string[] allowedColumns = ["Reference", "CreatedAt", "Status"];

var column = allowedColumns.Contains(orderBy) ? orderBy : "Id";
var sql = "SELECT * FROM Orders ORDER BY " + column;

The detail that makes this work is the list being of allowed things, and not of forbidden ones. Blocking what you imagine to be dangerous forces you to predict the attack. Allowing what you know to be safe forces you to predict nothing.

What this becomes in training

Every pattern in this article goes into the detector's training material in pairs: the wrong version labelled as SQL Injection, and the right version of the same query labelled as safe. It is that pair that teaches the model to tell them apart, instead of reacting to the word SELECT.

And it is worth marking down something the rest of the series will come back to: the model learns shape, not intent. In SQL Injection it turned out good at not crying wolf, and even so it misses one in every four vulnerable queries it should have caught. A vulnerability it never sees never reaches anyone to be reviewed.

The next article continues the fundamentals with the second vulnerability on the list, Cross-Site Scripting, where the user's data does not become a database command, but code inside somebody else's browser.

Sources