# ormar is vulnerable to SQL Injection through aggregate functions min() and max()

ormar ORM's min()/max() aggregates pass user column strings into sqlalchemy.text() unsanitized, letting unauthenticated API callers inject subqueries and dump the whole database.

- Payout: Research
- Severity: critical
- Program: ormar
- Found by: AAtomical
- Disclosed: 2026-02-23
- CVE: CVE-2026-26198
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-xxh2-68g9-8jqr
- Library page: http://bugbountylibrary.com/r/ghsa%3AGHSA-xxh2-68g9-8jqr
- Tags: area:oss domain:oss impact:ato impact:data-leak impact:pii-leak kind:research platform:ghsa sev:critical tech:python technique:blind technique:source-review vuln:sqli

**Target:** Python ORM aggregate query builder (QuerySet.min/max, SelectAction.get_text_clause)
**Root cause:** SelectAction.get_text_clause() interpolates the user-supplied field_name into sqlalchemy.text() as raw SQL; _query_aggr_function() only applies the is_numeric field check to sum/avg, so min/max accept arbitrary strings.
**Steps:**
1. Install ormar, databases, aiosqlite, fastapi, uvicorn, httpx and run a FastAPI app exposing `Item.objects.max(column)` with `column` from a query param.
2. Confirm injection: `GET /items/stats?metric=max&column=1+1` returns result 2, proving SQL evaluation.
3. Enumerate tables: `column=(SELECT GROUP_CONCAT(name) FROM sqlite_master WHERE type='table')` returns `categories,admin_users,items`.
4. Extract schema: `column=(SELECT sql FROM sqlite_master WHERE name='admin_users')` reveals username/password/api_key columns.
5. Dump credentials: `column=(SELECT GROUP_CONCAT(username || ' | ' || password || ' | ' || api_key, CHAR(10)) FROM admin_users)`.
6. Blind fallback: `column=CASE WHEN UNICODE(SUBSTR((SELECT password FROM admin_users WHERE username='root'),1,1))>83 THEN 1 ELSE 0 END` binary-searches chars (~113 requests).
**How found:** Reporter read the aggregate code path and noticed sum/avg are guarded by an is_numeric check while min/max skip it, then traced field_name into sqlalchemy.text() and confirmed with an arithmetic probe.
**Exploitation:** Unauthenticated GET requests to an endpoint that forwards a user-controlled column name into Model.objects.max()/min(); the injected subquery is wrapped as max(<subquery>) and its result returned in the JSON response, yielding full cross-table data exfiltration in a single request, or boolean-blind extraction when results aren't echoed.
**Escalation:** Single endpoint used for staged recon: arithmetic probe → table enumeration via sqlite_master → schema extraction → bulk credential dump → blind extraction fallback → API key theft.
**Insight:** When an ORM validates one subset of aggregate/ordering functions but not siblings, diff the guard conditions across the whole family — the unguarded sibling (min/max vs sum/avg) is the injection point, and any endpoint exposing column names as params is the trigger.

## Proof of concept (copied exactly from the original report)

**Vulnerable FastAPI endpoint passing column to max/min**

```
@app.get("/items/stats")
async def item_stats(
    metric: str = Query("max", description="max or min"),
    column: str = Query("price", description="Column to aggregate"),
):
    """Return aggregate statistics for items."""
    if metric == "max":
        result = await Item.objects.max(column)
    elif metric == "min":
        result = await Item.objects.min(column)
    else:
        return {"error": "Unsupported metric"}
    return {"metric": metric, "column": column, "result": result}
```

