15.3 SQL Injection
Table of Contents
Understanding SQL Injection
SQL injection is one of the most dangerous and common attacks against backend applications that use relational databases. It happens when user input ends up inside an SQL query in an unsafe way, so the attacker can change what the query does.
This chapter focuses on how SQL injection works, how it is exploited, and how to defend against it in the context of backend development.
Core idea of SQL injection
User-controlled data must never be directly concatenated into SQL strings.
You must use parameterized queries or safe query builders / ORMs for all dynamic values.
A Simple Example Of SQL Injection
Vulnerable login query
Imagine a simple login endpoint:
username = request.form["username"]
password = request.form["password"]
query = (
"SELECT * FROM users "
"WHERE username = '" + username + "' "
"AND password = '" + password + "';"
)
cursor.execute(query)
user = cursor.fetchone()If the user submits:
username = alicepassword = secret
The query becomes:
SELECT * FROM users
WHERE username = 'alice'
AND password = 'secret';That looks fine. But an attacker can send:
username = alice' --password = anything
Then the query becomes:
SELECT * FROM users
WHERE username = 'alice' --'
AND password = 'anything';
In SQL, -- starts a comment to the end of the line. Everything after -- is ignored:
SELECT * FROM users
WHERE username = 'alice'
Now the password check is removed. The attacker logs in as alice without knowing her password.
How SQL Injection Works
Where the vulnerability comes from
The problem is this pattern:
"SELECT ... WHERE column = '" + user_input + "';"The application builds SQL by mixing code and data in a single string. The database cannot tell which characters are part of the data and which are part of the SQL syntax.
If the user input contains characters like ', ", ;, --, /, /, or keywords like OR, AND, UNION, DROP, it can change how the SQL is interpreted.
Typical attack payloads
Some classic malicious inputs:
| Goal | Example input | Effect |
|---|---|---|
| Bypass login check | ' OR 1=1 -- | Makes condition always true |
| Bypass numeric check | 0 OR 1=1 | Makes id = 0 OR 1=1, returns many rows |
| Terminate and add query | 1; DROP TABLE users; -- | Ends original query, runs DROP TABLE |
| Read more data | ' UNION SELECT * FROM credit_cards -- | Combines results from another table |
| Detect vulnerability | ' or " or ') | Often causes syntax errors, reveals stack traces |
Example of a login bypass payload:
username: ' OR 1=1 --
password: anythingQuery becomes:
SELECT * FROM users
WHERE username = '' OR 1=1 --'
AND password = 'anything';
OR 1=1 is always true, the password condition is commented out, so the attacker usually gets access as the first user in the table.
Types Of SQL Injection
Some useful categories for understanding how attacks work.
1. In-band SQL injection
The attacker both sends the malicious input and receives results using the same channel (the normal HTTP response).
Two common subtypes:
| Type | Description | Example effect |
|---|---|---|
| Error-based | Forces database errors to reveal information | Leaks table/column names, DB version |
| Union-based | Uses UNION SELECT to combine attacker-chosen data | Shows data from other tables in page |
Example, UNION-based:
Suppose the app runs:
SELECT id, title, body
FROM posts
WHERE id = <user_input>;
If the attacker controls id and the app prints titles and bodies directly, they can send:
id = 1 UNION SELECT 1, username, password FROM usersFinal query:
SELECT id, title, body
FROM posts
WHERE id = 1 UNION
SELECT 1, username, password FROM users;The page might now display usernames and password hashes instead of blog posts.
2. Blind SQL injection
The application does not show database errors or query results directly. But the attacker can still infer information from behavior changes, like:
- Whether a page loads normally or with an error page.
- How long the response takes.
Two main forms:
| Type | Description | Example |
|---|---|---|
| Boolean-based | Uses true/false conditions and observes differences | AND 1=1 vs AND 1=2 |
| Time-based | Uses SLEEP() or similar functions and measures time | AND IF(condition, SLEEP(5), 0) |
Boolean-based example:
The app runs:
SELECT * FROM products WHERE id = <user_input>;Attacker tests:
id = 1 AND 1=1
id = 1 AND 1=2If the first returns a normal page and the second returns an empty or error page, the attacker knows the input is inside an SQL condition and can build more complex conditions to extract data bit by bit.
3. Out-of-band SQL injection
The attacker cannot use the normal HTTP response or timing easily, but the database can reach out to external services.
For example, some databases can:
- Make DNS requests using attacker-controlled hostnames.
- Make HTTP requests or write files.
The attacker crafts SQL that triggers such behavior and then monitors their own server logs to see which data was exfiltrated.
This is rarer in typical backend apps for beginners, but it exists, especially when database features like LOAD_FILE or external functions are enabled.
Where SQL Injection Shows Up In Backends
Common vulnerable places
SQL injection can happen anywhere user input ends up in a query:
- Authentication
Login forms, "remember me" tokens, password reset tokens. - Search
Search boxes with queries like:
WHERE title LIKE '%<user_input>%'- Filtering, sorting, pagination
WHEREclauses built from filters.ORDER BYbuilt from sort fields.LIMITandOFFSETfrom user input.- Admin panels
"Export CSV" features, reporting dashboards, advanced filters. - APIs
Query parameters and JSON bodies used directly in SQL. - Hidden input sources
- HTTP headers (User-Agent, Referer).
- Cookies.
- URL path segments.
Any place where developers think "users cannot modify this" is dangerous. Attackers often can.
Example: search endpoint
Vulnerable code:
search = request.args.get("q", "")
query = f"SELECT * FROM products WHERE name LIKE '%{search}%';"
cursor.execute(query)Attacker sends:
?q=' OR 1=1 --Query becomes:
SELECT * FROM products
WHERE name LIKE '%' OR 1=1 -- %';
The OR 1=1 makes the condition always true, so the attacker sees every product, possibly including unpublished ones.
Real-World Impact Of SQL Injection
Depending on how severe the vulnerability is, SQL injection can allow attackers to:
| Impact level | Possible attacker actions |
|---|---|
| Read data | Dump users, emails, password hashes, secrets, logs, etc. |
| Modify data | Change balances, approve orders, alter permissions |
| Delete data | Drop tables, truncate logs, erase users |
| Escalate privileges | Promote self to admin, create backdoor accounts |
| Execute system commands | On some setups, run OS commands through DB functions |
| Persist further access | Install malicious triggers, store webshells, change config |
For a backend developer, even "just" reading the users table can be catastrophic, because passwords, tokens, etc, may be leaked.
Defending Against SQL Injection
1. Use parameterized queries
This is the most important protection.
With parameterized queries (also called prepared statements), you write SQL with placeholders, and pass user values separately:
# Python with psycopg2 (PostgreSQL)
username = request.form["username"]
password = request.form["password"]
query = """
SELECT * FROM users
WHERE username = %s
AND password_hash = crypt(%s, password_hash);
"""
cursor.execute(query, (username, password))
user = cursor.fetchone()The driver:
- Sends the SQL structure with placeholders to the database.
- Sends the values separately.
- The database always treats values as data, not SQL code.
Even if username is:
alice' OR 1=1 --The driver will escape it correctly and the query will still have the same structure:
WHERE username = 'alice'' OR 1=1 --'The attacker cannot break out of the string literal.
Examples in different languages / libraries
| Environment | Placeholder style | Example |
|---|---|---|
| psycopg2 (Python) | %s | cursor.execute("... WHERE id = %s", (id_val,)) |
| sqlite3 (Python) | ? | cursor.execute("... WHERE id = ?", (id_val,)) |
| Node, pg | $1, $2, ... | client.query("... WHERE id = $1", [idVal]) |
| Java, JDBC | ? | PreparedStatement ps = con.prepareStatement("...") |
| Go, database/sql | ? | db.Query("... WHERE id = ?", idVal) |
Rule: Always use parameterized queries
Any time you include user-controllable values in SQL, you must use parameters, never string concatenation or manual interpolation.
2. Use an ORM or safe query builder
ORMs and query builders usually parameterize values for you.
Example with SQLAlchemy (Python):
user = db.query(User).filter(User.username == username).first()Example with SQLAlchemy Core:
from sqlalchemy import select
stmt = select(User).where(User.id == user_id)
result = session.execute(stmt)The ORM converts this to parameterized SQL under the hood.
However:
- If you pass raw SQL strings directly to the ORM, you can still create vulnerabilities.
- When using raw fragments like
text("..."), you must still use parameters.
Unsafe:
stmt = text(f"SELECT * FROM users WHERE username = '{username}'")
session.execute(stmt)Safe:
stmt = text("SELECT * FROM users WHERE username = :username")
session.execute(stmt, {"username": username})3. Never build queries by concatenating strings
Avoid this pattern:
query = "SELECT * FROM users WHERE username = '" + username + "';"Or:
query = f"SELECT * FROM users WHERE username = '{username}';"Or:
query = "SELECT * FROM users WHERE username = '%s';" % usernameEven if you "sanitize" the input, this pattern is hard to make safe and easy to get wrong.
A few exceptions may exist, for example when constructing dynamic parts that cannot be parameterized, like column names or sort directions. We will cover that later.
4. Whitelist identifiers for dynamic SQL
Sometimes you need to build queries with dynamic:
- Column names in
ORDER BY. - Table names.
- Sort directions like
ASCorDESC.
Most database drivers do not let you parameterize these, only values. In these cases, use whitelisting.
Example: safe sorting
# User provides sort field and direction
sort_field = request.args.get("sort", "created_at")
sort_dir = request.args.get("dir", "desc")
allowed_fields = {
"created_at": "created_at",
"price": "price",
"name": "name",
}
allowed_dirs = {"asc": "ASC", "desc": "DESC"}
field = allowed_fields.get(sort_field, "created_at")
direction = allowed_dirs.get(sort_dir, "DESC")
query = f"SELECT * FROM products ORDER BY {field} {direction} LIMIT %s OFFSET %s"
cursor.execute(query, (limit, offset))- The column name and direction come from fixed sets of strings defined in code.
- The values
limitandoffsetare still parameters, not interpolated.
Attacker inputs like price; DROP TABLE users; -- will not be found in allowed_fields, so they are ignored.
5. Validate and constrain input
Input validation is not a complete defense, but it helps:
- Ensure numeric fields are really numbers.
- Enforce maximum lengths.
- Restrict allowed characters for certain fields.
Examples:
# Numeric ID from path
try:
user_id = int(request.path_params["user_id"])
except ValueError:
raise HTTPException(status_code=400, detail="Invalid user id")
# Limit search length and characters
search = request.query_params.get("q", "")
if len(search) > 100:
raise HTTPException(status_code=400, detail="Search too long")Even with validation, you still must use parameterized queries.
6. Principle of least privilege for database users
Use database accounts with the minimum required permissions for the application.
For example:
- The public web application user should not have
DROP TABLEorCREATE DATABASEpermissions. - For read-only operations, consider a separate read-only database user.
Then, even if SQL injection happens, the impact is reduced.
Example table of roles:
| Role | Example permissions | Used for |
|---|---|---|
app_read_write | SELECT, INSERT, UPDATE, limited DELETE | Normal app behavior |
app_read_only | SELECT only | Analytics pages, public APIs |
db_admin | Full privileges | Migrations, maintenance only |
7. Avoid exposing raw error messages
Detailed SQL error messages can help attackers craft injection payloads:
- Syntax errors reveal where the injection is.
- Error messages show table and column names.
- Stack traces reveal library versions and code structure.
In production:
- Log detailed errors internally.
- Return generic error messages to clients.
Example:
try:
cursor.execute(query, params)
except Exception:
logger.exception("Database query failed")
raise HTTPException(status_code=500, detail="Internal server error")Examples Of Vulnerable And Safe Code
Example 1: User profile endpoint
Vulnerable
# GET /users?username=<username>
username = request.args["username"]
query = f"SELECT id, username, email FROM users WHERE username = '{username}';"
cursor.execute(query)
user = cursor.fetchone()Payload:
username = alice' OR '1'='1Query:
SELECT id, username, email FROM users
WHERE username = 'alice' OR '1'='1';Attacker gets all users.
Safe
username = request.args["username"]
query = "SELECT id, username, email FROM users WHERE username = %s;"
cursor.execute(query, (username,))
user = cursor.fetchone()Payload is safely treated as a string value.
Example 2: Search with LIKE
Vulnerable
search = request.args.get("q", "")
query = "SELECT * FROM articles WHERE title LIKE '%" + search + "%';"
cursor.execute(query)Safe
search = request.args.get("q", "")
pattern = "%" + search + "%"
query = "SELECT * FROM articles WHERE title LIKE %s;"
cursor.execute(query, (pattern,))Example 3: Numeric ID
Assume an endpoint:
# GET /product/<id>
product_id = request.path_params["id"]
query = f"SELECT * FROM products WHERE id = {product_id};"
cursor.execute(query)
product = cursor.fetchone()
Even if id is supposed to be an integer, the application might receive:
id = 1; DROP TABLE products; --Query becomes:
SELECT * FROM products WHERE id = 1; DROP TABLE products; --;Safe version
product_id = int(request.path_params["id"]) # may raise ValueError
query = "SELECT * FROM products WHERE id = %s;"
cursor.execute(query, (product_id,))Or, in an ORM:
product_id = int(request.path_params["id"])
product = db.query(Product).filter(Product.id == product_id).first()Testing For SQL Injection Vulnerabilities
As a backend developer, you should learn to spot and test for potential SQL injection issues.
Basic manual tests
- Try special characters
Fields likely used in SQL:
- Add a single quote:
' - Input:
abc' - If you get a database error like "syntax error near 'abc''", it is suspicious.
- Try OR conditions
abc' OR '1'='1- Check if you suddenly get more results than expected.
- Numeric fields
- Replace
123with123 OR 1=1 - If the response changes, it may be vulnerable.
- Time-based tests
If the database supports SLEEP, try:
1 AND SLEEP(5)- If the response slows down significantly only with the injected part, this suggests a vulnerability.
Automated tools
Security professionals often use tools like:
sqlmap- Burp Suite extensions
For learning purposes, you can:
- Set up intentionally vulnerable apps like DVWA (Damn Vulnerable Web Application).
- Observe how tools detect and exploit SQL injection.
Never run such tools against systems you do not own or do not have explicit permission to test. That is illegal.
Special Cases And Tricky Spots
Dynamic WHERE clauses
Sometimes you build complex filters based on many optional parameters:
conditions = []
if "status" in filters:
conditions.append(f"status = '{filters['status']}'")
if "category" in filters:
conditions.append(f"category = '{filters['category']}'")
where_clause = " AND ".join(conditions) if conditions else "1=1"
query = "SELECT * FROM orders WHERE " + where_clause
cursor.execute(query)This is deeply vulnerable, because multiple user-controlled values are inserted directly.
Safe approach
Build conditions and parameters together:
conditions = []
params = []
if "status" in filters:
conditions.append("status = %s")
params.append(filters["status"])
if "category" in filters:
conditions.append("category = %s")
params.append(filters["category"])
where_clause = " AND ".join(conditions) if conditions else "1=1"
query = "SELECT * FROM orders WHERE " + where_clause
cursor.execute(query, params)The SQL structure stays fixed, only values are parameterized.
Stored procedures and SQL injection
Using stored procedures does not automatically prevent SQL injection.
- If the procedure uses string concatenation with input parameters, it can still be vulnerable.
- The same rules apply inside stored procedures: use parameters, not concatenated SQL.
Example of vulnerable stored procedure:
CREATE PROCEDURE GetUser(IN p_username VARCHAR(50))
BEGIN
SET @sql = CONCAT('SELECT * FROM users WHERE username = ''', p_username, '''');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
END;
If p_username contains ' OR 1=1 --, the procedure becomes vulnerable.
Summary
Key points to remember as a backend developer:
- Never build SQL by concatenating or interpolating user input into query strings.
- Always use parameterized queries or safe ORMs / query builders for all dynamic values.
- When you must build dynamic SQL for identifiers like column names, use strict whitelisting.
- Validate input, limit privileges of your database users, and avoid exposing raw SQL errors.
If you consistently follow these rules, you will prevent almost all SQL injection vulnerabilities in your backend applications.
Views: 11
KAHIBARO