SQL Injection Labs (Portswigger)
If you want a second reference alongside this, Rana Khalil's Web Security Academy series on GitHub covers these too and is worth a look once you've had your own "wait, why is this query failing" moment.
For each lab below I'm following the same numbered-step structure: intercept in Burp, build the payload up piece by piece, and note exactly where I went wrong along the way — rather than just presenting the final payload as if I typed it correctly first try (I did not).
Quick refresher: SQL injection happens when user input gets concatenated directly into a SQL query instead of being passed as a parameter. If an app builds a query like SELECT * FROM products WHERE category = ' + your_input + ', and you control your_input, you can break out of that string and rewrite the query's logic entirely.
Lab 1: SQL Injection in WHERE Clause Allowing Retrieval of Hidden Data
The setup
This is a shop app where clicking a category (like "Gifts") sends a request to /filter?category=Gifts. The page shows only products in that category — implying a query roughly like:
SELECT * FROM products WHERE category = 'Gifts' AND released = 1
That released = 1 part is invisible to us, but it's there — the goal is to see unreleased products too.
Step 1: Use Burp Suite to intercept and modify the request that sets the product category filter
With intercept on, I clicked the "Gifts" category and caught:
GET /filter?category=Gifts HTTP/1.1
Host: YOUR-LAB-ID.web-security-academy.net
I sent this to Repeater so I could edit and resend the category parameter without reloading the page each time.
Step 2: Confirm the injection point by breaking the query
I changed category to:
Gifts'
This came back with a generic database error — confirming the single quote is landing directly inside the SQL query unescaped. Good, it's injectable. But then I just sat there for a minute, because breaking the query isn't the goal — controlling it is.
Mistake #1: forgetting to comment out the rest of the query
My next attempt was:
Gifts' OR 1=1
This still errored. The reason: the original query has more stuff after the category condition (the hidden AND released = 1). My injected OR 1=1 was now sitting in the middle of a syntactically broken query:
SELECT * FROM products WHERE category = 'Gifts' OR 1=1' AND released = 1
See the dangling ' after 1=1? That extra quote — the one that originally closed 'Gifts' — is now floating in the wrong place, and the whole thing is malformed.
Step 3: Add a comment to remove the trailing part of the query
Gifts'+OR+1=1--
Which results in:
SELECT * FROM products WHERE category = 'Gifts' OR 1=1--' AND released = 1
Gifts'closes the original string literal early.OR 1=1is a condition that's always true, so theWHEREclause now matches every row, regardless of category or release status.--is a SQL comment marker. Everything after it — including the leftover' AND released = 1— gets ignored by the database entirely.The
+characters are URL-encoded spaces. I initially typed real spaces into the Repeater field and they got mangled, so I switched to+(which decodes to a space) to keep the query string clean.
Mistake #2: forgetting the trailing space after --
My very first attempt at Step 3 was Gifts'+OR+1=1-- with absolutely nothing after the --. In MySQL, -- needs a trailing space to be recognized as a comment. Since I'd ended the parameter value right at --, there was technically a trailing space already (the end of the string counts), so this one happened to work — but on a later attempt where I appended more characters after -- without a space, I got bitten by exactly this issue. Lesson learned early: always make sure there's a space (or +) immediately after --.
Step 4: Send the request and confirm the lab is solved
Sending the Step 3 payload returned every product in the store, including hidden "Lifestyle" category items that aren't normally browsable. Lab solved.
Lab 2: SQL Injection Vulnerability Allowing Login Bypass
The setup
A login form, POST to /login with username and password parameters. Presumably the backend runs something like:
SELECT * FROM users WHERE username = 'INPUT_USER' AND password = 'INPUT_PASS'
If a row comes back, you're logged in as that user.
Step 1: Use Burp Suite to intercept the login request
I turned on intercept, submitted the login form with some throwaway values, and caught:
POST /login HTTP/1.1
Host: YOUR-LAB-ID.web-security-academy.net
Content-Type: application/x-www-form-urlencoded
csrf=...&username=test&password=test
Sent it to Repeater for editing.
Mistake #3: trying to inject the password field first
My first instinct was to leave username as administrator and put something clever in password:
username=administrator&password=' OR 1=1
This either errored or just failed to log me in, depending on how I closed the quote. The problem is conceptual: even if OR 1=1 makes the password condition always true, I'm still constraining username = 'administrator' correctly — so at best I'd match the administrator row anyway, but I kept getting comment-syntax errors because I was overcomplicating the password value instead of going after the simpler target: removing the password check entirely.
Step 2: Inject the username field instead
username=administrator'--&password=anything
Which turns the query into:
SELECT * FROM users WHERE username = 'administrator'--' AND password = 'anything'
administrator'closes the username string right after the value we want.--comments out the entire rest of the query — meaning theAND password = '...'check never executes at all.The query effectively becomes
SELECT * FROM users WHERE username = 'administrator', which returns the administrator's row regardless of what password was submitted.
Mistake #4: forgetting the trailing space again
Just like Lab 1, my first pass at this had administrator'-- immediately followed by &password=anything with no space between -- and the &. This time it genuinely broke things, because unlike Lab 1 where -- was at the very end of the parameter, here there's more of the request after it. Adding a literal space (administrator'-- , i.e. administrator'--+) before the parameter boundary fixed it.
Step 3: Forward the request and confirm login
The response logged me straight in as administrator, no password required. Lab solved — and mildly terrifying that this pattern shows up in real codebases.
Lab 3: SQL Injection — Querying Database Type and Version (Oracle)
The setup
Same /filter?category=Gifts style injection point as Lab 1, but now the goal isn't to bypass a filter — it's to use the injection to ask the database "what are you, and what version are you running?" The lab tells us up front it's Oracle, which matters a lot here.
Step 1: Use Burp Suite to intercept and modify the category filter request
Same setup as Lab 1 — caught the /filter?category=Gifts request and sent it to Repeater.
Mistake #5: jumping straight to UNION SELECT without checking the column count
I knew the general technique was a UNION SELECT attack — append a second SELECT so its results get merged into the response. But UNION requires both SELECT statements to return the same number of columns, and I didn't check first. My first attempt:
Gifts' UNION SELECT 'a','b'--
errored out — "different number of columns" type error. I had to find the real count first.
Step 2: Determine the number of columns and which contain text
I used the classic ORDER BY trick, incrementing the column number until it errored:
Gifts' ORDER BY 1--
Gifts' ORDER BY 2--
Gifts' ORDER BY 3-- <- this one errors
ORDER BY 3 failing told me the query returns 2 columns. Then, to confirm both columns can hold text, I tried:
Gifts' UNION SELECT 'a','b'--
Mistake #6: forgetting Oracle requires FROM on every SELECT
Even with the right column count, this still errored — and this one threw me for a bit, because the exact same payload works fine on MySQL. The difference: Oracle does not allow a SELECT statement without a FROM clause. Every other database engine I'd touched lets you do SELECT 'a', 'b' with no table at all, but Oracle demands you select from something.
Step 3: Use FROM dual to satisfy Oracle's syntax requirements
Oracle ships a built-in single-row, single-column table called dual, specifically for situations like this where you want to SELECT a literal value without caring about any real table:
Gifts' UNION SELECT 'a','b' FROM dual--
This finally returned successfully, and I could see 'a' and 'b' rendered on the page — confirming 2 columns, both displayed, both able to hold text.
Step 4: Query the database version using v$version
Gifts' UNION SELECT banner, NULL FROM v$version--
banneris the column inv$versioncontaining the full version string (e.g.,Oracle Database 11g Enterprise Edition Release ...).NULLfills the second column — I usedNULLinstead of a string because it's a safe placeholder that works regardless of the original column's data type.I deliberately didn't add
FROM dualhere —v$versionis itself a table, so it's already a validFROMtarget.
Step 5: Confirm the version is displayed and the lab is solved
This came back with the full Oracle version banner displayed on the page. Lab solved, and I finally internalized the FROM dual thing for good.
Lab 4: SQL Injection — Querying Database Type and Version (MySQL / Microsoft)
The setup
Same injection point, same general goal, but this time the target is MySQL or Microsoft SQL Server (the lab doesn't tell you which up front — that's sort of the point).
Step 1: Intercept and modify the category filter request
Same Burp setup as before — caught /filter?category=Gifts, sent to Repeater.
Step 2: Determine the number of columns and which contain text
Gifts' UNION SELECT 'a','b'--
Mistake #7: copy-pasting the Oracle payload from Lab 3
Having just spent ages getting FROM dual to work, I lazily reused the exact same structure here too:
Gifts' UNION SELECT 'a','b' FROM dual--
This errored. dual is an Oracle-specific thing — MySQL and Microsoft SQL Server don't have it (MySQL has a dual table for compatibility in some configurations, but it's not guaranteed, and Microsoft definitely doesn't have it at all). I'd over-corrected from the previous lab and assumed FROM dual was just "the way you do this," when really it was an Oracle-specific workaround for an Oracle-specific restriction. Dropping FROM dual entirely — Gifts' UNION SELECT 'a','b'-- — worked fine and confirmed 2 text columns.
Step 3: Query the version using @@version
Gifts' UNION SELECT @@version, NULL--
@@version— built-in system variable on both MySQL and MSSQL, returns the version/build string.NULL— same reasoning as Lab 3, a type-agnostic placeholder for the second column.No
FROM— because neither of these engines needs (or in MSSQL's case, supports) it for aSELECTwith no table reference.
Step 4: Confirm the version is displayed and the lab is solved
This came back with a version string starting with something like Microsoft SQL Server 2019... or a MySQL version number depending on the lab instance, confirming the engine and version in one shot. Honestly this lab took me about a third of the time of Lab 3, mostly because I'd already done the painful column-counting step mentally and just had to swap the version-fetching expression.
Lab 5: SQL Injection Attack, Listing the Database Contents (Non-Oracle)
The setup
This is where it gets real. No more "just confirm the version" — now the goal is to actually walk the database schema and pull out a username/password from some unknown table and column, using only the /filter?category= injection point.
Step 1: Intercept and modify the category filter request
Same Burp setup as every other lab in this post — caught /filter?category=Gifts, sent to Repeater.
Step 2: Determine the number of columns and which contain text
Gifts' UNION SELECT 'abc','def'--
Mistake #8: trying to dump everything in one query, skipping ahead
Once this returned abc and def on the page (confirming 2 text columns), I got overconfident and tried to jump straight to the finish line:
Gifts' UNION SELECT username, password FROM users--
This actually worked on this particular lab instance — but only because I got lucky and guessed the table/column names (users/username/password) correctly, since they're extremely common in these labs. In a real engagement (or a lab with less predictable naming, like Lab 6's _ABCDEF suffixes), this would have failed silently or errored, and I'd have had no idea why — wrong table name? Wrong column name? Wrong column count? Too many unknowns guessed at once. So I went back and did it properly, step by step.
Step 3: Retrieve the list of tables in the database
Gifts' UNION SELECT table_name, NULL FROM information_schema.tables--
information_schema is a built-in schema available on MySQL, PostgreSQL, and Microsoft SQL Server (notably not the same on Oracle, which is why Lab 6 needs a different approach) that contains metadata about every table and column in the database. table_name from information_schema.tables gives you the name of every table — including ones the application never references directly.
Step 4: Find the table containing user credentials
Scrolling through the results, I spotted a table named users_<random suffix> — PortSwigger randomizes table names specifically so you can't just guess users and skip the enumeration step (even though my earlier shortcut in Mistake #8 happened to work on this particular instance).
Step 5: Retrieve the column details for that table
Gifts' UNION SELECT column_name, NULL FROM information_schema.columns WHERE table_name='users_abcdef'--
Mistake #9: only fetching one column at a time in the final query
This step itself went fine and returned column names like username_xyz and password_xyz. But when I got to the final dump, my first attempt was:
Gifts' UNION SELECT username_xyz FROM users_abcdef--
This errored — back to the column-count mismatch problem from Lab 3, except this time I'd genuinely forgotten my own earlier finding (2 columns, from Step 2) because I'd been staring at information_schema results for so long.
Step 6: Find the names of the columns containing usernames and passwords
From the Step 5 results, I identified username_xyz and password_xyz as the two columns of interest.
Step 7: Retrieve the usernames and passwords for all users
Gifts' UNION SELECT username_xyz, password_xyz FROM users_abcdef--
This matches the 2-column shape confirmed in Step 2 — one output column for usernames, one for passwords, displayed side by side for every row in the table. No concatenation or separator characters needed; the page just renders both columns directly.
Step 8: Find the password for the administrator user and log in
Scrolling through the dumped rows, I found the row where the username column read administrator, grabbed the corresponding password, and used it to log in. Lab solved — and this is the lab where I really understood why the column-count and "what gets displayed" reconnaissance at the start of an injection matters so much. Skipping it doesn't save time; it just moves the confusion to later.
Lab 6: SQL Injection Attack, Listing the Database Contents (Oracle)
The setup
Same goal as Lab 5 — enumerate tables, enumerate columns, dump credentials — but back on Oracle. I started this one in Burp again, sending the category filter request to Repeater so I could tweak the category parameter repeatedly without re-triggering the whole page load each time.
Step 1: Use Burp Suite to intercept and modify the request that sets the product category filter
GET /filter?category=Gifts HTTP/1.1
Host: YOUR-LAB-ID.web-security-academy.net
Sent to Repeater, intercept off from there.
Step 2: Determine the number of columns and which contain text
I already knew from Lab 3 that Oracle needs FROM dual for a SELECT with no real table, so I went straight to a two-column test payload:
'+UNION+SELECT+'abc','def'+FROM+dual--
Mistake #10: assuming the column count from Lab 3 carried over
My first thought was "it's 2 columns again, just like Lab 3, I can skip this step." I sent the payload above anyway out of habit, and it's lucky I did — if the count had been wrong, this would've errored with a column-mismatch message instead of rendering abc and def somewhere on the page. It worked first try here, but I made a mental note: don't assume the column count is the same across labs just because the injection point looks identical. Always confirm with the 'abc','def' check before moving on, because guessing wrong here wastes a lot of debugging time three steps later when the real query fails for a completely unrelated reason.
Once I saw both abc and def rendered on the page, I had confirmation: 2 columns, both able to hold text. Good — that's the shape every subsequent payload needs to match.
Step 3: Retrieve the list of tables in the database
'+UNION+SELECT+table_name,NULL+FROM+all_tables--
Mistake #11: trying information_schema first out of habit
Before settling on the payload above, I actually tried reusing the Lab 5 approach verbatim:
'+UNION+SELECT+table_name,NULL+FROM+information_schema.tables--
This errored immediately. It's not a FROM dual issue this time — the problem is more fundamental: Oracle doesn't have information_schema at all. The entire schema I was querying simply doesn't exist on this engine, so no amount of fiddling with the query around it was going to fix it.
Switching to all_tables — Oracle's built-in view listing every table accessible to the current user, and the rough equivalent of information_schema.tables — fixed it immediately. Scrolling through the results, I found a table that stood out from the usual PRODUCTS/USERS_ROLES/etc. noise: something like USERS_ABCDEF. That randomized suffix is PortSwigger's way of stopping you from just guessing USERS and skipping the enumeration step entirely.
Step 4: Find the name of the table containing user credentials
Scanning the all_tables output, USERS_ABCDEF was the obvious candidate based on the name alone — but I made sure to actually note it down exactly as it appeared, because Oracle returned it in uppercase, which matters for the next step.
Step 5: Retrieve the details of the columns in that table
'+UNION+SELECT+column_name,NULL+FROM+all_tab_columns+WHERE+table_name='USERS_ABCDEF'--
Mistake #12: case mismatch in the WHERE clause
My first attempt at this used a lowercase table name, because that's just how I'd been typing table names all day (users_abcdef, force of habit from Lab 5's lowercase information_schema results):
'+UNION+SELECT+column_name,NULL+FROM+all_tab_columns+WHERE+table_name='users_abcdef'--
This came back with zero rows — no error, just nothing, which is arguably worse to debug than an error because nothing looks wrong at first glance. The table definitely existed (I'd just seen it in all_tables), so why no columns?
The answer: Oracle string comparisons are case-sensitive, and all_tables had returned the name as USERS_ABCDEF — all uppercase. My WHERE table_name='users_abcdef' was comparing against a string that simply didn't match any row in all_tab_columns, since Oracle stores the identifier as USERS_ABCDEF. Switching to the exact uppercase form from Step 4 fixed it immediately, which is the version shown above.
Step 6: Find the names of the columns containing usernames and passwords
The all_tab_columns results for USERS_ABCDEF came back with two columns that were obviously the targets: USERNAME_ABCDEF and PASSWORD_ABCDEF — same randomized-suffix pattern as the table name, and again returned in uppercase.
Step 7: Retrieve the usernames and passwords for all users
'+UNION+SELECT+USERNAME_ABCDEF,+PASSWORD_ABCDEF+FROM+USERS_ABCDEF--
A couple of notes on this final query:
No
FROM dualhere —USERS_ABCDEFis itself a real table, so it's already a validFROMtarget.FROM dualis only needed when there's no real table to select from (like the version-banner query back in Lab 3, or the'abc','def'test in Step 2).Two separate output columns, not concatenated — same approach as the Lab 5 fix: put
USERNAME_ABCDEFin the first output column andPASSWORD_ABCDEFin the second, matching the 2-column shape confirmed back in Step 2. The page rendered them side by side for every user in the table.Case consistency throughout —
USERS_ABCDEF,USERNAME_ABCDEF, andPASSWORD_ABCDEFall needed to match the exact casing Oracle gave back in Steps 4 and 6. After getting burned once in Step 5, I copy-pasted these directly from the Burp response instead of retyping them.
Step 8: Find the password for the administrator user and log in
Scrolling through the dumped rows, I found the one where the username column read administrator and grabbed the corresponding value from the password column. Logging in with administrator and that password solved the lab.
What I actually took away from this batch
ORDER BY(or a quick'a','b'test) for column-count discovery andUNION SELECTfor data exfiltration are basically the backbone of every one of these labs. Once those two techniques are solid, the rest is mostly engine-specific syntax differences.Every database has its own quirks around
FROM: Oracle requires it everywhere (hencedual), MySQL and Microsoft SQL Server don't need it for literal/variable selects, andinformation_schemavsall_tables/all_tab_columnsis the single biggest non-Oracle-vs-Oracle divide.Case sensitivity will get you on Oracle. If a
WHEREclause against metadata returns nothing despite the table "existing," check casing before anything else.Trailing spaces after
--matter more than they should. I lost time to this exact issue in two separate labs and will probably lose time to it again in the future, because old habits die hard.Don't skip the reconnaissance steps even when you're confident. Lab 5's "lucky guess" taught me nothing useful, because I didn't understand why it worked — going back and doing the column-count and
information_schemasteps properly is what actually built the mental model I used for Lab 6.
If you're working through these yourself: get comfortable with the column-count check before you touch UNION SELECT for real data, and the moment something errors, ask "is this a column-count problem, a FROM-clause problem, a comment-syntax problem, or a case-sensitivity problem?" — because in my experience it's almost always one of those four.
