SQL Injection: Anatomy, Detection, and Defense — A Hands-On Security Practitioner's Guide
Chapter 8 of 10 · updated Jul 03, 2026
Reference: Database-Specific Features and Injection Behaviors Compared
Reference: Database-Specific Features and Injection Behaviors Compared
When you're mid-assessment and the target database engine turns out to be something other than what you expected, you need answers fast. I've been on ShopBox variants where the MySQL backend at 192.0.2.20 was documented wrong in the asset inventory, and the PostgreSQL instance at 192.0.2.30 was the one actually fielding login queries. This page is the cheat sheet I wish I'd had laminated on my first few engagements. Cross-reference with the manual exploitation patterns from "First Blood" (page 3) and the sqlmap behaviors from page 4.
Table 1: String Construction and Comment Syntax
| Feature | MySQL 8.x | PostgreSQL 16 | SQL Server 2022 |
|---|---|---|---|
| String concatenation | CONCAT('a','b') or 'a' 'b' (space-separated literals) | 'a' \|\| 'b' or CONCAT('a','b') | 'a' + 'b' or CONCAT('a','b') |
| Single-line comment | -- (requires trailing space) or # | -- (standard) | -- (standard) |
| Multi-line comment | /* ... */ | /* ... */ | /* ... */ |
| String literal quotes | Single ' only; " is identifier | Single '; E'\n' for escape sequences | Single '; quoted identifiers use [ ] or " |
| Null byte handling | Stops parsing at %00 in some contexts | Treated as data, not terminator | Treated as data, not terminator |
| Case sensitivity | Database/table names: filesystem-dependent on Linux | Identifiers fold to lower unless quoted | Case-insensitive by default for identifiers |
Sam's note: The MySQL
--double-dash with trailing space is a gotcha that burned me early. Without that space, MySQL treats--as a subtraction operator and keeps parsing. I hit this on a ShopBox MySQL login form whereadmin'--(no space) failed silently butadmin'--worked. PostgreSQL doesn't care about that trailing space. SQL Server's--behaves like PostgreSQL's.
In plain terms: the same payload skeleton won't port cleanly. Always verify comment syntax first with a benign true/false test before building complex injections.
Table 2: Schema Enumeration — System Catalog Equivalents
| Information Target | MySQL 8.x | PostgreSQL 16 | SQL Server 2022 |
|---|---|---|---|
| List databases | information_schema.schemata or SHOW DATABASES | information_schema.schemata | sys.databases or information_schema.schemata |
| List tables in current DB | information_schema.tables | information_schema.tables | sys.tables or information_schema.tables |
| List columns in a table | information_schema.columns | information_schema.columns | sys.columns joined to sys.tables, or information_schema.columns |
| Current database name | DATABASE() | current_database() | DB_NAME() |
| Current user | CURRENT_USER() or USER() | current_user | SUSER_SNAME() or CURRENT_USER |
| Database version | VERSION() | version() | @@VERSION |
| Hostname | @@hostname | inet_server_addr() (IP) or inet_server_port() | @@SERVERNAME |
Sam's note: SQL Server's dual catalog system (
INFORMATION_SCHEMAfor ANSI compatibility,sys.*for Microsoft-native) is genuinely useful.sys.tablesincludesis_ms_shippedto filter out system tables, whichINFORMATION_SCHEMA.tablesdoesn't give you directly. I've seen junior analysts waste time enumeratingINFORMATION_SCHEMAon SQL Server whensys.tableswould have been cleaner. On the PostgreSQL ShopBox at 192.0.2.30,information_schemaworks fine butpg_catalog.pg_tablesis faster for large schemas because it's the underlying implementation table.
Table 3: Time-Delay Functions for Blind Detection
| Engine | Function | Typical Precision | Notes from the Field |
|---|---|---|---|
| MySQL 8.x | SLEEP(n) | Second granularity | Most reliable; returns 0 after delay. Beware: SLEEP() in SELECT can be optimized away in WHERE clauses if the optimizer decides the row doesn't match. |
| MySQL 8.x | BENCHMARK(count, expr) | Sub-second possible | CPU-bound, not clock-bound; noisy on shared hosting. I avoid it now. |
| PostgreSQL 16 | pg_sleep(n) | Millisecond with pg_sleep(interval) | pg_sleep('0.5') works for half-second. Requires EXECUTE privilege on the function, which is default public but can be revoked. |
| PostgreSQL 16 | pg_sleep_for(interval) | Same | Syntactic variant; same underlying mechanism. |
| SQL Server 2022 | WAITFOR DELAY '00:00:05' | ~10-15ms practical floor | Requires no special privileges. String format is rigid: HH:MM:SS or HH:MM:SS.mmm. |
| SQL Server 2022 | WAITFOR TIME '12:00:00' | Clock-aligned | Rarely useful for injection; included for completeness. |
Sam's note: Blind detection via timing is where you earn your patience. On a loaded ShopBox MySQL instance,
SLEEP(5)might take 8 seconds wall-clock due to query queueing, so I always establish a baseline withSLEEP(0)andSLEEP(5)on a known-true condition before interpreting results. PostgreSQL'spg_sleepaccepts fractional seconds more naturally than MySQL's integer-onlySLEEP(). SQL Server'sWAITFORis the most verbose syntax but also the most predictable in my experience — I've never seen it optimized away.
⚠️ Authorized, defensive use only.
Table 4: File System and Command Execution Primitives
| Capability | MySQL 8.x | PostgreSQL 16 | SQL Server 2022 |
|---|---|---|---|
| Read files from server | LOAD_FILE('/path') — requires FILE privilege and secure_file_priv setting | pg_read_file('path', offset, length) in pg_catalog; superuser or explicit grant | OPENROWSET(BULK...) or BULK INSERT; complex ACLs |
| Write files to server | INTO OUTFILE '/path' or INTO DUMPFILE — same privilege constraints | COPY TO with PROGRAM or file path; superuser typically required | xp_cmdshell with redirection, or sp_OACreate/sp_OAMethod alternatives |
| Direct OS command execution | Not natively available — requires UDF (User-Defined Function) or plugin like lib_mysqludf_sys | COPY ... FROM PROGRAM 'command' (superuser); or plpythonu/plperlu extensions | xp_cmdshell 'command' — extended stored procedure |
| Enable disabled execution | N/A (no built-in equivalent) | Extension loading via CREATE EXTENSION or ALTER SYSTEM | sp_configure 'xp_cmdshell', 1; RECONFIGURE; |
| Privilege requirement for execution | FILE for read/write; UDF requires INSERT to mysql.func plus filesystem access | Superuser for COPY PROGRAM; extension-dependent otherwise | sysadmin role by default; proxy accounts possible per [S3] |
Sam's note: The SQL Server
xp_cmdshellchain is the only one I can speak to with confidence from repeated lab work. The full enable sequence, as we touched on in "The Attacker's Toolkit" (page 2):
-- Check current setting
EXEC sp_configure 'xp_cmdshell';
-- If 'show advanced options' is 0, enable it first
EXEC sp_configure 'show advanced options', 1;
RECONFIGURE;
-- Now enable xp_cmdshell
EXEC sp_configure 'xp_cmdshell', 1;
RECONFIGURE;
Why this matters:
RECONFIGUREis not optional — [S3] confirms the setting change doesn't take effect without it. I've watched students enablexp_cmdshell, try to run it, get a permissions error, and assume they're notsysadmin. Nope — they just forgotRECONFIGURE. Thesp_configureoutput showingrun_value≠config_valueis your diagnostic.
For MySQL and PostgreSQL command execution, I have to hedge: I've used MySQL UDFs in CTF environments but never in a production assessment, and the lib_mysqludf_sys project maintenance status varies. PostgreSQL's COPY ... FROM PROGRAM is powerful but I've encountered it more often disabled or restricted than enabled. Check your target's pg_hba.conf and postgresql.conf for allow_system_table_mods and related settings.
Table 5: Prepared Statement Syntax and Parameter Binding
| Aspect | MySQL 8.x | PostgreSQL 16 | SQL Server 2022 |
|---|---|---|---|
| Client-side prepared statement API | PREPARE stmt FROM 'SELECT ... WHERE id = ?' | PREPARE stmt (INT) AS SELECT ... WHERE id = $1 | sp_executesql N'SELECT ... WHERE id = @p1', N'@p1 INT', @p1 = ? |
| Parameter placeholder | ? (positional) | $1, $2, etc. (positional) | @name (named) or ? with ADO.NET |
| Binary protocol support | Yes (mysqlnd, C API) | Yes (extended query protocol) | Yes (Tabular Data Stream, TDS) |
| Server-side cursor behavior | Optional; default is emulate prepared | True server-side prepare by default | sp_executesql compiles and caches plan |
| PDO/PHP binding example | $stmt->bind_param('i', $id) | $stmt->bindParam(1, $id, PDO::PARAM_INT) | $cmd->Parameters->AddWithValue('@p1', $id) |
| ORM abstraction | Doctrine, Eloquent handle internally | Doctrine, Eloquent handle internally | Entity Framework, Dapper |
Sam's note: The core principle from [S1] holds everywhere — the SQL template goes to the server first, parameters second. But the implementation details matter for defense. MySQL's
PREPAREfrom string concatenation is still vulnerable if you build the template string with user input:PREPARE stmt FROM CONCAT('SELECT * FROM ', user_input)defeats the purpose. I've seen this in legacy ShopBox code that "used prepared statements" but concatenated the table name.
PostgreSQL's numbered parameters ($1) are less readable but harder to confuse in complex queries. SQL Server's named parameters (@p1) shine in stored procedures but require discipline — dynamic SQL inside sp_executesql with string concatenation is still injection city.
In plain terms: prepared statements are not magic. The template must be fixed. If any part of the SQL structure comes from user input, you're back to square one.
Quick Cross-Reference: ShopBox Payload Porting
| Attack Step (from Page 3) | MySQL 192.0.2.20 | PostgreSQL 192.0.2.30 |
|---|---|---|
| Confirm injection point | 1' AND SLEEP(5)-- | 1'; SELECT pg_sleep(5)-- |
| Enumerate database name | UNION SELECT 1,DATABASE(),3-- | UNION SELECT 1,current_database(),3-- |
| Enumerate tables | FROM information_schema.tables WHERE table_schema=DATABASE() | FROM information_schema.tables WHERE table_schema=current_database() |
| Extract column values | GROUP_CONCAT(username,':',password) | STRING_AGG(username || ':' || password, ',') |
The GROUP_CONCAT versus STRING_AGG difference bit me on a timed assessment once. MySQL's GROUP_CONCAT has a default 1024-byte limit (configurable via group_concat_max_len); PostgreSQL's STRING_AGG has no such limit by default. SQL Server would use STRING_AGG as of 2017, or FOR XML PATH('') on older versions — check your target's build.
Sam's closing note for this page: I keep a printed copy of Tables 1 and 2 taped inside my notebook. The rest I can reconstruct, but comment syntax and catalog tables are the first things I need when the engine surprises me. In the next page, "When Defenses Fail," we'll look at what happens when your WAF rules know MySQL syntax but the attacker switches to PostgreSQL — the polyglot payload problem. For now, lab this: spin up the ShopBox variants on 192.0.2.20 and 192.0.2.30, run the same
sqlmapcommand from page 4 with--dbms=mysqlversus--dbms=postgresql, and watch the payload difference in your application logs. The defensive correlation patterns from page 5 only work if you know what you're looking for.