Skip to content

Defensive hardening: SQL injection (sqli) - #296

Open
ajrequenez wants to merge 1 commit into
OWASP-CTF:dc34-ctffrom
ajrequenez:ctf/hardening
Open

ajrequenez wants to merge 1 commit into
OWASP-CTF:dc34-ctffrom
ajrequenez:ctf/hardening

Conversation

@ajrequenez

Copy link
Copy Markdown

What this changes

This closes SQL injection in vulnerabilities/sqli/ at the low, medium and high security
levels, on both the MySQL and SQLite branches of each level's switch ($_DVWA['SQLI_DB']), and
stops 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:

  • MySQL arm: mysqli_preparemysqli_stmt_bind_param($stmt, "i", $id)
    mysqli_stmt_executemysqli_stmt_get_result, with the existing fetch loop preserved and
    mysqli_stmt_close before the connection close.
  • SQLite arm: SQLite3::preparebindValue(':id', $id, SQLITE3_INTEGER)execute(), with
    the existing fetchArray() loop preserved and finalize() placed after the loop.

Above the switch in each file, a single shared is_numeric($id)intval($id) guard mirrors the
validation 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 a
fixed 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 the
execution 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 UNION to enter through, and no token stream for a trailing
comment to truncate. The guarantee is about when parsing happened, and an attacker cannot change
when parsing happened.

That is a categorical difference from what medium previously relied on. Its
mysqli_real_escape_string call never engaged at all: the query interpolated the value into an
unquoted numeric context (WHERE user_id = $id), so a payload needs no quote characters to
break 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 trailing LIMIT 1 was never the
defence — 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

  • Every level still dispatches to its own source/<level>.php; each remains a real, distinct
    implementation rather than a copy of the reference.
  • Neither impossible.php is modified. No challenge definition, detection hook, or navigation
    entry is removed, and nothing is deleted.
  • Each level keeps its original input source and query shape — $_REQUEST at low, $_POST at
    medium, $_SESSION at high; the multi-row loop where the level had no LIMIT, and LIMIT 1
    where it did.
  • The trailing SELECT COUNT(*) block at medium is kept, with its row count initialised on the
    failure path as well — the module's index.php reads that variable unconditionally to build the
    medium-level dropdown, so an undefined read would empty the form.
  • No CSRF token was added below impossible, and no rate limiting or other control was introduced
    that 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.

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant