Back to Blog
critical SEVERITY7 min read

How SQL Injection happens in Python database scripts and how to fix it

A critical SQL injection vulnerability was discovered in `MangosSuperUI/Scripts/discover_relationships.py`, where database, table, and column names were interpolated directly into SQL queries using Python f-strings. An attacker controlling these input parameters could execute arbitrary SQL against the database. The fix applies backtick escaping for identifier names and parameterized queries for the `LIMIT` clause.

O
By Orbis AppSec
Published August 26, 2026Reviewed August 26, 2026

Answer Summary

This is a SQL injection vulnerability (CWE-89) in Python's `discover_relationships.py` script, where the `sample_distinct_values` and `count_distinct` functions used f-string formatting to embed user-controlled database, table, and column identifiers directly into SQL queries. The fix escapes backtick characters in identifier names and replaces the raw `LIMIT {limit}` interpolation with a parameterized `LIMIT %s` placeholder, preventing attackers from injecting malicious SQL through controlled input parameters.

Vulnerability at a Glance

cweCWE-89
fixBacktick-escape all identifier names and use parameterized query for the LIMIT clause
riskArbitrary SQL execution against the database via controlled input parameters
languagePython
root causeDatabase, table, and column names interpolated into SQL using f-strings without escaping or validation
vulnerabilitySQL Injection via f-string identifier interpolation

The Vulnerability: SQL Injection in discover_relationships.py

The MangosSuperUI/Scripts/discover_relationships.py file is responsible for analyzing database schemas and discovering relationships between tables — a core piece of infrastructure that queries live database metadata. But two of its functions, sample_distinct_values and count_distinct, contained a critical SQL injection flaw that could have allowed an attacker to execute arbitrary SQL against the underlying database.

The root cause? Python f-strings used to build SQL queries, with database names, table names, and column names dropped in raw — no escaping, no validation, no parameterization.


The Vulnerability Explained

What the code looked like before the fix

In sample_distinct_values (around line 195), the original query was constructed like this:

# VULNERABLE — before the fix
cursor.execute(f"SELECT DISTINCT `{column}` FROM `{database}`.`{table}` "
               f"WHERE `{column}` IS NOT NULL AND `{column}` != 0 LIMIT {limit}")

And in count_distinct (around line 216):

# VULNERABLE — before the fix
cursor.execute(f"SELECT COUNT(DISTINCT `{column}`) FROM `{database}`.`{table}` "
               f"WHERE `{column}` IS NOT NULL AND `{column}` != 0")

The variables database, table, and column are passed in from the caller — and if those values come from command-line arguments, environment variables, or any external configuration, an attacker who controls them can inject arbitrary SQL.

Why backtick quoting alone isn't enough

Many developers assume that wrapping identifiers in backticks (`) is sufficient protection. It isn't. Backticks delimit MySQL identifiers, but a backtick inside the identifier value will terminate the delimiter early. Consider what happens if column is set to:

id` FROM users WHERE 1=1; DROP TABLE users; -- 

The resulting query becomes:

SELECT DISTINCT `id` FROM users WHERE 1=1; DROP TABLE users; -- ` FROM `mydb`.`mytable` WHERE ...

The backtick is closed prematurely, and the injected SQL executes. The LIMIT {limit} interpolation is equally dangerous — if limit is not strictly an integer, arbitrary SQL fragments can be appended there too.

Real-world attack scenario

Imagine discover_relationships.py is invoked as part of an automated pipeline where the database and table names are read from a configuration file or passed as arguments:

python discover_relationships.py --database "mydb" --table "orders`; GRANT ALL ON *.* TO 'attacker'@'%'; --"

With the vulnerable code, this would construct and execute:

SELECT DISTINCT `col` FROM `mydb`.`orders`; GRANT ALL ON *.* TO 'attacker'@'%'; -- `.`...`

Depending on the database user's privileges, this could escalate to full database compromise, data exfiltration, or destruction of data.


The Fix

What changed

The fix applied two complementary defenses across both functions:

1. Backtick escaping for all SQL identifiers

Before using database, table, or column in a query, each value now has its backtick characters doubled — the standard MySQL escape sequence for literal backticks inside backtick-quoted identifiers:

db_esc  = database.replace('`', '``')
tbl_esc = table.replace('`', '``')
col_esc = column.replace('`', '``')

This means an attacker-supplied value like orders`; DROP TABLE -- becomes orders; DROP TABLE -- `` inside the query, which MySQL treats as a literal identifier name rather than a SQL injection vector.

2. Parameterized query for the LIMIT clause

The LIMIT value, previously interpolated raw as {limit}, is now passed as a proper query parameter:

# FIXED — after the fix
cursor.execute(f"SELECT DISTINCT `{col_esc}` FROM `{db_esc}`.`{tbl_esc}` "
               f"WHERE `{col_esc}` IS NOT NULL AND `{col_esc}` != 0 LIMIT %s",
               (int(limit),))

The int(limit) cast ensures the value is strictly numeric before it even reaches the database driver, and the %s placeholder hands off binding to the database connector — which handles escaping correctly.

Before vs. after comparison

Before After
database in query Raw f-string: {database} Escaped: db_esc = database.replace('', '`')
table in query Raw f-string: {table} Escaped: tbl_esc = table.replace('', '`')
column in query Raw f-string: {column} Escaped: col_esc = column.replace('', '`')
limit in query Raw f-string: {limit} Parameterized: LIMIT %s with (int(limit),)

Why this approach is correct for identifiers

Standard SQL parameterization (using %s placeholders) works for values — string literals, numbers, dates. It does not work for SQL identifiers like table names and column names, because the database driver quotes and escapes them as string literals, not as identifiers. The correct approach for identifiers in MySQL is exactly what this fix does: use backtick quoting and escape any literal backticks in the name by doubling them.


Prevention & Best Practices

1. Never use f-strings or % formatting to build SQL

This is the single most important rule. If you find yourself writing:

cursor.execute(f"SELECT * FROM {table_name}")

Stop. This is a SQL injection waiting to happen.

2. Use parameterization for all values

For any value that isn't a SQL identifier (table/column name), use your database driver's parameterization:

# MySQL Connector / PyMySQL
cursor.execute("SELECT * FROM users WHERE id = %s", (user_id,))

# sqlite3
cursor.execute("SELECT * FROM users WHERE id = ?", (user_id,))

3. Use allowlists for identifier names when possible

If the set of valid database, table, or column names is known ahead of time, validate against an allowlist before constructing any query:

ALLOWED_TABLES = {"orders", "customers", "products"}
if table not in ALLOWED_TABLES:
    raise ValueError(f"Invalid table name: {table}")

This is the strongest defense for identifier injection.

4. Escape identifiers when allowlists aren't feasible

When dynamic identifier names are truly necessary (as in schema discovery tools like this one), use the backtick-doubling approach from the fix, or use a library that provides identifier quoting (e.g., mysql.connector's cmd_query_iter or SQLAlchemy's quoted_name).

5. Run static analysis in CI

Tools that can catch this pattern:
- Bandit (B608 rule: hardcoded SQL expressions)
- Semgrep with the python.lang.security.audit.formatted-sql-query ruleset
- AI-based scanners like Orbis AppSec (which detected this exact issue)

6. Reference standards

  • OWASP SQL Injection Prevention Cheat Sheet: The definitive guide to parameterization and input validation
  • CWE-89: Improper Neutralization of Special Elements used in an SQL Command

Key Takeaways

  • F-string SQL construction in discover_relationships.py was the direct cause — even with backtick quoting, raw identifier interpolation is exploitable by terminating the backtick early.
  • LIMIT {limit} was a separate injection point — numeric-looking parameters are still dangerous if not cast and parameterized.
  • Backtick doubling (replace('', '`')) is the correct MySQL escape for identifier names — standard %s parameterization cannot be used for table or column names.
  • Schema introspection tools are high-risk targets — they frequently accept database/table/column names as inputs, making them prime candidates for identifier injection.
  • The count_distinct function at line 216 had the same pattern and was fixed in the same PR — always audit all similar call sites when fixing injection vulnerabilities.

How Orbis AppSec Detected This

  • Source: User-controlled input parameters (database, table, column, limit) passed to sample_distinct_values() and count_distinct() in discover_relationships.py
  • Sink: cursor.execute(f"SELECT DISTINCT{column}FROM{database}.{table}...") at lines 195, 198, 216, and 234 — raw f-string interpolation of identifiers directly into SQL
  • Missing control: No backtick escaping, no identifier allowlist validation, and no parameterization for the LIMIT clause
  • CWE: CWE-89 — Improper Neutralization of Special Elements used in an SQL Command
  • Fix: Added replace('', '`') escaping for all three identifier variables and replaced LIMIT {limit} with a parameterized LIMIT %s binding with an explicit int() cast

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 through identifier interpolation is a subtle but critical vulnerability — and it's easy to overlook precisely because backtick quoting looks safe. The discover_relationships.py script is a perfect example of how schema introspection tools, which by design accept dynamic table and column names, can become injection vectors if those names aren't properly escaped.

The fix here is a model for how to handle this correctly in Python MySQL code: escape backticks in identifier names by doubling them, and use %s parameterization with an explicit type cast for any numeric parameters like LIMIT. Combined with static analysis in CI, these practices can prevent entire classes of SQL injection from reaching production.


References

Frequently Asked Questions

What is SQL injection in Python database scripts?

SQL injection occurs when user-controlled data is embedded directly into SQL query strings without sanitization, allowing attackers to alter the query's logic or execute arbitrary SQL commands.

How do you prevent SQL injection in Python with identifier names?

For SQL identifiers (table/column names), use backtick escaping by replacing backticks in the input (e.g., `name.replace('`', '``')`), since standard parameterization only works for values, not identifiers.

What CWE is SQL injection?

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

Is f-string formatting enough to prevent SQL injection in Python?

No. F-strings provide no SQL escaping or sanitization. They simply embed variables as raw strings, making it trivial for an attacker to break out of the intended query structure.

Can static analysis detect SQL injection via f-strings in Python?

Yes. Tools like Semgrep, Bandit, and AI-based scanners can detect f-string or %-format SQL construction patterns and flag them as potential injection sinks.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #2

Related Articles

critical

How SQL Injection happens in Node.js SQLite CLI calls and how to fix it

A critical SQL injection vulnerability was discovered in `lib/ParamediciOSPermissions.js`, where the `service` and `app` variables were interpolated directly into raw SQL strings passed to the `sqlite3` command-line tool without any escaping or parameterization. An attacker with control over these inputs could manipulate the iOS simulator's TCC permission database, potentially granting unauthorized app permissions. The fix applies SQLite-standard single-quote escaping to both variables before th

critical

How SQL Injection happens in Python SQLite utilities and how to fix it

A SQL injection risk was discovered in `scripts/db_utils.py` where the `_get_or_create` function used f-string interpolation to dynamically construct table and column names in SQL queries. While current callers passed hardcoded values, the function accepted arbitrary strings, making it a latent injection vector for any future code that passed user-controlled input. The fix replaces dynamic SQL construction with a strict allowlist of pre-written, parameterized query strings.

critical

How Unsafe Fall-Through in getWhereConditions Happens in Sequelize and How to Fix It

A critical vulnerability in Sequelize (CVE-2023-22579) allowed attackers to inject raw SQL through an unsafe fall-through in the `getWhereConditions` function when parentheses were used in query attributes. Upgrading from version 6.26.0 to 6.29.0 closes this attack vector by tightening how raw attributes are handled. Any Node.js application using Sequelize for database queries should treat this upgrade as an urgent security priority.

high

How SQL Injection happens in Python BigQuery connectors and how to fix it

A high-severity SQL injection vulnerability was discovered in a BigQuery connector's query-building logic, where Python f-strings interpolated user-controlled identifiers—project_id, dataset_id, table_id, and timestamp_column—directly into SQL without validation. An attacker with control over connector configuration could inject arbitrary BigQuery SQL, including destructive statements. The fix introduces strict allowlist-based identifier validation using compiled regular expressions before any S

critical

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.

critical

How Archive Path Traversal Happens in Node.js and How to Fix It

CVE-2026-53486 is a critical path traversal vulnerability in the Decompress library, where crafted archive entries can write files and symbolic links outside the intended extraction directory. This vulnerability was transitively introduced through `@vitest/browser` and related packages pinned at version 4.1.5, and was resolved by upgrading to 4.1.6 and 5.0.0-beta.3. Left unpatched, an attacker who controls an archive file processed by any downstream consumer of this dependency chain could overwr