GHSA-xxh2-68g9-8jqr ormar is vulnerable to SQL Injection through aggregate functions min() and max()
Report of SQL Injection Vulnerability in Ormar ORMA SQL Injection attack can be achieved by passing a crafted string to the min() or max() aggregate functions.Brief descriptionWhen performing aggregate queries, Ormar ORM constructs SQL expressions by passing user-supplied column names directly into `sqlalchemy.text()` without any validation or sanitization. The `min()` and `max()` methods in the `QuerySet` class accept arbitrary string input as the column parameter. While `sum()` and `avg()` are partially protected by an `is_numeric` type check that rejects non-existent fields, `min()` and `max()` skip this validation entirely. As a result, an attacker-controlled string is embedded as raw SQL inside the aggregate function call. Any unauthorized user can exploit this vulnerability to read the entire database contents, including tables unrelated to the queried model, by injecting a subquery as the column parameter.Affected versionsThe vulnerable `SelectAction.get_text_clause()` method and the `min()`/`max()` aggregate functions were introduced together in commit `ff9d412` (March 12, 2021) and first released in version **0.9.9**. The vulnerable code has never been modified since — `get_text_clause()` is identical in every subsequent version through the latest **0.21.0**.Versions prior to 0.9.9 do not contain the `min()`/`max()` aggregate feature and are not affected.The following uses the latest ormar 0.21.0 as an example to illustrate the attack.Vulnerability detailsWhen performing an aggregate query, the `QuerySet.max()` method (line 721, `queryset.py`) passes user input to `_query_aggr_function()`. This method creates a `SelectAction` object for each column name. The column string is split by `__` and the last part becomes `self.field_name` — with no validation against the model's actual fields.The critical vulnerability is in `SelectAction.get_text_clause()` (line 41-43, `select_action.py`), which directly passes `self.field_name` into `sqlalchemy.text()`:The `apply_func()` method then wraps this raw text clause inside `func.max()`, producing SQL like `max(<attacker_input>)`. Since `sqlalchemy.text()` treats its argument as literal SQL, any subquery or SQL expression injected through the column name will be executed by the database engine.The `_query_aggr_function()` method (line 704-719, `queryset.py`) only validates field types for `sum` and `avg`, leaving `min` and `max` completely unprotected:To reproduce the attack, you can follow the steps below, using a FastAPI application with SQLite as an example.Note: The PoC consists of two files provided in the attachments — `poc_server.py` (the vulnerable server) and `poc_attacker.py` (the HTTP-based attacker script).@app.get(&quot;/items/stats&quot;)
async def item_stats(
    metric: str = Query(&quot;max&quot;, description=&quot;max or min&quot;),
    column: str = Query(&quot;price&quot;, description=&quot;Column to aggregate&quot;),
):
    &quot;&quot;&quot;Return aggregate statistics for items.&quot;&quot;&quot;
    if metric == &quot;max&quot;:
        result = await Item.objects.max(column)
    elif metric == &quot;min&quot;:
        result = await Item.objects.min(column)
    else:
        return {&quot;error&quot;: &quot;Unsupported metric&quot;}
    return {&quot;metric&quot;: metric, &quot;column&quot;: column, &quot;result&quot;: result}
</code></pre>Table | Data
-- | --
categories | Electronics
items | Laptop ($999.99), Phone ($699.99), Tablet ($449.99), Monitor ($329.99)
admin_users | root / Sup3r$ecretP@ss! / ak-9f8e7d6c5b4a3210-prod
  | deploy-bot / ghp_Tx7KmR29vLp4QzN1bWcA3sYjDf80Ue5Xoi / ak-1a2b3c4d5e6f7890-ciInstall dependencies:The vulnerable server (`poc_server.py`) is based on the **official ormar FastAPI example** ([[ormar/examples/fastapi_quick_start.py](https://github.com/collerek/ormar/blob/master/examples/fastapi_quick_start.py)](https://github.com/collerek/ormar/blob/master/examples/fastapi_quick_start.py)). The only modification is the addition of a `/items/stats` endpoint — a common pattern for applications that provide aggregate statistics. This demonstrates that the vulnerability is easily triggered by natural API design.The server defines three models:`Category` and `Item` — from the official ormar example (unchanged)`AdminUser` — simulates internal data (e.g., an admin_users table) that should NOT be accessible through the public APIThe vulnerable endpoint:The database contains:| Table | Data |
| --- | --- |
| `categories` | Electronics |
| `items` | Laptop ($999.99), Phone ($699.99), Tablet ($449.99), Monitor ($329.99) |
| `admin_users` | root / Sup3r$ecretP@ss! / ak-9f8e7d6c5b4a3210-prod |
|  | deploy-bot / ghp_Tx7KmR29vLp4QzN1bWcA3sYjDf80Ue5Xoi / ak-1a2b3c4d5e6f7890-ci |The `admin_users` table is **NOT** exposed via any API endpoint.The attack stepsThe PoC requires two terminals:**Terminal 1** — Start the vulnerable server:**Terminal 2** — Run the attacker script:The attacker script (`poc_attacker.py`) sends HTTP requests to the running server. It has **NO prior knowledge** of the database schema — all information is discovered through the injection. The attacker executes 6 progressive attack stages through the single `/items/stats` endpoint.Principle of vulnerability exploitation1. The attacker confirms injection by sending an arithmetic expressionThe attacker sends `GET /items/stats?metric=max&column=1+1`. The data flow is:Generated SQL:The API returns `{"metric":"max","column":"1+1","result":2}`, confirming that the arithmetic expression was evaluated as SQL.2. The attacker enumerates database tablesThe attacker injects a subquery to read `sqlite_master`:Which internally calls:Generated SQL:The API returns `categories,admin_users,items`, revealing the hidden `admin_users` table.3. The attacker extracts the schema of the target tableThe API returns the full `CREATE TABLE` statement, revealing column names: `username`, `password`, `api_key`.4. The attacker dumps all credentials in a single queryGenerated SQL:The API returns all credentials:5. Blind boolean-based extraction (when results are not directly visible)Even if the API does not return query results directly, the attacker can use boolean-based blind injection to extract data character by character using binary search:Which internally calls:By iterating over each position with binary search, the full password `Sup3r$ecretP@ss!` is extracted in approximately 113 HTTP requests (16 characters x ~7 binary search steps).6. The attacker extracts the production API keyThe API returns: `ak-9f8e7d6c5b4a3210-prod`All data was extracted through a single public API endpoint using only unauthenticated GET requests.The complete POCpoc_server.py (Vulnerable Server)Based on the official ormar FastAPI example ([[fastapi_quick_start.py](https://github.com/collerek/ormar/blob/master/examples/fastapi_quick_start.py)](https://github.com/collerek/ormar/blob/master/examples/fastapi_quick_start.py)):poc_attacker.py (Attacker Script)Vulnerability ImpactThis attack allows an unauthenticated user to read the entire database contents. Any API endpoint that passes user-controlled input to `Model.objects.min()` or `Model.objects.max()` becomes a full SQL injection entry point.The attack is confirmed to work with the following database backends:SQLite (via aiosqlite)PostgreSQL (via asyncpg) — subquery syntax is identicalMySQL (via aiomysql) — subquery syntax is compatible**Realistic attack scenarios include:****REST APIs** with user-selectable aggregate fields: `GET /items/stats?column=<input>`**GraphQL resolvers** that accept field names as arguments**Dynamic report generators** where users select columns for aggregationThe vulnerable server in this PoC is based on the **official ormar FastAPI example**, demonstrating that the vulnerability is easily triggered through natural, documented API design patterns. The ormar documentation itself shows this exact usage pattern: `await Book.objects.max(columns=["year"])` ([[ormar aggregations docs](https://collerek.github.io/ormar/queries/aggregations/)](https://collerek.github.io/ormar/queries/aggregations/)).Display of attack resultsTerminal 1 — Start server:
![image](https://github.com/user-attachments/assets/4c8b4a20-75da-4aba-b649-f818e46165dd)
Terminal 2 — Run attacker:
<img width="2004" height="1478" alt="image (1)" src="https://github.com/user-attachments/assets/ae41657b-2730-4fab-ac01-e79acd267bde" />
<img width="1984" height="1500" alt="image (2)" src="https://github.com/user-attachments/assets/cbe0d652-d4d4-458c-998b-e636d6c362a1" />
CVSS v3.1 score: 9.8