After two days deep in Broken Access Control, today I moved to the next OWASP category: Injection. Specifically, SQL injection — arguably the most historically damaging vulnerability class in web security, and the one behind some of the largest breaches on record. I worked through three PortSwigger labs, each building on the last.
The Core Idea, Before the Labs
Injection happens when untrusted input gets treated as code or query logic instead of as plain data. A backend that builds SQL queries by directly stitching user input into a string — instead of keeping "data" and "instructions" strictly separate — lets an attacker supply input that changes what the query actually does.
It's the same root failure as everything I covered in access control: the server trusting something it shouldn't. Just applied to a database query instead of a role or a cookie.
Lab 1: Retrieving Hidden Data via the WHERE Clause
This lab's shop app filters products by category through a URL parameter:
/filter?category=Gifts
No visible form field — the input lives entirely in the URL. The app almost certainly builds a query like:
SELECT * FROM products WHERE category = 'Gifts' AND released = 1
The released = 1 condition hides products that aren't public yet — that's the "hidden data" the lab is about.
First test — confirm the injection exists at all:
?category='
This threw an Internal Server Error — the unescaped quote broke the SQL syntax, confirming the input reaches raw SQL with zero sanitization.
Getting the actual exploit right took two attempts. My first payload:
?category=Gifts'--
This commented out the trailing AND released = 1, but it still only matched products where the category genuinely equals Gifts — so it wouldn't surface hidden products outside that category. The payload that actually solved it:
?category=Gifts' OR 1=1-- -
OR 1=1 is always true, so it overrides the category match entirely and returns every row in the table — hidden or not. The trailing -- - comments out the leftover ' AND released = 1 the app appends after the input, so that filter never runs at all.
The fix: parameterized queries.
const query = `SELECT * FROM products WHERE category = ? AND released = 1`;
db.execute(query, [category]);
With a parameterized query, user input is always treated strictly as data — no matter what characters it contains, it can never change the query's structure.
Lab 2: Login Bypass
This lab had a real login form, with a query shaped roughly like:
SELECT * FROM users WHERE username = 'admin' AND password = 'wrongpassword'
Payload, dropped straight into the username field:
administrator'--
The resulting query:
SELECT * FROM users WHERE username = 'administrator'--' AND password = 'whatever'
-- comments out everything after it, including the entire password check. The database never even evaluates the password — it just looks for a user named administrator and logs in as them, no valid credentials needed at all.
Lab 3: UNION-Based Extraction (Oracle Version)
This one was a different technique entirely — not bypassing a check, but pulling arbitrary data out of the database using UNION.
What UNION actually does, in plain terms: it stacks two separate SELECT results into one combined output. The one hard rule: both queries need to return the same number of columns, or the database errors out.
Step 1 — find the column count. Using ORDER BY (sort by column N) as a probe:
?category=Pets' ORDER BY 1-- → loads fine
?category=Pets' ORDER BY 2-- → loads fine
?category=Pets' ORDER BY 3-- → Internal Server Error
ORDER BY 3 failing meant there's no 3rd column — the original query returns exactly 2 columns.
Step 2 — inject a matching UNION SELECT. Oracle requires every SELECT to reference a table (no bare SELECT 'x'), so version info comes from a built-in table, v$version, in a column called banner:
?category=Pets' UNION SELECT banner,NULL FROM v%24version--
(%24 is the URL-encoded $.) The final combined query the database actually ran:
SELECT name, price FROM products WHERE category = 'Pets'
UNION
SELECT banner, NULL FROM v$version
The app only expected the first half to run. Because the column counts matched, the database happily merged both result sets — surfacing the Oracle version string in what looked like a normal product listing.
Why This Still Happens on "Modern" Sites
Genuinely surprised me at first — SQLi has been public knowledge for over two decades, so why does it keep showing up? A few real reasons:
Legacy code. A lot of production systems have database layers written a decade-plus ago that never get revisited.
Escape hatches in modern frameworks. Most ORMs (Django, Rails, Sequelize) default to safe, parameterized queries — but almost all of them still allow raw SQL for edge cases the ORM can't express. That's where injection creeps back in, even in an otherwise "modern" stack.
Third-party plugins and vendor software. A lot of real-world SQLi isn't in custom app code at all — it's in an old CMS plugin or admin panel nobody's patched.
Testing gap. Most QA checks "does the feature work," not "can this be abused" — that gap usually only gets closed by a dedicated security review, which smaller companies often skip until after an incident.
Takeaways
Injection is the same root failure as broken access control — the server trusting input it shouldn't — just applied to query structure instead of permissions.
UNION-based injection requires matching column counts first;ORDER BYis a simple, reliable way to find that number before building the real payload.The permanent fix across every variant is the same: parameterized queries. String concatenation into a query is the actual root cause every single time.
Three SQL injection labs down. Next up: continuing through the rest of the Injection category before moving on to Insecure Design.
Testing was performed exclusively against PortSwigger's own intentionally vulnerable lab environments as part of self-directed learning on the Web Security Academy.
About the author
- just a simple guy
Comments
Join the discussionNo comments yet. Be the first to comment.




