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 OUTFILEor UDFs
Found this useful? More code reviews coming. Hit me up on X if you want to discuss.