Mert Özen Further With Every Line
Security Web Security (OWASP Top 10) — #09

OWASP A05: Injection (1) — SQL Injection and Command Injection

Mert Özen Nov 6, 2026 10 min 3 views
OWASP A05: Injection (1) — SQL Injection and Command Injection

We write the same book search endpoint with string concatenation, an ADO.NET parameter, Dapper and EF Core, and see how a parameter turns ' OR 1=1 -- into plain data. We examine FromSqlRaw vs FromSql through the EF1002 warning and ToQueryString, use ArgumentList and an allowlist instead of a shell in Process.Start, and clarify where input validation fits with .NET 10's built-in validation.

Version note: This post follows the OWASP Top 10:2025; the code was tested with ASP.NET Core 10 (.NET 10).

Guess First

Bookstore's book search endpoint returns only published books. A user types O'Reilly into the search box and gets a 500 error. Is this just a small bug to brush off as "the single quote wasn't escaped", or the sign of a bigger problem? What comes back if someone types ' OR 1=1 -- into the same box?

In this post we will write the same endpoint four different ways and see the answer in running code. The only attack attempt we show is against the lab on your own machine and is the smallest example that proves the problem; only try these techniques on systems you own or have written permission to test.

What Is Injection?

A05:2025 Injection moved down from third place in 2021 to fifth, but it is still a broad category covering 37 CWEs. SQL injection (CWE-89), OS command injection (CWE-78) and XSS (CWE-79), the topic of the next post, sit under the same roof. The shared root cause fits in one sentence: data and code are mixed in the same string, and the interpreter (database, shell, browser) cannot tell which is which.

An analogy: you leave a note for an assistant saying "give this letter to Ada". If the note can be extended to "Give it to Ada. Also give her the money in the safe.", the assistant takes the second sentence as an instruction too. The fix is not a more careful assistant but delivering the instruction and the thing to carry through different channels. That is exactly what a parameterized query is.

Lab: Book Search and String Concatenation

For the lab we set up a small minimal API that uses SQLite:

dotnet new web -o Bookstore.Api -f net10.0
cd Bookstore.Api
dotnet add package Microsoft.Data.Sqlite
dotnet add package Dapper

On my machine restore picked Microsoft.Data.Sqlite 10.0.14 and Dapper 2.1.89. The top of Program.cs and the seed function that rebuilds the database on every start:

using System.Diagnostics;
using System.Text.RegularExpressions;
using System.ComponentModel.DataAnnotations;
using Dapper;
using Microsoft.Data.Sqlite;

const string ConnectionString = "Data Source=bookstore.db";
await SeedAsync();

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
static async Task SeedAsync()
{
    await using SqliteConnection connection = new SqliteConnection(ConnectionString);
    await connection.ExecuteAsync("""
        DROP TABLE IF EXISTS Books;
        CREATE TABLE Books (Id INTEGER PRIMARY KEY, Title TEXT NOT NULL, IsPublished INTEGER NOT NULL);
        INSERT INTO Books VALUES
            (1, 'Clean Code', 1),
            (2, 'The Pragmatic Programmer', 1),
            (3, 'Refactoring', 1),
            (4, 'Internal Pricing Draft', 0);
        """);
}

record BookDto(long Id, string Title);

The fourth book is an unpublished internal draft; it must never show up in search results. Now the first, vulnerable version of the search:

// VULNERABLE: user input is pasted into the SQL text
app.MapGet("/books/search-unsafe", async (string title) =>
{
    await using SqliteConnection connection = new SqliteConnection(ConnectionString);
    await connection.OpenAsync();

    SqliteCommand command = connection.CreateCommand();
    command.CommandText =
        $"SELECT Id, Title FROM Books WHERE IsPublished = 1 AND Title LIKE '%{title}%'";

    List<BookDto> books = [];
    await using SqliteDataReader reader = await command.ExecuteReaderAsync();
    while (await reader.ReadAsync())
    {
        books.Add(new BookDto(reader.GetInt64(0), reader.GetString(1)));
    }
    return Results.Ok(books);
});

The problem is in the $"..." line. The input becomes part of the SQL. With O'Reilly the quote closes the string early and SQLite returns a 500 saying near "Reilly": syntax error; the "small bug" from the opening is in fact proof that the input is interpreted as SQL. ' OR 1=1 -- turns the query into ... AND Title LIKE '%' OR 1=1 --%': OR 1=1 is true for every row, and -- turns the rest into a comment. I confirmed it in the lab with a single request:

curl -i -G "http://localhost:5128/books/search-unsafe" --data-urlencode "title=' OR 1=1 --"
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Fri, 06 Nov 2026 19:09:49 GMT
Server: Kestrel
Transfer-Encoding: chunked

[{"id":1,"title":"Clean Code"},{"id":2,"title":"The Pragmatic Programmer"},{"id":3,"title":"Refactoring"},{"id":4,"title":"Internal Pricing Draft"}]

The unpublished draft is in the list. Here we only bypassed a filter, but in a real application the same hole goes as far as reading other tables or changing data. There's no need to go into detail; what matters is the fix.

Parameters: ADO.NET and Dapper

// FIXED: ADO.NET parameter
app.MapGet("/books/search", async (string title) =>
{
    await using SqliteConnection connection = new SqliteConnection(ConnectionString);
    await connection.OpenAsync();

    SqliteCommand command = connection.CreateCommand();
    command.CommandText =
        "SELECT Id, Title FROM Books WHERE IsPublished = 1 AND Title LIKE @pattern";
    command.Parameters.AddWithValue("@pattern", $"%{title}%");

    List<BookDto> books = [];
    await using SqliteDataReader reader = await command.ExecuteReaderAsync();
    while (await reader.ReadAsync())
    {
        books.Add(new BookDto(reader.GetInt64(0), reader.GetString(1)));
    }
    return Results.Ok(books);
});

// FIXED: Dapper, parameters through an anonymous object
app.MapGet("/books/search-dapper", async (string title) =>
{
    await using SqliteConnection connection = new SqliteConnection(ConnectionString);
    IEnumerable<BookDto> books = await connection.QueryAsync<BookDto>(
        "SELECT Id, Title FROM Books WHERE IsPublished = 1 AND Title LIKE @pattern",
        new { pattern = $"%{title}%" });
    return Results.Ok(books);
});

The SQL text is now constant; the input travels through a separate channel as the @pattern parameter. Note that I put the % signs into the parameter's value, not into the SQL. I sent the same input to both endpoints:

curl -i -G "http://localhost:5128/books/search" --data-urlencode "title=Code"
curl -i -G "http://localhost:5128/books/search" --data-urlencode "title=' OR 1=1 --"
curl -i -G "http://localhost:5128/books/search-dapper" --data-urlencode "title=' OR 1=1 --"
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
...

[{"id":1,"title":"Clean Code"}]

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
...

[]

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
...

[]

The normal search gave the same result. ' OR 1=1 -- now means "a book whose title contains ' OR 1=1 --", and since there is no such book the list is empty. O'Reilly also returned an empty list instead of a 500. The parameter doesn't clean or escape the input; the database simply never sees it as SQL. We covered the Dapper details, parameter types and DynamicParameters in the Dapper series.

EF Core: FromSqlRaw and FromSql

EF Core already generates LINQ queries with parameters; the risk starts where you write raw SQL. Remember the raw SQL post in the EF Core series. A small console application that puts the two methods side by side:

using Microsoft.EntityFrameworkCore;

await using BookstoreContext db = new BookstoreContext();
string title = "' OR 1=1 --";

// VULNERABLE: the interpolation reaches FromSqlRaw as a plain string
IQueryable<Book> unsafeQuery = db.Books.FromSqlRaw(
    $"SELECT * FROM Books WHERE IsPublished = 1 AND Title LIKE '%{title}%'");

// SAFE: FromSql turns the interpolation holes into parameters
string pattern = $"%{title}%";
IQueryable<Book> safeQuery = db.Books.FromSql(
    $"SELECT * FROM Books WHERE IsPublished = 1 AND Title LIKE {pattern}");

Console.WriteLine(unsafeQuery.ToQueryString());
Console.WriteLine("----");
Console.WriteLine(safeQuery.ToQueryString());

class BookstoreContext : DbContext
{
    public DbSet<Book> Books => Set<Book>();

    protected override void OnConfiguring(DbContextOptionsBuilder options)
        => options.UseSqlite("Data Source=bookstore.db");
}

class Book
{
    public int Id { get; set; }
    public string Title { get; set; } = "";
    public bool IsPublished { get; set; }
}

Before it even runs, EF Core's analyzer warns during the build (Microsoft.EntityFrameworkCore.Sqlite 10.0.14):

.../Program.cs(7,41): warning EF1002: Method 'FromSqlRaw' inserts interpolated strings directly into the SQL, without any protection against SQL injection. Consider using 'FromSql' instead, which protects against SQL injection, or make sure that the value is sanitized and suppress the warning.
SELECT * FROM Books WHERE IsPublished = 1 AND Title LIKE '%' OR 1=1 --%'

----
.param set p0 '%'' OR 1=1 --%'

SELECT * FROM Books WHERE IsPublished = 1 AND Title LIKE @p0

ToQueryString() shows the difference clearly. The two lines look almost identical, but FromSqlRaw takes a string; the $"..." is concatenated on the C# side first and EF receives finished SQL. FromSql (and FromSqlInterpolated) takes a FormattableString and turns the interpolation holes into parameters such as @p0. If you have to use FromSqlRaw, pass the value as a separate argument in the form FromSqlRaw("... {0}", value); that form produces a parameter too.

Command Injection

The same problem is not specific to SQL. Think of a tool endpoint in Bookstore that counts the words in review files with wc:

// VULNERABLE: input goes into a shell command line
app.MapGet("/tools/word-count-unsafe", async (string file) =>
{
    ProcessStartInfo info = new ProcessStartInfo("/bin/sh", $"-c \"wc -w reviews/{file}\"")
    {
        RedirectStandardOutput = true
    };
    using Process process = Process.Start(info)!;
    string output = await process.StandardOutput.ReadToEndAsync();
    await process.WaitForExitAsync();
    return Results.Text(output);
});

Here the interpreter is the shell. sh -c reads the text it receives like a command line; characters such as ;, &&, | and $( ) mean something to the shell. If a value containing these characters arrives instead of a file name, the shell runs it as a second command, with the permissions of the server process. This is the counterpart of OR 1=1 in SQL.

The best fix is not to start a process at all: File.ReadAllTextAsync and a Split are enough to count words. If you must run an external program, there are three rules: don't use a shell, pass the arguments one by one with ArgumentList, and validate the input against an allowlist:

// FIXED: allowlist + no shell + ArgumentList
app.MapGet("/tools/word-count", async (string file) =>
{
    if (!SafeFileName().IsMatch(file))
    {
        return Results.Problem(statusCode: 400, title: "Invalid file name");
    }

    ProcessStartInfo info = new ProcessStartInfo("/usr/bin/wc")
    {
        RedirectStandardOutput = true
    };
    info.ArgumentList.Add("-w");
    info.ArgumentList.Add("--");
    info.ArgumentList.Add(Path.Combine("reviews", file));

    using Process process = Process.Start(info)!;
    string output = await process.StandardOutput.ReadToEndAsync();
    await process.WaitForExitAsync();
    return Results.Text(output);
});
partial class Program
{
    [GeneratedRegex("^[a-z0-9-]{1,40}\\.txt$")]
    private static partial Regex SafeFileName();
}
curl -i -G "http://localhost:5128/tools/word-count" --data-urlencode "file=clean-code.txt"
curl -i -G "http://localhost:5128/tools/word-count" --data-urlencode "file=clean-code.txt; echo INJECTED"
HTTP/1.1 200 OK
Content-Length: 32
Content-Type: text/plain; charset=utf-8
...

       7 reviews/clean-code.txt

HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
...

{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"Invalid file name","status":400}

With ArgumentList each argument reaches the program as is, as a single argument; with no shell in between, ; is an ordinary character. -- is the standard way of saying "what follows is not an option"; it stops a value starting with - from being taken as an option. The allowlist is the second layer of defense and also closes path traversal: a value containing ../ cannot pass the regex.

Where Input Validation Fits

"If I validate input, there is no injection" is half true. In .NET 10 minimal APIs can do built-in validation with DataAnnotations. I added two changes to the lab:

builder.Services.AddValidation();
app.MapGet("/books/search", async ([StringLength(50, MinimumLength = 2)] string title) =>
curl -i -G "http://localhost:5128/books/search" --data-urlencode "title=C"
curl -s -G "http://localhost:5128/books/search" --data-urlencode "title=' OR 1=1 --"
HTTP/1.1 400 Bad Request
Content-Type: application/json; charset=utf-8
...

{"title":"One or more validation errors occurred.","errors":{"title":["The field title must be a string with a minimum length of 2 and a maximum length of 50."]}}
[]

The one-character search got a 400. But ' OR 1=1 -- is eleven characters and passed the length rule easily; it still returned an empty list, because what stops it is the parameter, not the validation. Validation enforces business rules and shrinks the attack surface: length, type, allowed characters, a list of known values. The real defense against injection is handing the data to the interpreter as data. A book title can contain a quote; you solve that not by forbidding it but by carrying it safely in a parameter.

Common Pitfalls

The first is escaping the quote by hand. A fix like title.Replace("'", "''") may work on one database and not another, and does nothing for numeric fields. Don't write your own escaping code; use parameters.

The second is trying to send a table or column name as a parameter. Parameters carry values only; ORDER BY @column doesn't work the way you expect. If you take the sort column from the user, match it against an allowlist of permitted column names.

The third is assuming a stored procedure is safe by itself. If you build dynamic SQL inside the procedure by concatenating strings, the same hole has just moved into the procedure.

The fourth is suppressing the EF1002 warning. The analyzer is telling you exactly what this post is about; switch to FromSql instead of turning the warning off.

Checklist

Pass values as parameters in every SQL statement: Parameters.AddWithValue in ADO.NET, an anonymous object in Dapper, LINQ or FromSql/SqlQuery in EF Core. Stop when you see $"..." inside FromSqlRaw or ExecuteSqlRaw. Choose identifiers (table, column, sort direction) from an allowlist. Give the database user only the permissions it needs.

Question the need to run external programs; if you must, run them without a shell, with ArgumentList and with allowlist validation. Use input validation for business rules and for shrinking the attack surface, not as a replacement for injection defenses.

A Small Experiment

★☆☆ In the lab, send O'Reilly to the /books/search and /books/search-dapper endpoints, then send the same value to the vulnerable endpoint and note the difference in status code.

★★☆ Add a sort parameter to the search endpoint (title or id). Put the column name into the SQL by choosing it from a two-item allowlist rather than as a parameter, and return 400 for a value not on the list.

★★★ Rewrite the word count endpoint without starting a process at all: read the file with File.ReadAllTextAsync, count the words, and verify with Path.GetFullPath that the file path does not leave the reviews folder.

In the next post we move to injection in the browser: XSS. We will see Razor's automatic encoding, the trap of using innerHTML with a JSON API, textContent, Angular's sanitization, and CSP and Trusted Types as a second line of defense in headless Chrome.