Web Security & Ethical Hacking · Lesson 4 of 23
SQL Injection — How It Works and How to Stop It
Why SQL Injection Is Still #1
SQL injection has been in the OWASP Top 10 for over 20 years. It should be a solved problem. It isn't — because developers keep concatenating strings with SQL, ORMs have unsafe methods that look safe, and legacy codebases accumulate vulnerabilities faster than they get fixed.
A successful SQL injection attack can result in full database exfiltration, authentication bypass, data modification, and in some configurations, remote code execution via xp_cmdshell.
The Classic Login Bypass
The textbook example, but it still works in real systems:
// VULNERABLE — never do this
string query = $"SELECT * FROM Users WHERE Username = '{username}' AND Password = '{password}'";
var user = db.Query<User>(query).FirstOrDefault();Attacker enters username: admin' --
The resulting query:
SELECT * FROM Users WHERE Username = 'admin' --' AND Password = 'anything'-- is a SQL comment. Everything after it is ignored. The query becomes "find any user named admin" — no password check. The attacker logs in as admin.
Variation — always-true payload: ' OR '1'='1
SELECT * FROM Users WHERE Username = '' OR '1'='1' AND Password = '' OR '1'='1'Returns every user. The first one is often an admin.
UNION-Based Injection
Used to extract data from other tables:
Vulnerable URL: /products?category=electronics
Payload: electronics' UNION SELECT username, password, NULL FROM Users --The attacker first determines the number of columns in the original query (using ORDER BY 1, ORDER BY 2, etc. until an error), then constructs a UNION query that matches that column count. The injected SELECT results appear in the normal application response.
Blind SQL Injection
When the application doesn't show query results but behaves differently based on them.
Boolean-based blind: the attacker asks true/false questions and reads the result from page behavior.
/users?id=1 AND (SELECT SUBSTRING(password,1,1) FROM Users WHERE id=1)='a'If the page loads normally → first character of password is 'a'. Error/empty → try 'b'. Automated tools can dump an entire database character by character in minutes.
Time-based blind: the attacker uses SLEEP() or WAITFOR DELAY to infer data when there's no visible difference.
-- SQL Server
'; IF (SELECT COUNT(*) FROM Users WHERE Username='admin') > 0 WAITFOR DELAY '0:0:5' --
-- MySQL
'; SELECT IF(1=1, SLEEP(5), 0) --If the page takes 5 seconds to respond, the condition is true. Attackers use this when absolutely no output is reflected.
The Fix: Parameterized Queries
The rule is simple: never concatenate user input into SQL. Use parameters — the database driver keeps data and code separate at the protocol level.
// VULNERABLE
string query = $"SELECT * FROM Users WHERE Username = '{username}'";
// SAFE — raw ADO.NET
using var cmd = new SqlCommand(
"SELECT * FROM Users WHERE Username = @username AND Password = @password",
connection
);
cmd.Parameters.AddWithValue("@username", username);
cmd.Parameters.AddWithValue("@password", hashedPassword);
var reader = await cmd.ExecuteReaderAsync();With Dapper:
// SAFE — Dapper parameterizes automatically when you use an anonymous object
var user = await connection.QuerySingleOrDefaultAsync<User>(
"SELECT * FROM Users WHERE Username = @Username AND PasswordHash = @Hash",
new { Username = username, Hash = hashedPassword }
);
// VULNERABLE — Dapper string interpolation bypasses parameterization
var user = await connection.QueryAsync<User>(
$"SELECT * FROM Users WHERE Username = '{username}'" // DON'T DO THIS
);EF Core — Protected and Not Protected
EF Core's LINQ queries are always parameterized:
// SAFE — EF Core translates this to a parameterized query
var user = await dbContext.Users
.Where(u => u.Username == username && u.PasswordHash == hash)
.FirstOrDefaultAsync();But EF Core has raw SQL methods, and their safety depends on how you use them:
// SAFE — FromSqlRaw with parameters
var users = dbContext.Users
.FromSqlRaw("SELECT * FROM Users WHERE Username = {0}", username)
.ToList();
// Also SAFE — FromSqlInterpolated uses FormattableString, parameterizes the interpolation
var users = dbContext.Users
.FromSqlInterpolated($"SELECT * FROM Users WHERE Username = {username}")
.ToList();
// DANGEROUS — string interpolation before passing to FromSqlRaw
string sql = $"SELECT * FROM Users WHERE Username = '{username}'";
var users = dbContext.Users.FromSqlRaw(sql).ToList(); // Injection vulnerability!The distinction: FromSqlInterpolated accepts a FormattableString and extracts parameters. FromSqlRaw accepts a plain string — if you've already interpolated values into it, they're baked in as literals.
Stored Procedures — Not a Complete Defense
Common misconception: "We use stored procedures, so we're safe."
-- Safe stored procedure
CREATE PROCEDURE GetUser @Username NVARCHAR(100)
AS
SELECT * FROM Users WHERE Username = @Username
-- STILL VULNERABLE stored procedure (dynamic SQL inside)
CREATE PROCEDURE SearchUsers @Filter NVARCHAR(200)
AS
DECLARE @sql NVARCHAR(500)
SET @sql = 'SELECT * FROM Users WHERE Username LIKE ''%' + @Filter + '%'''
EXEC(@sql) -- Injection inside the stored procedure!Stored procedures are safe only if they don't construct dynamic SQL internally. Parameterized stored procedures called from parameterized ADO.NET are fine.
Second-Order Injection
The attack payload is stored safely (parameterized insert), then retrieved and used unsafely in a subsequent SQL query.
// Step 1 — safe insert (attacker registers with username: admin'/--)
await connection.ExecuteAsync(
"INSERT INTO Users (Username) VALUES (@Username)",
new { Username = userInput } // Stored safely as literal string
);
// Step 2 — unsafe use later (password change feature)
string username = await GetCurrentUsername(userId); // Returns: admin'--
string updateQuery = $"UPDATE Users SET Password = '{newPass}' WHERE Username = '{username}'";
// username came from the DB so the developer "trusted" it — but it contains injection payloadThe fix is the same: parameterize every query, regardless of where the data came from. Data from your own database is not automatically safe.
NoSQL Injection — MongoDB Operator Injection
NoSQL databases are also vulnerable, just differently. MongoDB accepts JSON queries, and if you build them from user input, attackers inject operators.
// Vulnerable Node.js/MongoDB — but the same concept applies to C# MongoDB driver
const user = await db.collection('users').findOne({
username: req.body.username,
password: req.body.password
});
// Attacker sends JSON body: { "username": "admin", "password": { "$gt": "" } }
// Query becomes: { username: "admin", password: { $gt: "" } }
// $gt: "" matches any non-empty string — authentication bypassedIn C# with the MongoDB driver:
// VULNERABLE — building filter from string
var filter = BsonDocument.Parse($"{{ username: '{username}', password: '{password}' }}");
// SAFE — use typed builder
var filter = Builders<User>.Filter.And(
Builders<User>.Filter.Eq(u => u.Username, username),
Builders<User>.Filter.Eq(u => u.PasswordHash, hashedPassword)
);
var user = await collection.Find(filter).FirstOrDefaultAsync();Defense Checklist
- Always use parameterized queries or ORM LINQ — zero exceptions
- Review every
FromSqlRaw,ExecuteRawSql, orDapper.Querycall for string interpolation - Never trust data from the database for subsequent queries without parameterizing
- Enable least privilege: the app's DB user should not have
DROP,CREATE, orxp_cmdshellaccess - Use an ORM as your default — raw SQL only when necessary
- Run automated scanning (SAST tools like Semgrep have SQL injection rules)
- Test with sqlmap against your own staging environment