Back to Blog
critical SEVERITY7 min read

How SQL Injection happens in PHP PDO queries and how to fix it

A critical SQL injection vulnerability was discovered in the `getOfficialContests()` method of ContestRepository.php, where the `$site_id` parameter was directly interpolated into a SQL query string instead of using prepared statements. This vulnerability allowed attackers to inject arbitrary SQL commands and potentially access or manipulate the entire contest database. The fix replaced `pdo->query()` with `pdo->prepare()` and proper parameter binding.

O
By Orbis AppSec
•Published August 10, 2026•Reviewed August 10, 2026

Answer Summary

SQL injection (CWE-89) in PHP PDO occurs when user input is directly interpolated into SQL query strings instead of using prepared statements with parameter binding. In the PatitoOnlineJudge ContestRepository.php file, the `getOfficialContests()` method used `$this->pdo->query("... AND contest_site.site_id = $site_id ...")`, which allowed attackers to inject malicious SQL through the `$site_id` parameter. The fix replaced `query()` with `prepare()` and changed `$site_id` to a bound parameter `:site_id`, preventing SQL injection by separating SQL logic from data values.

Vulnerability at a Glance

cweCWE-89
fixReplace pdo->query() with pdo->prepare() and bind parameters using execute()
riskAttackers can execute arbitrary SQL queries to read, modify, or delete contest data
languagePHP
root causeDirect variable interpolation in SQL query string instead of parameterized query
vulnerabilitySQL Injection via Direct String Interpolation

Introduction

In the PatitoOnlineJudge repository, we discovered a critical SQL injection vulnerability in ContestRepository.php at line 117. While most methods in this file correctly used prepared statements to interact with the database, the getOfficialContests() method took a dangerous shortcut: it directly interpolated the $site_id parameter into a SQL query string using PHP's variable interpolation. This single inconsistency created a textbook SQL injection vulnerability that could have allowed attackers to execute arbitrary SQL commands against the contest database.

What makes this vulnerability particularly concerning is that it appears in a repository that otherwise demonstrates good security practices. The same file contains numerous other methods that correctly use pdo->prepare() and parameter binding. This highlights how a single oversight—using pdo->query() instead of pdo->prepare()—can undermine an entire application's security posture.

The Vulnerability Explained

Let's examine the vulnerable code from line 133 of ContestRepository.php:

public function getOfficialContests($site_id)
{
    $stmt = $this->pdo->query("SELECT * FROM contest, contest_site
        WHERE contest.defunct = 'O'
        AND contest_site.contest_id = contest.contest_id
        AND contest_site.site_id = $site_id
        ORDER BY contest.contest_id DESC");
    return $stmt->fetchAll(PDO::FETCH_ASSOC);
}

The problem lies in line 136: AND contest_site.site_id = $site_id. Here, the $site_id variable is directly interpolated into the SQL string using PHP's variable interpolation syntax. When PHP processes this code, it replaces $site_id with its actual value before sending the query to the database. This means the database never knows that $site_id was supposed to be a separate data value—it just sees one complete SQL string.

The Attack Scenario

An attacker controlling the $site_id parameter could exploit this vulnerability to inject arbitrary SQL. Here's a concrete example:

Instead of passing a legitimate site ID like 123, an attacker could pass:

123 OR 1=1 UNION SELECT username, password, email, NULL, NULL, NULL, NULL FROM users --

The resulting query would become:

SELECT * FROM contest, contest_site
WHERE contest.defunct = 'O'
AND contest_site.contest_id = contest.contest_id
AND contest_site.site_id = 123 OR 1=1 UNION SELECT username, password, email, NULL, NULL, NULL, NULL FROM users --
ORDER BY contest.contest_id DESC

This injected SQL would:
1. Return all contests (via OR 1=1)
2. Append user credentials from the users table (via UNION SELECT)
3. Comment out the rest of the original query (via --)

Real-World Impact

For the PatitoOnlineJudge application, this vulnerability could allow attackers to:

  • Extract sensitive data: Access contest information marked as private, user credentials, or administrative data
  • Modify contest records: Change contest dates, participants, or results
  • Delete data: Drop tables or truncate contest records
  • Bypass authentication: Extract password hashes or manipulate user roles
  • Escalate privileges: Modify their own user records to gain administrative access

The severity is amplified because this method specifically queries the contest_site table, which likely controls which contests are visible on which sites—a critical access control mechanism.

The Fix

The fix implements the standard defense against SQL injection: prepared statements with parameter binding. Here's the corrected code:

Before (Vulnerable):

public function getOfficialContests($site_id)
{
    $stmt = $this->pdo->query("SELECT * FROM contest, contest_site
        WHERE contest.defunct = 'O'
        AND contest_site.contest_id = contest.contest_id
        AND contest_site.site_id = $site_id
        ORDER BY contest.contest_id DESC");
    return $stmt->fetchAll(PDO::FETCH_ASSOC);
}

After (Secure):

public function getOfficialContests($site_id)
{
    $stmt = $this->pdo->prepare("SELECT * FROM contest, contest_site
        WHERE contest.defunct = 'O'
        AND contest_site.contest_id = contest.contest_id
        AND contest_site.site_id = :site_id
        ORDER BY contest.contest_id DESC");
    $stmt->execute([':site_id' => $site_id]);
    return $stmt->fetchAll(PDO::FETCH_ASSOC);
}

What Changed

Three specific changes were made to line 133, 136, and the addition of line 138:

  1. Line 133: pdo->query() → pdo->prepare()
    - This tells PDO to prepare the SQL statement as a template rather than executing it immediately

  2. Line 136: $site_id → :site_id
    - The direct variable interpolation is replaced with a named placeholder (:site_id)
    - The placeholder acts as a marker where the parameter value will be inserted

  3. Line 138 (new): $stmt->execute([':site_id' => $site_id]);
    - The actual $site_id value is passed separately through the execute() method
    - PDO handles the value as pure data, never as SQL code

How This Prevents SQL Injection

When using prepared statements with parameter binding:

  1. The SQL structure is sent to the database first (with placeholders)
  2. The database compiles and optimizes the query structure
  3. Parameter values are sent separately and treated exclusively as data
  4. No matter what characters the $site_id contains (quotes, semicolons, SQL keywords), they're never interpreted as SQL commands

Even if an attacker passes 123 OR 1=1 --, the database treats the entire string as a literal value to compare against contest_site.site_id. The query would look for a site_id that exactly matches the string "123 OR 1=1 --" (which doesn't exist), rather than executing the injected SQL logic.

Key Takeaways

  • The getOfficialContests() method in ContestRepository.php used direct string interpolation ($site_id) instead of parameter binding, creating a critical SQL injection vulnerability at line 136
  • Switching from pdo->query() to pdo->prepare() with :site_id placeholder eliminated the vulnerability by separating SQL structure from data values
  • Inconsistent security patterns are dangerous: while other methods in the same file correctly used prepared statements, this single method's shortcut created an exploitable weakness
  • Input validation is not a substitute for prepared statements: even with validation, always use parameterized queries as the primary SQL injection defense
  • Static analysis tools can catch these patterns early: implementing automated security scanning would have flagged this vulnerability before it reached production

How Orbis AppSec Detected This

  • Source: The $site_id parameter passed to the getOfficialContests() method from untrusted input (likely HTTP request parameters)
  • Sink: Direct variable interpolation in the SQL query string at line 136: AND contest_site.site_id = $site_id passed to pdo->query()
  • Missing control: No prepared statement or parameter binding; the variable was directly interpolated into the SQL string instead of using a placeholder and execute()
  • CWE: CWE-89 (Improper Neutralization of Special Elements used in an SQL Command)
  • Fix: Replaced pdo->query() with pdo->prepare(), changed $site_id to :site_id placeholder, and added execute([':site_id' => $site_id]) to bind the parameter safely

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

The SQL injection vulnerability in ContestRepository.php demonstrates how a single inconsistent security practice can create critical exposure, even in a codebase that otherwise follows secure patterns. By replacing direct string interpolation with prepared statements and parameter binding, the fix eliminates the vulnerability at its root cause. This case reinforces a fundamental principle: always use prepared statements for SQL queries with dynamic values, regardless of how simple the query appears or how much you trust the input source. The small effort of using prepare() and execute() instead of query() provides complete protection against one of the most dangerous web application vulnerabilities.

Prevention and further reading

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #2

Related Articles

critical

heatmap.php SQL Injection: $_REQUEST Parameters in Unparameterized

A critical SQL injection vulnerability in the heatmap data retrieval endpoint allowed attackers to execute arbitrary database commands by manipulating coordinate bounds or time range parameters. The vulnerability affected all six user-controlled $_REQUEST parameters passed directly into query construction without parameterization.

critical

Actual Budget addTransaction.sh SQL Injection via Shell Variable

A critical SQL injection vulnerability in Actual Budget's transaction automation script allowed attackers to manipulate database records through shell variables interpolated directly into SQL strings. The fix introduces proper escaping functions and numeric validation to prevent injection through unquoted fields.

critical

SQL's Insert() and Update() Methods Used F-String Interpolation in u2share_batch_give_sugar

The SQL helper class in u2share_batch_give_sugar used Python f-strings to construct INSERT and UPDATE queries, creating SQL injection vulnerabilities even though values appeared to come from internal constants. The fix replaces all f-string query construction with sqlite3 parameterized queries using `?` placeholders, eliminating string interpolation entirely from the database path.

high

TrackOptionsManager DDL: Template-Literal SQL Injection Closed

The `TrackOptionsManager` service built its `CREATE TABLE` and `ALTER TABLE ... ADD COLUMN alias` statements by interpolating a `DEFAULT_ALIAS` constant directly inside single quotes in a JavaScript template literal, and its private `_query()` helper had no parameter channel at all. The fix routes the default value through `mysql.escape()` and gives `_query(q, params = [])` a real bound-parameter argument that is forwarded to `db.query()`. This removes an injection primitive on a schema-bootstra

high

How SQL injection via template literals happens in Node.js SQLite and how to fix it

A SQL injection vulnerability in `src/lib/codex-state.mjs` allowed dynamic column names to reach SQL queries through JavaScript template literals. The fix implements defense-in-depth with strict identifier validation using `SAFE_IDENTIFIER` regex before query construction.

high

How Python SQLAlchemy Raw Query SQL Injection happens and how to fix it

A high-severity SQL injection vulnerability was fixed in the `skills/last30days/scripts/store.py` file where untrusted input was being concatenated directly into raw SQL queries. The fix replaces string concatenation with SQLAlchemy's TextualSQL prepared statements using named parameters, preventing attackers from manipulating database queries through malicious input.