Back to Blog
critical SEVERITY6 min read

How SQL injection happens in PHP MySQLi and how to fix it

A critical SQL injection vulnerability was discovered in `sign_up.php` where user registration inputs—including Username and Email—were directly concatenated into SQL queries. Despite using `mysqli_real_escape_string()`, the code remained exploitable. The fix replaces all string-concatenated queries with MySQLi prepared statements and bound parameters, completely eliminating the injection vector.

O
By Orbis AppSec
Published July 31, 2026Reviewed July 31, 2026

Answer Summary

This is a SQL injection vulnerability (CWE-89) in PHP MySQLi where the `sign_up.php` user registration form concatenates user-supplied values like `$Username` and `$Email` directly into SQL query strings. Even though `mysqli_real_escape_string()` was applied, it provides insufficient protection in all contexts. The fix replaces all concatenated queries with `mysqli_prepare()` and `mysqli_stmt_bind_param()` parameterized statements, ensuring user input is never interpreted as SQL code.

Vulnerability at a Glance

cweCWE-89
fixReplace all concatenated queries with mysqli_prepare() and mysqli_stmt_bind_param() parameterized statements
riskAttackers can inject arbitrary SQL to bypass authentication, exfiltrate data, or modify the Users table
languagePHP (MySQLi)
root causeUser inputs ($Username, $Email, etc.) concatenated directly into SQL query strings
vulnerabilitySQL Injection via string concatenation

How SQL Injection Happens in PHP MySQLi and How to Fix It

Introduction

The sign_up.php file handles user registration—collecting full names, usernames, passwords, emails, and profile images—but a flaw in how the SQL queries were constructed at lines 28-32 created a critical security risk. The variables $Username and $Email (sourced directly from $_POST) were concatenated into raw SQL strings like "select * from Users where Username = '$Username'", making the registration form a direct gateway for SQL injection attacks.

This isn't a theoretical concern. Any internet-facing registration form that constructs queries this way is actively exploitable, and the consequences range from data theft to complete database compromise.

The Vulnerability Explained

Here's the vulnerable code that was present in sign_up.php:

$chk_user = "select * from Users where Username = '$Username'";
$chk_run_user = mysqli_query($con, $chk_user);

$chk_email = "select * from Users where Email = '$Email'";
$chk_run_email = mysqli_query($con, $chk_email);

And the INSERT statement:

$insert_query = "insert into Users(Full_Name, Username, Password, Email, Language, Gender, Phone_No, Image) values('$Full_Name', '$Username', '$Password', '$Email', 'English', '$Gender', '$Phone_No', '$Image')";
mysqli_query($con, $insert_query);

Why is this dangerous? When a user submits the registration form, the $Username value is placed directly inside the SQL string. If an attacker enters a username like:

' UNION SELECT Full_Name, Username, Password, Email, Language, Gender, Phone_No, Image FROM Users--

The resulting query becomes:

select * from Users where Username = '' UNION SELECT Full_Name, Username, Password, Email, Language, Gender, Phone_No, Image FROM Users--'

This would return every user's credentials from the database. The -- comments out the trailing single quote, making the SQL syntactically valid.

The INSERT injection is even more dangerous. An attacker could inject into the $Username field during registration:

', '', '', '', '', '', ''); DROP TABLE Users;--

This could terminate the INSERT statement and execute a destructive DROP TABLE command, wiping the entire Users table.

What about mysqli_real_escape_string()? While the code may have applied this function earlier in the flow, it's insufficient protection because:

  1. It doesn't protect against injection in numeric contexts
  2. Character set mismatches (e.g., GBK encoding) can bypass it entirely
  3. It creates a false sense of security that discourages proper parameterization
  4. It requires developers to remember to apply it to every variable—one miss and you're vulnerable

The Fix

The fix replaces all three vulnerable queries with MySQLi prepared statements. Here's the transformation:

Before — Username check (vulnerable):

$chk_user = "select * from Users where Username = '$Username'";
$chk_run_user = mysqli_query($con, $chk_user);

After — Username check (secure):

$chk_stmt = mysqli_prepare($con, "SELECT * FROM Users WHERE Username = ?");
mysqli_stmt_bind_param($chk_stmt, "s", $Username);
mysqli_stmt_execute($chk_stmt);
$chk_run_user = mysqli_stmt_get_result($chk_stmt);

Before — Email check (vulnerable):

$chk_email = "select * from Users where Email = '$Email'";
$chk_run_email = mysqli_query($con, $chk_email);

After — Email check (secure):

$chk_email_stmt = mysqli_prepare($con, "SELECT * FROM Users WHERE Email = ?");
mysqli_stmt_bind_param($chk_email_stmt, "s", $Email);
mysqli_stmt_execute($chk_email_stmt);
$chk_run_email = mysqli_stmt_get_result($chk_email_stmt);

Before — INSERT (vulnerable):

$insert_query = "insert into Users(Full_Name, Username, Password, Email, Language, Gender, Phone_No, Image) values('$Full_Name', '$Username', '$Password', '$Email', 'English', '$Gender', '$Phone_No', '$Image')";
if(mysqli_query($con, $insert_query)){

After — INSERT (secure):

$insert_stmt = mysqli_prepare($con, "INSERT INTO Users(Full_Name, Username, Password, Email, Language, Gender, Phone_No, Image) VALUES(?, ?, ?, ?, 'English', ?, ?, ?)");
mysqli_stmt_bind_param($insert_stmt, "sssssss", $Full_Name, $Username, $Password, $Email, $Gender, $Phone_No, $Image);
if(mysqli_stmt_execute($insert_stmt)){

How this solves the problem: The ? placeholder tells MySQL's query parser to treat the bound value as data only, never as SQL syntax. Even if an attacker submits ' OR 1=1-- as their username, the database engine will literally search for a user named ' OR 1=1-- rather than interpreting it as SQL logic. The query structure is fixed at prepare time and cannot be altered by input values.

Note the "sssssss" type string in the INSERT bind—each s indicates that the corresponding parameter should be treated as a string type, providing an additional layer of type safety.

Prevention & Best Practices

  1. Always use prepared statements: In PHP MySQLi, use mysqli_prepare() + mysqli_stmt_bind_param(). In PDO, use $pdo->prepare() + $stmt->execute(['param' => $value]). Never concatenate variables into SQL strings.

  2. Adopt an ORM or query builder: Frameworks like Laravel's Eloquent or Doctrine automatically parameterize queries, making SQL injection nearly impossible through normal usage.

  3. Apply the principle of least privilege: The database user for your web application should only have SELECT, INSERT, UPDATE, and DELETE permissions—never DROP, ALTER, or GRANT. This limits damage even if injection occurs.

  4. Validate input types: Before the query, validate that emails look like emails, usernames match expected patterns (/^[a-zA-Z0-9_]+$/), and phone numbers are numeric. This is defense-in-depth, not a replacement for parameterization.

  5. Use static analysis in CI/CD: Tools like Semgrep can flag mysqli_query() calls that contain variables in the query string, catching these issues before they reach production.

  6. Store passwords with proper hashing: While not the focus of this fix, the code stores $Password directly. It should use password_hash($Password, PASSWORD_BCRYPT) before insertion.

Key Takeaways

  • Never trust mysqli_real_escape_string() as your primary defense — the sign_up.php registration form was still exploitable despite its use because parameterized queries are the only reliable SQL injection prevention.
  • All three queries in the registration flow were vulnerable — the username check, email check, AND the INSERT statement all concatenated user input, meaning an attacker had multiple injection points to choose from.
  • The "sssssss" type specification in mysqli_stmt_bind_param() adds type enforcement — each parameter is explicitly declared as a string, preventing type confusion attacks.
  • Registration forms are high-value targets — they accept multiple user inputs (7 fields in this case) and perform multiple database operations, creating a large attack surface.
  • The fix preserves existing behaviormysqli_stmt_get_result() returns a result set compatible with the same mysqli_num_rows() checks used downstream, so no other code changes were needed.

How Orbis AppSec Detected This

  • Source: User-supplied $_POST['Username'] and $_POST['Email'] parameters from the registration form in sign_up.php
  • Sink: mysqli_query($con, $chk_user) at sign_up.php:28 where the concatenated string is executed as a SQL query
  • Missing control: No parameterized query preparation; user input flows directly into the SQL string despite mysqli_real_escape_string() being insufficient
  • CWE: CWE-89 — Improper Neutralization of Special Elements used in an SQL Command ('SQL Injection')
  • Fix: Replaced all three mysqli_query() calls that used concatenated strings with mysqli_prepare() + mysqli_stmt_bind_param() + mysqli_stmt_execute() parameterized statements

Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.

Conclusion

SQL injection remains one of the most dangerous and prevalent web vulnerabilities—OWASP consistently ranks it in the top 10. This sign_up.php fix demonstrates that the solution is straightforward: replace string concatenation with prepared statements. The MySQLi extension has supported mysqli_prepare() since PHP 5.0 (released in 2004), yet developers continue to write concatenated queries out of habit or unfamiliarity.

If you're maintaining PHP code, search your codebase for patterns like "SELECT * FROM table WHERE column = '$variable'" — every instance is a potential SQL injection waiting to be exploited. The migration to prepared statements is mechanical, preserves existing behavior, and eliminates an entire class of vulnerabilities.

References

Frequently Asked Questions

What is SQL injection?

SQL injection is a code injection technique where an attacker inserts malicious SQL statements into input fields that are incorporated into database queries without proper sanitization, allowing unauthorized data access or manipulation.

How do you prevent SQL injection in PHP?

Use parameterized queries (prepared statements) with mysqli_prepare() and mysqli_stmt_bind_param(), or use PDO with prepared statements. Never concatenate user input directly into SQL strings.

What CWE is SQL injection?

SQL injection is classified as CWE-89: Improper Neutralization of Special Elements used in an SQL Command ('SQL Injection').

Is mysqli_real_escape_string() enough to prevent SQL injection?

No. While it escapes certain characters, it provides insufficient protection in contexts like numeric fields, LIKE clauses, or when character set mismatches exist. Parameterized queries are the only reliable defense.

Can static analysis detect SQL injection?

Yes. Static analysis tools and SAST scanners can detect SQL injection by tracing user-controlled data flow from input sources (like $_POST) to SQL query construction functions, flagging string concatenation patterns.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #524

Related Articles

critical

How SQL injection via unsafe template literals happens in TypeScript database scripts and how to fix it

A critical SQL injection vulnerability in `scripts/verify-db.ts` allowed attackers to execute arbitrary SQL commands by manipulating table names passed to the `countTable()` function. The script used `client.unsafe()` with string interpolation, directly embedding unsanitized input into SQL queries. The fix replaced the unsafe pattern with parameterized queries using the postgres client's built-in escaping.

critical

How SQL injection happens in PostgreSQL dictionary synchronization and how to fix it

A critical SQL injection vulnerability in `zhparser--2.1.sql` allowed attackers to execute arbitrary SQL commands by crafting malicious database names. The vulnerability existed because the dictionary synchronization function constructed COPY commands using string concatenation without proper escaping. This fix implements parameterized queries to safely handle database identifiers.

critical

SQL Injection via SQLite's %s Format Specifier in LR2_statlong.cpp ReadPlayerScore()

A critical SQL injection vulnerability was discovered in `LR2/LR2_statlong.cpp` at line 42, where `sqlite3_snprintf` used the `%s` format specifier instead of `%q` to interpolate a player ID into a SQL query. This single-character difference meant that single quotes in the player ID were inserted verbatim, allowing an attacker to break out of the SQL string literal and inject arbitrary commands. The fix changes `%s` to `%q`, which doubles all single quotes to properly escape them.

critical

Buffer Overflow in hoeldb.c: How sprintf() Threatened a Racing Sim's Database Layer

A critical buffer overflow vulnerability was discovered in `src/simmonitor/db/hoeldb.c`, where fixed-size heap buffers (150 and 250 bytes) were allocated with `malloc()` and then written to using `sprintf()` without any bounds checking. The fix replaces these unsafe patterns with `asprintf()` for dynamic allocation and `calloc()` for row data buffers, eliminating both the overflow risk and a related uninitialized memory hazard.

low

SQL Injection via String Formatting: How Parameterized Queries Save the Day

A database query in DBeaver's Altibase extension was constructing SQL statements using `String.format()` with user-controlled input, creating a classic SQL injection vulnerability. The fix replaces the unsafe string interpolation with parameterized queries using `PreparedStatement`, ensuring user input is always treated as data rather than executable SQL. This type of vulnerability is deceptively simple to introduce but equally simple to fix once you know what to look for.

critical

How missing authorization enforcement happens in Node.js Express routers and how to fix it

A critical authorization bypass was discovered in lib/router.js where readOnly and noDelete configuration options were only enforced through UI controls, not server-side middleware. Any authenticated user could bypass these restrictions by sending direct HTTP requests to perform destructive operations like database deletion or document modification. The fix adds Express middleware that enforces these security modes at the server level, blocking POST, PUT, and DELETE requests when appropriate.