SecLab

SQL & NoSQL injection

V1A05CWE-89
01

What it is

SQL injection happens when user-supplied data is concatenated into a query as SYNTAX rather than as data. The attacker then changes not just the search value but the statement itself: adding conditions, joining other tables, or running a second statement. NoSQL injection is the same bug one layer over — instead of a SQL string, the attacker sends an operator object that the driver interprets as query structure.

02

Why you should care

Relevance: CoreExpected: L2

SQL injection is older than most people reading this and is still inside A05:2025. It survives because the wrong way is more convenient than the right way in exactly one situation: when the query must change shape based on input (a dynamic ORDER BY, optional filters, a variable-length IN (...)). If you find yourself concatenating in one of those three places, that is where the bug will be.

On impact, SQL injection differs from other classes in that it needs no escalation: it is already at the data tier. One UNION SELECT reads the whole users table, and with broad DB privileges xp_cmdshell, COPY … FROM PROGRAM or LOAD_FILE turn it into RCE.

On ORMs: an ORM does not protect you. It protects the 95% of queries you write in LINQ or a query builder, then leaves you on your own at the one FromSqlRaw/$queryRaw/.raw() you had to reach for on query 96.

03

How the attack works

The mechanism is a confusion of boundary: the database parser receives a string and must decide for itself which part is command and which is data. Concatenation destroys that boundary before the parser ever sees it.

Diagram source
flowchart TD    I["Input: email = a@b.c<br/>' OR 1=1 --"] --> C{App builds the statement}    C -->|Concatenation| S1["SELECT * FROM users<br/>WHERE email = 'a@b.c' OR 1=1 --'"]    C -->|Parameterised| S2["SELECT * FROM users<br/>WHERE email = $1<br/>$1 = #quot;a@b.c#quot; OR 1=1 --#quot;"]    S1 --> P1["Parser sees 2 conditions<br/>→ returns EVERY ROW"]    S2 --> P2["Parser sees 1 condition<br/>+ 1 string value<br/>→ returns 0 rows"]    P1 --> B["🔓 Whole table leaks"]    P2 --> G["✅ Exactly as designed"]

The key point: on the parameterised branch the string ' OR 1=1 -- still reaches the database intact. It is not stripped, not escaped — it is simply placed on the DATA side of the boundary, so the parser never reads it as syntax. That is why parameterisation wins and escaping does not: escaping tries to fix the data, parameterisation rebuilds the boundary.

Four forms, differing only in how the attacker READS the result:

FormHow results come backSignature
In-band / UNIONResults appear in the responseColumn count must match; ORDER BY n to probe
Error-basedThe DB error message carries the dataCAST((SELECT …) AS int)
Blind booleanResponse differs between true and falseAND 1=1 vs AND 1=2
Blind timeResponse latencypg_sleep(5), WAITFOR DELAY

NoSQL: same boundary, different syntax. {"email": {"$ne": null}} — the attacker sends an object where the code expected a string, and the driver reads $ne as an operator. In Express, ?email[$ne]=null produces exactly that object without any JSON.

Diagram description: A branching diagram comparing two ways of building the same statement from an input containing a quote. The concatenation branch produces a statement with two conditions, so the parser returns every row and the whole table leaks. The parameterised branch keeps the input intact but places it on the data side, so the parser sees one condition and one string value and returns zero rows.

04

Concrete example

A product search endpoint with optional filters and client-chosen sort column — exactly the query shape that makes people concatenate.

Exploit 1 — read another table via UNION:

HTTP
GET /api/products?category=Gifts' UNION SELECT email,password_hash,NULL FROM users-- HTTP/1.1Host: shop.example.com
HTTP
HTTP/1.1 200 OK [{"name":"admin@shop.example.com","description":"$2b$12$Kq3…","price":null}, {"name":"alice@shop.example.com","description":"$2b$12$8Zt…","price":null}]

Exploit 2 — through the sort parameter, where parameterisation does NOT apply:

HTTP
GET /api/products?sort=(CASE WHEN (SELECT current_setting('is_superuser'))='on'                       THEN name ELSE price END) HTTP/1.1

Whether the result order changes is one bit of information. This is why block 6 has to treat ORDER BY separately.

C#Two different bugs: line 12 concatenates a value, line 16 an identifier. Their fixes are NOT the same.
public async Task<List<Product>> Search(string? category, string sort, bool desc){    // Câu truy vấn phải đổi HÌNH DẠNG theo input — đây chính là tình huống khiến    // người ta nối chuỗi, và là chỗ gần như mọi SQL injection thật sự nằm.    var sql = "SELECT id, name, description, price FROM products WHERE 1=1";     if (!string.IsNullOrEmpty(category))        // LỖI 1 — giá trị nối vào cú pháp. ' UNION SELECT … -- đọc được bảng khác.        sql += $" AND category = '{category}'";     // LỖI 2 — định danh nối vào cú pháp. Tham số hoá KHÔNG vá được dòng này;    // nó cần một cách vá khác hẳn (map allowlist), và đó là lý do nó sống lâu.    sql += $" ORDER BY {sort} {(desc ? "DESC" : "ASC")}";     return await _db.Products.FromSqlRaw(sql).ToListAsync();}
TypeScriptNoSQL injection: not a single quote involved, which is why it gets missed.
app.post("/api/login", async (req, res) => {  const { email, password } = req.body;   // Không có SQL, không có dấu nháy — và vẫn là injection.  // Với body {"email": {"$ne": null}, "password": {"$ne": null}} câu này trả về  // user ĐẦU TIÊN trong collection, thường là tài khoản admin tạo lúc seed.  const user = await db.collection("users").findOne({ email, password });   if (!user) return res.status(401).json({ error: "invalid" });  return res.json({ token: sign(user) });});
05

What happened in the wild

TalkTalk, October 2015 — 156,959 customers, £400,000 fine. The ICO report is specific: SQL injection on three websites inherited through an acquisition, running a version of Drupal whose fix had been available three and a half years earlier. The ICO called the failure "basic" and noted TalkTalk did not know those three pages still existed. The lesson is not "parameterise" — it is that code you do not know you are running is code nobody patches.

Heartland Payment Systems, 2008 — ~130 million card numbers. The entry point was SQL injection on a web form; from there the attackers moved laterally into the payment processing network. This is the case for layer 3 in block 6: the DB account's privileges decide whether SQL injection means "one table leaked" or "the whole estate lost".

06

How to defend

Layer 1

Parameterise, with no exception for values

mandatory

Every value entering a query goes through a parameter. No escaping, no string.Format, no interpolation. With an ORM: use LINQ or the query builder; when you must write raw SQL, use its parameterised form (FromSql with a FormattableString in EF Core 8 — NOT a concatenated FromSqlRaw).

C# · Layer 1Values → parameters. Identifiers → allowlist map. Two bugs, two different fixes.
/// <summary>/// Hai kỹ thuật, không phải một, vì hai lỗi ở ví dụ trên khác bản chất://////   • GIÁ TRỊ  → tham số. DB nhận nó ở phía dữ liệu của ranh giới cú pháp.///   • ĐỊNH DANH → map allowlist. Tham số hoá không áp dụng được cho tên cột,///     nên chỗ này KHÔNG có cách nào ngoài việc client chỉ được chọn trong một///     tập hữu hạn mà server định nghĩa./// </summary>public sealed class ProductSearch{    // Client gửi KHOÁ, server tra ra tên cột. Không có đường nào để một chuỗi do    // client kiểm soát đi vào câu lệnh — chỉ có giá trị bên phải của map này đi vào,    // và mọi giá trị đó do chúng ta gõ ra.    private static readonly Dictionary<string, string> SortColumns = new(StringComparer.OrdinalIgnoreCase)    {        ["name"]  = "name",        ["price"] = "price",        ["newest"] = "created_at",    };     private readonly SecLabDbContext _db;     public ProductSearch(SecLabDbContext db) => _db = db;     public async Task<List<Product>> Search(string? category, string sort, bool desc, CancellationToken ct)    {        // Khoá lạ → 400, KHÔNG rơi về mặc định. Rơi về mặc định trông như an toàn        // nhưng làm mất tín hiệu: sẽ không ai biết là có người đang dò tên cột.        if (!SortColumns.TryGetValue(sort, out var column))            throw new ApplicationGeneralException(CatalogErrorsList.INVALID_SORT_KEY,                $"Unknown sort key '{sort}'");         // Đường đi ưa dùng: LINQ. Provider tự tham số hoá, và không có chuỗi SQL nào        // để ai đó nối thêm vào trong lần sửa sau.        var query = _db.Products.AsNoTracking();         if (!string.IsNullOrEmpty(category))            query = query.Where(p => p.Category == category);   // → tham số         // Sắp xếp theo cột đã map. Chỉ có ba chuỗi có thể tới đây, cả ba do ta viết.        query = (column, desc) switch        {            ("name", false)  => query.OrderBy(p => p.Name),            ("name", true)   => query.OrderByDescending(p => p.Name),            ("price", false) => query.OrderBy(p => p.Price),            ("price", true)  => query.OrderByDescending(p => p.Price),            (_, false)       => query.OrderBy(p => p.CreatedAt),            (_, true)        => query.OrderByDescending(p => p.CreatedAt),        };         return await query.Take(100).ToListAsync(ct);    }     /// <summary>    /// Khi buộc phải viết SQL thô (CTE, window function, hint mà LINQ không sinh được):    /// dùng FromSql với FormattableString, KHÔNG phải FromSqlRaw.    ///    /// Khác biệt tinh vi và đáng nhớ: cả hai đều viết được dưới dạng $"…{x}…", nhưng    /// FromSql nhận FormattableString nên nó thấy được x là một hole và biến x thành    /// tham số; FromSqlRaw nhận string nên nội suy đã xảy ra TRƯỚC khi nó được gọi và    /// nó chỉ còn thấy một chuỗi đã ghép xong.    /// </summary>    public Task<List<Product>> TopInCategory(string category, CancellationToken ct) =>        _db.Products.FromSql(                $"""                SELECT * FROM products                 WHERE category = {category}                 ORDER BY price DESC                 LIMIT 10                """)            .AsNoTracking()            .ToListAsync(ct);}
TypeScript · Layer 1Coerce at the boundary. `$ne` can only exist if the value is an object — so rejecting objects is enough.
import { z } from "zod"; // Ranh giới ở đây là ranh giới KIỂU, không phải ranh giới cú pháp: một toán tử// Mongo chỉ tồn tại được nếu giá trị là object. Ép về string là đóng được cả họ lỗi// này, và nó đóng luôn những toán tử chưa ai nghĩ ra.const LoginBody = z.object({  email: z.string().email().max(320),  password: z.string().min(1).max(200),}); app.post("/api/login", async (req, res) => {  const parsed = LoginBody.safeParse(req.body);  if (!parsed.success) return res.status(400).json({ error: "invalid_body" });   const { email, password } = parsed.data;   // cả hai chắc chắn là string   // Và mật khẩu không bao giờ được so ở tầng truy vấn: tra user rồi verify hash,  // để việc so sánh đi qua một hàm hằng-thời-gian thay vì qua index của DB.  const user = await db.collection("users").findOne({ email });  if (!user || !(await argon2.verify(user.passwordHash, password)))    return res.status(401).json({ error: "invalid" });   return res.json({ token: sign(user) });});
Layer 1b

Identifiers (column names, table names, sort direction) cannot be parameterised

This is the only remaining gap and it is where real bugs live. The correct answer is an allowlist map: the client sends a key, the server looks that key up to get the real column name. Do not regex-validate a column name — map it. If the key is not in the map, return 400; do not silently "fall back to the default".

Layer 2

Least privilege for the database account

The app account holds no DROP, no CREATE, is not a superuser, and cannot read pg_shadow. On Postgres: GRANT SELECT, INSERT, UPDATE, DELETE per table, and consider RLS as a second layer for row-level authorisation. This layer decides the scale of the damage when layer 1 has a hole.

Layer 3

Never return database error text

Error-based injection needs the error message. Return an error code with a correlation id and log the detail. This layer slows an attacker down rather than stopping them — which is why it is layer 3, not layer 1.

NoSQL: coerce the type before querying. If the code expects a string, then if (typeof email !== 'string') return 400. With Mongoose, use a schema with type: String and enable sanitizeFilter. In Express, ?email[$ne]=null turns a query param into an object — so type checking is required, not defensive extra.

07

Verifying the fix

1. A unit test proving the payload REACHES the database without changing syntax. This is where most SQL injection tests go wrong: they assert "no error", when what needs asserting is that the malicious data is stored and read back intact, as a string. See the csharp / test tab.

2. Test the ORDER BY allowlist — an unknown key must return 400, not fall back to the default. Falling back looks safe but destroys the signal: nobody learns that someone is probing.

3. A merge-blocking grep in CI. This is the highest-yield check on the page:

Shell
# Any raw SQL carrying string interpolation:grep -rnE '(FromSqlRaw|ExecuteSqlRaw|queryRaw|\$queryRawUnsafe)' --include='*.cs' --include='*.ts' src/ \  | grep -E '\$"|\+ *[a-z]|\$\{' && { echo "raw SQL with concatenation — blocked"; exit 1; }exit 0

4. Check the DB privileges were actually narrowed:

SQL
-- Must return zero rows:SELECT rolname FROM pg_roles WHERE rolname = 'app_api' AND (rolsuper OR rolcreatedb OR rolcreaterole);-- Must show nothing beyond CRUD:SELECT DISTINCT privilege_type FROM information_schema.table_privileges WHERE grantee = 'app_api';

5. Dynamic scanning (sqlmap) in a nightly pipeline, not the PR gate — it is slow and noisy, but it is the only thing that finds what grep cannot see.

C#The test asserts the payload REACHES the DB intact — not merely that "no error occurred".
public class ProductSearchTests{    /// <summary>    /// Phần lớn test SQL injection khẳng định sai thứ: chúng kiểm "không throw", mà    /// một bản vá kiểu blocklist cũng không throw. Test này khẳng định đúng thứ cần:    /// chuỗi độc hại đi tới DB NGUYÊN VẸN như một giá trị, khớp đúng 0 hàng, và    /// bảng users vẫn còn nguyên.    /// </summary>    [Theory]    [InlineData("Gifts' UNION SELECT email,password_hash,NULL FROM users--")]    [InlineData("Gifts' OR 1=1--")]    [InlineData("Gifts'; DROP TABLE products;--")]    [InlineData("Gifts' AND (SELECT pg_sleep(5))--")]    public async Task Malicious_category_is_data_not_syntax(string payload)    {        var usersBefore = await _db.Users.CountAsync();         var result = await _search.Search(payload, "name", false, default);         // 0 hàng: không có category nào TÊN như vậy — đúng như một so sánh chuỗi.        Assert.Empty(result);        // Bảng users còn nguyên: câu lệnh thứ hai không chạy.        Assert.Equal(usersBefore, await _db.Users.CountAsync());    }     /// <summary>    /// Khoá sort lạ phải 400. Nếu test này pass khi ta đổi throw thành "rơi về    /// created_at" thì test đang cho phép đúng cái hành vi làm mất tín hiệu dò.    /// </summary>    [Theory]    [InlineData("(CASE WHEN (SELECT current_setting('is_superuser'))='on' THEN name ELSE price END)")]    [InlineData("name; DROP TABLE products")]    [InlineData("created_at")]        // tên cột THẬT nhưng không có trong map    public async Task Unknown_sort_key_is_rejected(string sort)    {        var ex = await Assert.ThrowsAsync<ApplicationGeneralException>(            () => _search.Search(null, sort, false, default));         Assert.Contains("Unknown sort key", ex.Message);    }}
08

Common mistakes

The "fix"Why it is wrong
Escape single quotes (''')Useless in numeric context: id=1 OR 1=1 has no quote at all. And multi-byte encodings put the quote back
Blocklist UNION, SELECT, --UNiOn, /**/, %55NION, nested comments — and you just broke search for the user named "Select"
"We use an ORM, so we are safe"True for what you wrote in LINQ. FromSqlRaw($"…{input}") still concatenates, and $"…" makes it look parameterised
"Stored procedures are safe"An sp_ containing EXEC(@sql) is SQL injection one layer deeper, and harder to see
Regex-validate the column name for ORDER BYname matches the regex, and so can (CASE WHEN …) against a loose-enough one. An allowlist map has no "loose enough"
Block it at the WAFBypassable, and it does not fix the bug. A legitimate stop-gap while you ship the fix — not the fix

The NoSQL-specific mistake: assuming "no SQL means no injection". db.users.find({email: req.query.email}) with ?email[$ne]=null returns every user, and there is not one quote in it.

The layering mistake: fixing SQL injection in input validation instead of in query construction. Validation is good and worth having, but it depends on you predicting every payload; parameterisation predicts nothing.

09

References

Tier 1A03:2021 – Injection · OWASP · Top 10 · 2021
Tier 1Querying data with raw SQL — FromSql and parameterisation · Microsoft · EF Core docs · EF Core 8
Tier 1ICO monetary penalty notice — TalkTalk Telecom Group PLC · UK Information Commissioner's Office · 2016-10-05
Tier 2SQL injection · PortSwigger · Web Security Academy
Tier 2NoSQL injection · PortSwigger · Web Security Academy
Tier 2SQL Injection Prevention Cheat Sheet · OWASP · Cheat Sheet Series
Part of path
Secure Backend DeveloperView path

Comments

Join the discussion
Sign up to comment

Commenting needs an account with at least one completed lesson. That condition is what keeps this thread worth reading: every point belongs to someone who can be asked back, and reputation accrues over time.

Sign upSign in

You can still read every comment below without an account. Signing in brings you back to this exact spot, not to the top of the page.

Loading comments…