Find and Fix a SQL Injection
Run DVWA locally in Docker, find and exploit a real SQL injection at low and medium security levels to see exactly how it works, then rewrite the equivalent logic using parameterized queries to prove the fix actually closes the hole.
Problem
SQL injection happens when user input is concatenated directly into a SQL query string instead of being passed as a separate, typed parameter. The database cannot tell the difference between "data the developer wrote" and "data the attacker supplied" — so an attacker's input can change the *structure* of the query itself: turning a single-row lookup into a full table dump, bypassing a login check entirely, or extracting data from tables the query was never supposed to touch. In this lab you will run **DVWA (Damn Vulnerable Web Application)** in a local Docker container — an application deliberately built with real vulnerabilities for training. You will find and exploit its classic SQL injection at the "low" security level, understand why "medium" security's naive escaping is still broken, and then step back to the code level: rewrite the equivalent query logic (as a pseudo-endpoint, since DVWA itself is PHP) using **parameterized queries / prepared statements**, and prove your rewritten version resists the exact payload that broke the original. > Only ever run these techniques against the local DVWA container you start > yourself in this lab (`localhost`). Never run SQL injection payloads against > any site, API, or database you do not own or have explicit written > permission to test — doing so against third-party systems is illegal in most > jurisdictions.
Objectives
By the end of this lab you will be able to:
- Stand up DVWA locally in Docker and configure it for a controlled, offline
practice session. - Identify a SQL injection point by observing how application behavior changes
with crafted input (error messages, extra rows, boolean logic). - Exploit a classic UNION-based SQL injection to extract data outside the
query's intended scope. - Explain why naive escaping/blocklisting (DVWA "medium") still fails, and what
class of payload defeats it. - Rewrite the vulnerable query using parameterized queries / prepared
statements and explain why they close the vulnerability structurally, not
just cosmetically. - Write a test that proves the injection payload no longer alters the query's
behavior against the fixed version.
Prerequisites
To complete this lab you'll need:
- Docker installed and able to run
docker run -p 80:80 vulnerables/web-dvwa. - A web browser to interact with DVWA's UI.
- Basic familiarity with SQL (
SELECT,UNION,WHERE). - A backend project or scratch file in any language with a SQL database
client, to write the "before/after" pseudo-endpoint in Step 5 (no need to be
PHP — the point is the query logic, not the language).
The mental model: code vs. data
A SQL query is supposed to be code the developer wrote, with user input
plugged in as data. String concatenation destroys that boundary — the
database receives one string and has no way to know which parts were meant to
be a fixed instruction and which parts came from an attacker:
Query template: SELECT * FROM users WHERE id = '<user input>'
Intended: id = '3' → normal lookup
Attacker: id = '3' OR '1'='1' → the WHERE clause is now always true
Attacker: id = '3' UNION SELECT user,password FROM users -- '
→ the query now returns a second, unrelated result set
Parameterized queries (prepared statements) fix this at the protocol level:
the query structure is sent to the database first and separately, and the
user's value is bound afterward as pure data. The database is told, up front,
"this placeholder is one string value," so no amount of quotes, UNION, or
comment markers inside that value can change the query's shape. That's why
escaping (trying to neutralize dangerous characters in application code) is
weaker than parameterization (never letting user input touch the query
string at all).
Why DVWA
DVWA is intentionally vulnerable and ships with a security-level toggle (low,
medium, high, impossible) so you can see the same endpoint get progressively
harder to exploit — and, crucially, see that "medium" still isn't safe. That
progression is the best way to internalize why the type of fix matters more
than the amount of filtering.
How to work through this lab
Steps 1–2 set up and log into DVWA. Steps 3–4 exploit low and medium security.
Step 5 is the fix — do not skip it; every exploitation lab in this catalog ends
with remediation, not just a working attack.
Steps
-
Start DVWA locally and log in
Run the official DVWA image locally. It listens on port 80 and needs a
one-time database setup on first boot.docker run --rm -d --name dvwa -p 80:80 vulnerables/web-dvwaOpen
http://localhost/setup.phpin your browser and click
"Create / Reset Database". Then go tohttp://localhost/login.phpand
log in with the default credentials:username: admin password: passwordCheckpoint
After logging in, click DVWA Security in the left nav and confirm the
security level selector is visible with optionslow,medium,high,
impossible. Set it to low for now — you'll change it in Step 4.Deliverable for this step: DVWA running at
http://localhost, logged
in asadmin, with security level set tolow. -
Find the injection point (low security)
Navigate to the SQL Injection module in the left nav. It's a simple
form: enter a "User ID" and it prints that user's first/last name.Start with the normal, expected input to see the baseline behavior:
User ID: 1 → Result: First name: admin, Surname: adminNow probe with a single quote — the classic first move for finding
injection, because it often breaks the query's string literal and
surfaces a database error or a blank page:User ID: 1'You should see a SQL error (something like
You have an error in your SQL syntax...) — that's the injection point announcing itself. The
application is doing something like:SELECT first_name, last_name FROM users WHERE user_id = '$id';and your
'closed the string early, leaving a dangling quote the
database can't parse.Confirm it with a boolean payload
User ID: 1' OR '1'='1If this returns every user instead of just one, you've confirmed the
WHEREclause is being overridden, not just broken.Checkpoint
Record the exact request/response for both payloads (
1'and
1' OR '1'='1). You should be able to point to: the error message that
revealed the injection, and the query result that proved you controlled
theWHERElogic.Deliverable for this step: two observed payload/response pairs proving
the User ID field is unsafely concatenated into a SQL query. -
Exploit with UNION to extract other data
A
UNION-based injection appends a second, attacker-chosenSELECTto
the original query's result set. It works when you can match the column
count and get the output rendered somewhere visible — exactly DVWA's case,
since it prints two columns (first_name, last_name).Step 1: find the column count
User ID: 1' ORDER BY 1-- - User ID: 1' ORDER BY 2-- - User ID: 1' ORDER BY 3-- -Increase the number until the query errors (
Unknown column) — the last
one that worked tells you the column count. On stock DVWA this confirms
2 columns.Step 2: extract data with UNION SELECT
Now substitute a
UNION SELECTwith 2 columns, pulling from a different
table entirely:User ID: 1' UNION SELECT user, password FROM users-- -This should print every row of
users.userandusers.passwordin the
same "First name / Surname" display fields — data the User ID lookup was
never supposed to expose. On the DVWA sample database, the passwords are
MD5 hashes; note that too (weak hashing is a separate problem from
injection, but it compounds the damage here).Why this works structurally
The original query became:
SELECT first_name, last_name FROM users WHERE user_id = '1' UNION SELECT user, password FROM users-- -';The
--comments out the trailing quote from the original query so it
doesn't cause a syntax error. TwoSELECTs with matching column counts,
UNIONed, are indistinguishable to the application from one — it just
prints whatever rows come back.Checkpoint
Confirm you extracted at least the
userandpasswordcolumns for every
row in theuserstable, using only the web form — no direct database
access. Write down the final payload string you used.Deliverable for this step: the working
UNION SELECTpayload and the
extracteduser/passworddata it returned, demonstrating full exposure
of a table the endpoint was never meant to touch. -
See why 'medium' security still fails
Go back to DVWA Security and set the level to medium. Open the
SQL Injection module again — note it's now a<select>dropdown of
numeric IDs instead of a free-text field, and the underlying code switches
from raw concatenation tomysqli_real_escape_string()on the input.Escaping neutralizes quote characters, but it does not change the
query's structure, and it does nothing if you can get numeric/unquoted
input in. Because the source is a dropdown, the direct browser UI resists
casual typing — but the server-side check is still just escaping, and the
request itself is a normal HTTP parameter you can forge directly.Bypass it with a raw HTTP request
Use curl (or your browser's dev tools) to submit a value the dropdown
would never offer, sending your session cookie so the request is
authenticated:# Grab your PHPSESSID cookie from the browser dev tools after logging in curl -s "http://localhost/vulnerabilities/sqli/?id=1+UNION+SELECT+user,password+FROM+users-- -&Submit=Submit" \ -H "Cookie: PHPSESSID=<your-session-id>; security=medium" \ | grep -A2 "first_name"Because the query is still built as
... WHERE user_id = $id(numeric
context, no quotes to escape) or as a string that only strips quotes
rather than parameterizing, a numeric/unquoted UNION payload sails
straight through the escaping.Checkpoint — name the real gap
Answer in your own words: why did adding
mysqli_real_escape_string()
not fix the vulnerability? The answer should mention that escaping
treats symptoms (dangerous characters) rather than the cause (user input
being interpreted as part of the query grammar at all) — a value with no
quotes to escape (like a bareUNION SELECT) sails straight through.Deliverable for this step: a successful UNION extraction against
"medium" security via a forged request, plus a one-paragraph explanation
of why escaping alone is not a structural fix. -
Fix it: rewrite with parameterized queries (submission)
DVWA itself is PHP and out of scope to patch here — the point of this step
is to prove you understand the correct fix by rewriting the equivalent
logic in your own stack, and to write a test that shows the exact payload
from Step 3 no longer works.The vulnerable pattern (pseudocode, mirrors DVWA)
def get_user_by_id(id): query = "SELECT first_name, last_name FROM users WHERE user_id = '" + id + "'" return db.execute(query) # id is concatenated straight into the SQL stringThe fix: parameterized query / prepared statement
def get_user_by_id(id): query = "SELECT first_name, last_name FROM users WHERE user_id = ?" return db.execute(query, params=[id]) # id is bound as data, never parsed as SQLTranslate this to your actual stack. A few concrete examples:
# Python + a DB-API driver (psycopg2, sqlite3, mysql-connector, ...) cursor.execute( "SELECT first_name, last_name FROM users WHERE user_id = %s", (user_id,), )// Node.js + node-postgres await pool.query( "SELECT first_name, last_name FROM users WHERE user_id = $1", [userId] );# Ruby / ActiveRecord User.where("user_id = ?", user_id)Whatever language you use, the shape is the same: the query string with
placeholders is fixed and known ahead of time; user input is handed to the
driver separately and never touches the SQL string itself.Prove the fix with a test
Write a test (in your stack's test framework, or a small script) that runs
the exact payload from Step 3 against your parameterized version and
asserts it behaves as inert data, not as SQL:test "UNION payload is treated as a literal ID, not executed": payload = "1' UNION SELECT user, password FROM users-- -" result = get_user_by_id(payload) # A parameterized query treats the whole string as the user_id value. # No row has that literal string as its id, so we expect zero rows — # NOT a leaked password column and NOT a SQL error. expect(result) == [] test "normal lookup still works": result = get_user_by_id("1") expect(result) == [{ first_name: "admin", last_name: "admin" }]Why this is a structural fix, not a stronger filter
Unlike escaping, parameterization doesn't try to spot and neutralize
dangerous characters — it removes the possibility of the query's grammar
changing at all, because the driver sends the query template and the data
over the wire as two separate things. There is no string forUNION,
--, or'to "break out" of, because the value is never re-parsed as
SQL syntax.
Submission criteria
Submit when all of the following hold:
- Documented evidence of the exploit against DVWA "low" (Step 2) and the
fullUNION SELECTdata extraction (Step 3). - Documented evidence that DVWA "medium" is bypassed via a forged
request, plus your explanation of why escaping isn't structural
(Step 4). - A rewritten
get_user_by_id-equivalent function using a parameterized
query / prepared statement in your language of choice. - A passing test suite proving: the Step 3 payload returns zero rows /
no error against the fixed version, and a normal lookup still works
correctly.
Include a one-line note confirming you only tested against your local
DVWA container, never a third-party system. - Documented evidence of the exploit against DVWA "low" (Step 2) and the