Katabench
Start free
← All posts

SQL injection is still alive, and it's hiding in your C#

SQL injection is closing in on thirty years old. We have parameterized queries. We have ORMs. We have a generation of developers who can recite "never concatenate user input into SQL" in their sleep. And yet it sits there, year after year, inside Injection in the OWASP Top 10, refusing to die.

It survives because the advice we all memorized is not the code we actually write. We know the rule in the abstract, then we reach for an interpolated string because it's right there on the keyboard, and Roslyn happily compiles it. The vulnerability isn't a knowledge gap. It's a muscle-memory gap.

The one everyone "would never" write

Here's the canonical mistake, and the reason it persists is that it doesn't look reckless. It looks like clean, modern C# with string interpolation:

public async Task<User?> FindByEmail(string email)
{
    return await dbContext.Users
        .FromSqlRaw($"SELECT * FROM Users WHERE Email = '{email}'")
        .FirstOrDefaultAsync();
}

That $"" is the trap. FromSqlRaw takes a plain string, and by the time it sees your argument the interpolation has already happened: the user's input is now part of the SQL text. Pass x' OR '1'='1 and you return every user. Pass something with a trailing -- and a DROP and you have a very bad afternoon.

The fix is almost invisible, which is exactly why people miss it. EF Core ships a sibling method that treats the interpolated string as a template, not a string:

public async Task<User?> FindByEmail(string email)
{
    return await dbContext.Users
        .FromSqlInterpolated($"SELECT * FROM Users WHERE Email = {email}")
        .FirstOrDefaultAsync();
}

Same-looking code, completely different behavior. FromSqlInterpolated captures the FormattableString, pulls email out as a value, and sends a real parameterized command: WHERE Email = @p0. The database never confuses your data for your code. (If you genuinely need FromSqlRaw, pass parameters explicitly: FromSqlRaw("... WHERE Email = {0}", email). The rule is that user input must arrive as a parameter, never as text.)

Concatenated

data became code

SELECT * FROM Users WHERE Email = 'x' OR '1'='1'

the OR is now part of the query: every row matches

Parameterized

data stayed data

SELECT * FROM Users WHERE Email = @p0

param @p0 = "x' OR '1'='1"

compared as a literal string: it matches nothing

Concatenation lets the input rewrite the query. As a parameter, the identical payload stays a value forever.

The raw ADO.NET version

Drop below EF Core to a Dapper-free DbCommand and the same trap is waiting, just more honest about it:

// Vulnerable: the search term becomes part of the command text
var sql = "SELECT Id, Title FROM Articles WHERE Title LIKE '%" + term + "%'";
await using var cmd = new NpgsqlCommand(sql, connection);
await using var reader = await cmd.ExecuteReaderAsync();

Every character of term is now SQL. The fix is the parameter collection that has existed in ADO.NET since .NET 1.0:

const string sql = "SELECT Id, Title FROM Articles WHERE Title LIKE @term";
await using var cmd = new NpgsqlCommand(sql, connection);
cmd.Parameters.AddWithValue("term", $"%{term}%");
await using var reader = await cmd.ExecuteReaderAsync();

Note the interpolation moved to the value ($"%{term}%"), not the command text. That's the whole discipline in one line: interpolate values all you want, never interpolate the SQL itself.

The subtle one that breaks the rule

Now the part that catches even people who do everything above correctly. Parameters bind values. They cannot bind identifiers: table names, column names, or sort directions. So the moment you let a user pick what to sort by, you're back to building SQL from input, and your reflex to parameterize just... doesn't apply.

// Looks parameterized. Isn't. The ORDER BY column is raw user input.
public IQueryable<Order> Sort(string sortColumn, string direction)
{
    return dbContext.Orders
        .FromSqlRaw($"SELECT * FROM Orders ORDER BY {sortColumn} {direction}");
}

You can't fix this with a parameter. ORDER BY @col does not mean "sort by the column named in @col"; in most databases it sorts by the constant string, which silently does nothing useful and is its own bug. The correct fix is an allowlist: map the small, finite set of legal sort options to known-safe SQL fragments, and reject anything that isn't on the list.

private static readonly Dictionary<string, string> SortColumns = new(StringComparer.OrdinalIgnoreCase)
{
    ["date"] = "CreatedAt",
    ["total"] = "TotalAmount",
    ["status"] = "Status",
};

public IQueryable<Order> Sort(string sortColumn, string direction)
{
    if (!SortColumns.TryGetValue(sortColumn, out var column))
        throw new ArgumentException("Unknown sort column", nameof(sortColumn));

    var dir = direction.Equals("desc", StringComparison.OrdinalIgnoreCase) ? "DESC" : "ASC";

    // column and dir are now drawn from constants we control, never from raw input
    return dbContext.Orders.FromSqlRaw($"SELECT * FROM Orders ORDER BY {column} {dir}");
}

The user's sortColumn never reaches the SQL string. It's only ever a key into a dictionary you wrote. If it's not in the dictionary, the request fails closed. That's the mental model for anything that can't be parameterized: don't sanitize the input, choose from a fixed set of trusted outputs.

Rule of thumb: parameterize values, allowlist identifiers. If you find yourself reaching for a "sanitize this string" helper for SQL, you're already on the wrong side of the line.

Why "I know this" isn't enough

Here's the uncomfortable thing about all three of these. None of them are hard. None of them require knowledge a working .NET developer doesn't already have. The vulnerable versions and the safe versions pass exactly the same functional tests: find the user, search the articles, sort the orders. A normal test suite is blind to the difference, because the difference only shows up when someone feeds you input designed to hurt.

That's the gap, and it's why reading this article will not make injection an instinct on its own. You close it the way you close any reflex gap: reps against a feedback loop that actually fires when you get it wrong.

That's the premise of Katabench's secure-coding track. Each puzzle hands you a starter that is functionally correct but vulnerable, the exact shape of code that ships every day and passes review. Your job isn't to make the feature work; it already works. Your job is to survive an adversarial exploit suite, the malicious inputs above and worse, without breaking the functional tests. You can't satisfy the attacker by ripping out the feature, and you can't satisfy the feature by ignoring the attacker. You have to do what production demands: ship code that does the right thing and refuses the wrong input.

Do that fifteen or twenty times and FromSqlRaw($"...") starts to feel wrong under your fingers before you've even finished typing it. Which is the only version of "I know this" that ever actually holds up.

The secure-coding track lives on the Pro plan, alongside the other four. If you want to stop knowing the rule and start flinching at the violation, that's where the reps are.

Get new puzzles and .NET tips in your inbox

A short note when fresh kata land, plus the C# and performance tricks behind the grading. No spam, unsubscribe anytime.