Defensive hardening: SQL injection (sqli) - #296
Open
ajrequenez wants to merge 1 commit into
Open
ajrequenez wants to merge 1 commit into
ajrequenez wants to merge 1 commit into
Conversation
Vulnerability class: SQL injection (CWE-89, OWASP A03:2021 Injection). The same three
files also reflected the request-controlled id straight back into the response body
with no HTML encoding at any level below impossible, which is reflected cross-site
scripting, CWE-79, sitting in the very lines this change rewrites.
Why it was exploitable:
All three levels pasted a request-controlled id into the text of
"SELECT first_name, last_name FROM users WHERE user_id = ..." so an attacker
controlled the grammar of the statement and not merely its value. Measured on the
wire against the local dvwa-local:ctf build, each payload below returned all 5
seeded users (admin, Brown, Me, Picasso, Smith) where the lookup asks for 1 row.
vulnerabilities/sqli/source/low.php:10 (MYSQL arm) and :31 (SQLITE arm) built
"... WHERE user_id = '$id';" -- a single-quoted literal. GET id=1' OR '1'='1
closed the literal and appended a tautology, and with no LIMIT at this level the
fetch loop rendered the entire table. HTTP 200, 4939 bytes, 5 `Surname:` rows.
vulnerabilities/sqli/source/medium.php:11 (MYSQL arm) and :27 (SQLITE arm) built
"... WHERE user_id = $id;" -- UNQUOTED. mysqli_real_escape_string was applied at
:7, above the switch, and never engaged: it escapes quote characters, and POST
id=1 OR 1=1 carries none because there is no literal to break out of. 5 `Surname:`
rows. The escaping was written for a quoted context and deployed into an unquoted
one, and that mismatch is the entire bug.
vulnerabilities/sqli/source/high.php:10 (MYSQL arm) and :28 (SQLITE arm) built
"... WHERE user_id = '$id' LIMIT 1;" from $_SESSION['id']. Written into the session
by a prior POST to vulnerabilities/sqli/session-input.php, the payload
1' UNION SELECT first_name, last_name FROM users # closed the literal, appended a
UNION with a matching column count, and used the trailing hash to comment the
LIMIT clause out of existence. 5 `Surname:` rows. A value that has travelled
through a session hop is still untrusted input; the hop hides its origin, not its
risk.
low.php was an error oracle as well: GET id=1' returned a 581-byte body with no
page chrome, carrying MariaDB parser text, a full stack trace, and the absolute
path /var/www/html/vulnerabilities/sqli/source/low.php. PHP 8.5 runs mysqli in
exception mode, so the pre-existing `or die` right-hand side was unreachable dead
code and suppressed nothing -- the leak was an uncaught mysqli_sql_exception.
Why this closes it:
Every lookup is now a prepared statement with a typed integer parameter, on both
backends of all three levels. The MYSQL arms use mysqli_prepare +
mysqli_stmt_bind_param($stmt, "i", $id) + mysqli_stmt_execute +
mysqli_stmt_get_result; the SQLITE arms use SQLite3::prepare + bindValue(':id',
$id, SQLITE3_INTEGER) + execute(). This is the same typed-binding mechanism
vulnerabilities/sqli/source/impossible.php already uses, expressed in this module's
own procedural mysqli style rather than by borrowing that file's PDO handle, so
each level stays a real implementation of its own. It is also not a foreign idiom
here: vulnerabilities/bac/source/high.php already uses this exact mysqli call
sequence, bind_param("i") included, at a non-impossible level in this repository.
Binding is categorical where escaping is conditional. prepare() ships the statement
text to the parser with the marker still in it, so the execution plan is built
while the value is still sitting in PHP memory; the value then travels out of band
as a typed integer and is bound into an already-planned slot. It is never text
inside the statement, so there is no literal for a quote to close, no grammar for a
UNION to enter through, and no token stream for a trailing hash to comment out. The
guarantee is about when parsing happened, and an attacker cannot change when
parsing happened. Escaping makes the opposite bet: it leaves the value inside the
query text and tries to enumerate which characters are dangerous there, which is
context-dependent and was simply wrong at medium.
One shared is_numeric($id) then intval($id) guard sits above each switch rather
than inside either arm, so the two backends cannot drift apart. That guard is also
what actually closed the error oracle: 1' is not numeric, so no statement is ever
prepared and no exception is thrown. Both arms of all three files now append the
fixed literal "Something went wrong." inside a pre block -- the wording line 11 of
high.php already used -- instead of rendering driver text. The SQLite getMessage
echo, the exit() that truncated the page mid-render, and the dead lastErrorMsg
branch that dereferenced an undefined variable are gone; the last was DELETED
rather than repaired, because fixing that typo would have turned a dead leak into a
working one. The reflected id is encoded with htmlspecialchars($id, ENT_QUOTES,
'UTF-8') at all six sinks. The escaping call at medium:7 was REMOVED rather than
kept alongside the binding: it is a no-op once the value is bound, it sat outside
the switch so it silently rewrote the value on the SQLITE arm and at the reflected
sink too, and a vestigial defence in a public diff invites a reviewer to read it as
load-bearing.
Preserved: all three levels still dispatch to their own source files -- ship check
A9a/A9b/A9c report OK across 19 modules and 57 level sources, and the probe readback
reports served_level equal to the requested level at low, medium and high, with no
user_token field below impossible. The benign control holds at every level: the
legitimate lookup id=1 still returns exactly one row reading `Surname: admin` at low
(4651 bytes), medium (4789 bytes) and high (4585 bytes), and the medium dropdown that
index.php:52 builds from $number_of_rows still renders all five option entries, values
1 to 5, because $number_of_rows is now assigned on the failure path as well as the
success path. selftest V3 reports benign_ok=true across all 21 entry/level pairs. Each
level keeps its declared shape: the $_REQUEST source and absent LIMIT at low, the POST
source and the trailing SELECT COUNT block at medium, the session source and LIMIT 1
at high, the multi-row fetch loops, and the mysqli_close converter idiom retained
verbatim. No CSRF token was added below impossible. impossible.php,
vulnerabilities/sqli/index.php and vulnerabilities/sqli/session-input.php are
byte-identical to origin/dc34-ctf.
Refs: SQLI-01, SQLI-02, SQLI-05
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this changes
This closes SQL injection in
vulnerabilities/sqli/at thelow,mediumandhighsecuritylevels, on both the MySQL and SQLite branches of each level's
switch ($_DVWA['SQLI_DB']), andstops the module leaking database error text as an injection oracle. Three files change —
vulnerabilities/sqli/source/{low,medium,high}.php— and nothing else.Mechanism
Each level's user lookup is now a native prepared statement with an integer-typed bound parameter,
written in that backend's own procedural style:
mysqli_prepare→mysqli_stmt_bind_param($stmt, "i", $id)→mysqli_stmt_execute→mysqli_stmt_get_result, with the existing fetch loop preserved andmysqli_stmt_closebefore the connection close.SQLite3::prepare→bindValue(':id', $id, SQLITE3_INTEGER)→execute(), withthe existing
fetchArray()loop preserved andfinalize()placed after the loop.Above the
switchin each file, a single sharedis_numeric($id)→intval($id)guard mirrors thevalidation the module's secure reference already performs. Non-numeric input falls through to the
level's ordinary "no rows" rendering — never a
die(), never an error string, never a 500.The reflected id is now encoded at its sink with
htmlspecialchars($id, ENT_QUOTES, 'UTF-8'), and the error-disclosing sinks are replaced with afixed generic string (the wording already used elsewhere in this module), so a database failure no
longer reveals the parser message, a stack trace, or the absolute source path.
Why binding rather than a better filter
prepare()sends the SQL text to the server with the parameter marker still in it, so theexecution plan is built while the value is still sitting in PHP memory. The value then travels
separately, as a typed integer, and is bound into a slot in an already-planned statement. There is
no point in that sequence at which the value is text inside the statement — so there is nothing for
a quote to close, no grammar for a
UNIONto enter through, and no token stream for a trailingcomment to truncate. The guarantee is about when parsing happened, and an attacker cannot change
when parsing happened.
That is a categorical difference from what
mediumpreviously relied on. Itsmysqli_real_escape_stringcall never engaged at all: the query interpolated the value into anunquoted numeric context (
WHERE user_id = $id), so a payload needs no quote characters tobreak out — a space and a keyword suffice, and escaping has nothing to act on. That call was
removed rather than left in place beside the binding, because a vestigial defence reads to a
reviewer as load-bearing when it is not. Similarly at
high, the trailingLIMIT 1was never thedefence — the injection worked by commenting it out — so it is retained for the level's declared
shape, not as a control.
What is deliberately preserved
source/<level>.php; each remains a real, distinctimplementation rather than a copy of the reference.
impossible.phpis modified. No challenge definition, detection hook, or navigationentry is removed, and nothing is deleted.
$_REQUESTatlow,$_POSTatmedium,$_SESSIONathigh; the multi-row loop where the level had noLIMIT, andLIMIT 1where it did.
SELECT COUNT(*)block atmediumis kept, with its row count initialised on thefailure path as well — the module's
index.phpreads that variable unconditionally to build themedium-level dropdown, so an undefined read would empty the form.
impossible, and no rate limiting or other control was introducedthat would change how the module is driven.
The legitimate lookup still returns its record at all three levels, and the medium dropdown still
renders its full set of options.
Scope
Further modules follow on this same branch.