Why SQL Injection Still Dominates PHP Breach Reports

SQL injection has appeared in the OWASP Top 10 in every edition since the list began. It has been documented as a security vulnerability since 1998. More published exploit guides, more CVE entries, and more post-incident reports exist for SQL injection than for almost any other web application vulnerability. It still appears near the top of every real-world breach analysis published in the past five years, including reports covering PHP applications specifically.

The fix is well understood. Prepared statements have been available in PHP for years, and the concept is straightforward. The vulnerability persists because the reasoning behind the fix is not always intuitive to developers who have not seen what exploitation looks like in practice. This article explains how SQL injection actually works in PHP applications, shows the specific code patterns that create vulnerabilities, demonstrates real exploitation scenarios, and covers both the correct remediation and the tools available to test whether your application has these problems.

How SQL Injection Works in Practice

SQL injection occurs when user input is included in a SQL query without being properly escaped or parameterised. The database interprets part of the user input as SQL code rather than as a data value. The consequences range from unauthorised data access to complete server compromise, depending on the database configuration and the application's error handling. Understanding this distinction between data and code is fundamental to grasping why injection vulnerabilities occur and how to prevent them.

The canonical example is a login form. A PHP handler receives an email and password from a form and queries the database to check whether a matching user exists. The naive approach looks like this:

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

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

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

An attacker submits ' OR '1'='1 as the email and leaves the password blank. The resulting query becomes:

SELECT * FROM users WHERE email = '' OR '1'='1' AND password = ''

The condition '1'='1' is always true. The query returns the first row from the users table, which is often an administrator account. The attacker is logged in without knowing any password. The same principle applies whether the vulnerability exists in a login form, a search function, a filter, or any other user-accessible input.

The attack does not require a login form. Any user input that reaches a SQL query unsanitised is a potential vector. This includes URL parameters, query string values, HTTP headers, cookie values if they are used in database queries, and file content if filenames constructed from user input are passed to the database. The principle is always the same: untrusted data that reaches a SQL query without escaping creates a potential injection point.

Error-based injection is the most straightforward to demonstrate. Injecting a single quote into a vulnerable parameter causes a SQL syntax error when the query is constructed. The error message often reveals the database type, table structure, and sometimes the full SQL query being executed. A production application that displays database errors to the user is leaking information that makes subsequent injection attacks significantly easier. This is one of the most common findings during a technical security review of a PHP application.

Real CVE Examples in PHP Applications

Understanding theory is important. Seeing real exploitation in context makes the risk tangible. The following are genuine documented cases involving SQL injection in PHP applications and related infrastructure.

CVE-2017-9841 targeted the PHPUnit testing library, which is commonly included as a Composer dependency in PHP applications. A PHP file in the library accepted user input via a parameter and passed it to an eval() call, making it directly exploitable without authentication. Over 60,000 public GitHub repositories were estimated to have been vulnerable. The attack payload was a single HTTP request that could execute arbitrary PHP code on the server. This case demonstrates how dependencies are a direct attack surface in PHP applications.

CVE-2018-7263 affected the Drupal content management system via a SQL injection vulnerability in its database search module. An unauthenticated attacker could send a specially crafted request to trigger the injection, extracting user credentials, session tokens, and other sensitive data from the database. Drupal's security team published an emergency patch within days, but the vulnerability had been present in the code for some time before discovery. Drupal's case is instructive because it shows that even well-maintained open source projects with active security communities can ship vulnerable code.

The 2017 Equifax breach, one of the largest data exposures in history, was initiated through a vulnerability (CVE-2017-5638) in the Apache Struts framework running on a PHP application server. The attacker used the injection to access an internal database, ultimately exfiltrating personal data of 147 million people. While the initial vector was in the Struts framework rather than PHP application code, it demonstrates that SQL injection vulnerabilities in supporting infrastructure can be just as damaging as those in your own code.

WordPress plugin vulnerabilities are a consistent source of SQL injection in PHP applications. The WPvivid Backup plugin vulnerability (CVE-2024-31449) allowed unauthenticated SQL injection via a database prefix parameter that was not properly escaped. The plugin was installed on over 100,000 websites. Attackers could extract the entire WordPress user database, including hashed passwords, and use those credentials to compromise sites further. Conducting a WordPress security audit that includes plugin review is important for any site running third-party code.

These cases share a common pattern: the vulnerable code was simple and the fix was straightforward, but the vulnerability existed in production code for months or years before discovery. Automated tools can find these vulnerabilities in minutes. The challenge is applying the knowledge to your own codebase before an attacker does.

The Correct Fix: Prepared Statements

The correct approach to preventing SQL injection in PHP is prepared statements, also called parameterised queries. A prepared statement separates the SQL query structure from the user data. The query structure is sent to the database first, with placeholders where user data will go. The user data is sent separately. The database never interprets user data as SQL code because the structure is already fixed before any user data arrives. This structural separation is what makes prepared statements fundamentally different from string escaping.

The mysqli implementation of prepared statements looks like this:

$stmt = $conn->prepare("SELECT * FROM users WHERE email = ? AND password = ?");
$stmt->bind_param("ss", $_POST['email'], $_POST['password']);
$stmt->execute();
$result = $stmt->get_result();

The ? placeholders are bound to the actual values using bind_param(). The "ss" argument tells mysqli that both parameters are strings. This binding process handles all escaping automatically. No matter what characters the user submits, including single quotes, SQL keywords, or database commands, the database treats them as data, not as executable SQL.

The PDO implementation is similar but uses named parameters:

$stmt = $pdo->prepare("SELECT * FROM users WHERE email = :email AND password = :password");
$stmt->execute([
    ':email' => $_POST['email'],
    ':password' => $_POST['password']
]);
$user = $stmt->fetch();

Named parameters (:email, :password) make the code more readable and reduce the chance of parameter binding errors in complex queries. Either approach works correctly; the choice often depends on the existing codebase and team familiarity.

Prepared statements work for all query types, not just SELECT. UPDATE, INSERT, DELETE, and DROP statements all accept parameters and are equally vulnerable to injection if user input reaches them unsanitised. Every query that includes user input should use prepared statements, regardless of the query type.

mysqli_real_escape_string() and mysql_real_escape_string() (the latter is deprecated and removed in PHP 7.0) were the correct approach before prepared statements were widely available. They work by escaping special characters in user input so they cannot break out of the string context in a query. They are still safe to use when applied correctly to every input, but they are error-prone because it is easy to miss one input path while escaping others. Prepared statements make escaping the default and structurally impossible to forget. Use prepared statements for all new code, and migrate existing code when opportunities arise.

Blind SQL Injection and Automated Exploitation

Blind SQL injection applies when the application does not return query results or database errors in its HTTP response. The attacker cannot see the data returned by the query, so they infer information by asking true/false questions and observing the application's response. Blind injection is slower but still practical, and it works against applications that display no obvious error output.

Boolean-based blind injection sends a condition and checks whether the response changes. The payload ' AND 1=1 -- evaluates to true, and the page behaves normally. The payload ' AND 1=2 -- evaluates to false, and the page behaves differently. By iterating through character-by-character extraction using substring comparisons, an attacker can recover an entire database without ever seeing query results directly.

Time-based blind injection uses database wait functions to create a measurable delay that confirms a condition is true. A payload like ' AND SLEEP(5) -- causes the database to wait 5 seconds before responding if the condition is true. By iterating conditions and measuring response times, an attacker can extract data one bit at a time. This technique works even when the application returns identical content for both true and false conditions.

SQLMap is the standard tool for automating SQL injection detection and exploitation. It handles all three injection types and can extract database contents, read files from the server file system, and execute commands on the underlying operating system in many configurations. Running SQLMap against a vulnerable application takes minutes and requires no manual expertise beyond knowing how to specify a URL.

sqlmap -u "https://yoursite.com/product.php?id=5" --batch --dbs

The --batch flag runs SQLMap in non-interactive mode, making automated scans practical. The --dbs flag asks SQLMap to enumerate all databases. SQLMap will detect the injection point, identify the database type, and begin extracting data automatically. It will also identify WAF (Web Application Firewall) protections if they are present and can often adapt its payloads to bypass them.

Never run SQLMap against systems you do not have explicit written authorisation to test. Unauthorized scanning is illegal in most jurisdictions and can result in criminal prosecution or civil liability. Only use this tool against your own systems or systems where you have documented permission.

Testing Your PHP Application for SQL Injection

Manual testing requires injecting a known-safe payload into every user-accessible input and observing whether it causes unexpected database behaviour. A thorough manual test can identify obvious vulnerabilities, but it is time-consuming and relies on the tester covering every input path.

The single-quote test is the first check. Submit ' to every parameter and look for SQL syntax errors in the response. A payload of ' in a parameter that is used in a query without escaping causes a SQL syntax error. If the error appears and reveals database structure, the parameter is confirmed vulnerable.

The AND 1=1 test refines this. Submit ' AND '1'='1 to a parameter. If the response is the same as the normal response, the parameter may be vulnerable. Submit ' AND '1'='2. If the response differs, the parameter is processing SQL logic and is vulnerable. This differential testing is the basis of most blind injection techniques.

Automated tools are more thorough than manual testing and should be used in addition to manual review. OWASP ZAP (Zed Attack Proxy) is a free, open-source web application security scanner that includes active scanning for SQL injection vulnerabilities. It spiders your application, identifies all accessible endpoints, and tests each parameter with a range of SQL injection payloads. ZAP is a practical starting point for anyone reviewing a PHP application's attack surface.

Static analysis tools can scan your source code for SQL injection vulnerabilities without running the application. They analyse data flow, tracking how user input reaches database query functions and identifying paths where the input is not escaped before reaching the query. These tools are most useful during development as part of a continuous integration pipeline, where they can flag vulnerabilities before code reaches production.

A practical approach combines static analysis for regular ongoing scanning during development with manual penetration testing for a thorough assessment before launch and periodically thereafter. Neither approach alone is sufficient. A review against the OWASP Top 10 provides a structured baseline for what to check.

Least Privilege: Limiting Damage When Prevention Fails

No security control is perfectly reliable. Defence in depth limits the impact of a successful SQL injection attack. The most important layer is database account privilege restriction. Even if an attacker finds and exploits an injection vulnerability, well-configured database permissions can prevent them from escalating their access.

The database user account that your PHP application connects with should have exactly the permissions it needs and nothing more. An application that only reads data should connect with a read-only user. An application that writes to specific tables but never drops them should not have DROP permission. If the application account cannot DROP tables, an attacker who achieves SQL injection cannot drop your entire database, even if they successfully inject a DROP statement.

MySQL's GRANT statements control permissions at the database, table, and column level:

-- Create a read-only application user
CREATE USER 'app_readonly'@'localhost' IDENTIFIED BY 'strong_password';
GRANT SELECT ON app_db.* TO 'app_readonly'@'localhost';

-- Create a write-only application user (no SELECT)
CREATE USER 'app_writer'@'localhost' IDENTIFIED BY 'strong_password';
GRANT INSERT, UPDATE, DELETE ON app_db.* TO 'app_writer'@'localhost';

Use separate database user accounts for different application functions. Your web application's API that runs public queries should use an account with SELECT-only permissions. Your admin panel that modifies data should use a different account with INSERT/UPDATE/DELETE permissions. Separate accounts mean that an attacker who compromises one part of the application cannot modify data through the other.

Web server filesystem permissions are the next layer. The database server should not be able to write to the web server's document root. If an attacker uses SQL injection to write a PHP shell to the filesystem using INTO OUTFILE or INTO DUMPFILE, the file will only be created successfully if the database server's OS user has write permission to that directory. Keep the web server's document root owned by a different user than the database server process.

Logging and Monitoring for Injection Attempts

SQL injection attacks often arrive as a burst of requests with payloads designed to exploit known vulnerabilities. They are detectable through pattern-based monitoring. Your web server logs should capture query string parameters so that injection payloads are visible in the logs. A log analysis tool or a SIEM (Security Information and Event Management) system can alert on known SQL injection signatures.

A simple approach using fail2ban, which is primarily used for SSH brute-force protection, can be adapted to detect SQL injection attempts in web server logs:

failregex = ^<HOST> - .* "GET.*(\"|'|\bOR\b|\bAND\b).* HTTP.*"$
            ^<HOST> - .* "GET.*(\/| UNION | SELECT | DROP | INSERT).* HTTP.*"$

This failregex identifies common SQL injection patterns in Apache access logs. Adjust the pattern based on your application's normal URL structure to avoid false positives from legitimate requests that happen to contain these character sequences. Testing against production traffic before deployment is important to avoid blocking legitimate users.

Protecting the Wider Attack Surface

SQL injection exists within a broader security context. Configuring your web server correctly reduces the information available to attackers and limits exploitation paths. Using HTTPS with modern TLS configurations protects data in transit and prevents man-in-the-middle attacks that could modify requests to inject malicious content. A review of HTTPS and TLS configuration is a practical step for any business website.

Web Application Firewalls (WAFs) add another layer by detecting and blocking common injection patterns before they reach your application. A WAF is not a substitute for fixing vulnerable code, but it can reduce exposure while remediation is in progress.

Human factors also play a role. Developers who understand injection vulnerabilities are less likely to introduce them. Security awareness training helps teams recognise the risks and apply correct patterns consistently. Building security knowledge across your development team reduces the likelihood of vulnerable code reaching production.

A Practical Priority List for PHP Developers

Fix SQL injection vulnerabilities in this order of impact. Working through this list systematically reduces your exposure more effectively than adding security tools without fixing the underlying code.

  1. Find all queries that concatenate user input directly into SQL strings. Search your codebase for patterns where the query string contains $_GET, $_POST, or other user input variables. Every match needs review.
  2. Convert every database query to use prepared statements. This is the single change that eliminates SQL injection risk for that query. Work through the codebase systematically.
  3. Set database user accounts to least privilege. Separate read and write accounts. Remove unnecessary permissions. Assume the worst-case scenario where one application account is fully compromised.
  4. Remove error messages that reveal database structure. Production applications should not display SQL errors to end users. Log them, alert on them, but do not show them.
  5. Add monitoring and alerting for SQL injection patterns in web logs. A wave of injection probes that goes unnoticed for days is a higher-risk situation than one that triggers an alert within minutes.

SQL injection is not a complex vulnerability to understand or to fix. The reason it persists is that it requires consistent attention across an entire codebase. One unparameterised query is enough to compromise an entire application. The discipline is ensuring that every query, without exception, uses parameterised queries for any data that originates from a user or an untrusted source.

If you need help reviewing your current setup, prepare a short note with your website URL, hosting details, current issue, and any recent changes before getting in touch.