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
•Technically reviewed by Anupam Mediratta•Published August 26, 2026•Reviewed 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.


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.


Prevention and further reading

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #2

Related Articles

critical

Go ArticleRemove() SQL Injection via fmt.Sprintf IN Clause

The Go backend's `ArticleRemove` function built a SQL `DELETE` statement by joining a caller-supplied slice of article IDs directly into the query string with `fmt.Sprintf`. Because the `aids` values came straight from the frontend with no validation or escaping, an attacker could inject arbitrary SQL into the `IN(...)` clause. The fix replaces string concatenation with a parameterized query using placeholders and a `DB.Exec` argument list.

critical

JdbcSinkFunction.invoke() SQL Injection via Unvalidated Identifiers

The `JdbcSinkFunction.invoke()` method in Lacus's RTC engine built SQL INSERT statements with `String.format()`, placing database name, table name, and column names directly into the query string without validation. Although the actual row values used parameterized `?` placeholders, the identifiers themselves were injectable, letting a crafted sink configuration break out of the intended INSERT statement. The fix adds a strict allowlist regex that rejects any identifier containing characters out

critical

Node.js Auth Query SQL Injection via Group Code Interpolation

A sign-in authorization check built its SQL query by mapping an `authorizedGroups` array into quoted string literals and joining them directly into a template literal, creating a classic SQL injection point in a critical authentication path. The fix replaces every interpolated value — including the previously "typed" parameter — with `?` placeholders bound through a value-builder helper, closing off the injection vector entirely.

critical

Toolforge Database Creation SQL Injection via Unicode Backtick Bypass

A critical SQL injection vulnerability in the database creation routine of a Node.js wiki library allowed attackers to inject arbitrary SQL by exploiting a weak validation check. The fix replaces a character-based block with a strict allowlist pattern, ensuring only valid database identifiers reach the SQL engine.

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.