Django重置密码漏洞(CVE-2019-19844)复现和分析

首发于安全客

前言

CVE-2019-19844是Django重置密码功能的一个漏洞。Django的密码重置表单使用不区分大小写的查询来获取输入的邮箱地址对应的账号。一个知道邮箱地址和账号对应关系的攻击者可以精心构造一个和该账号邮箱地址不同,但经过Unicode大小写转换后相同的邮箱地址,来接收该账户的密码重置邮件,从而实现账户劫持的攻击目标。1

本文较为详细地记录了该漏洞的复现过程,简要分析了漏洞成因,讨论了攻击所需的条件。

复现

基于Python 3.6.0,Django 3.0.0和MariaDB 10.4.11复现漏洞。

准备环境

首先安装Python 3.6.0和MariaDB 10.4.11,然后安装有漏洞的Django版本3.0.0:

pip install django==3.0.0

全部安完后创建数据库cve_2019_19844_test

MariaDB [(none)]> CREATE DATABASE cve_2019_19844_test;

新建Django项目cve_2019_19844_test

django-admin startproject cve_2019_19844_test

需对项目cve_2019_19844_test的配置文件setting.py做一些修改。将LANGUAGE_CODEzh-hans,这样Django就会显示汉语界面了。修改数据库相关配置为使用MariaDB:

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.mysql',
        'NAME': 'cve_2019_19844_test',
        'USER': 'root',
        'PASSWORD': 'root',
        'HOST': 'localhost',
        'PORT': '3306',
    }
}

在文件末尾添加发送邮件相关的配置,示例如下。

EMAIL_USE_SSL = True
EMAIL_HOST = 'smtp.qq.com'  # 如果是 163 改成 smtp.163.com
EMAIL_PORT = 465
EMAIL_HOST_USER = 'xxx@qq.com' # 帐号
EMAIL_HOST_PASSWORD = 'p@ssw0rd'  # 密码
DEFAULT_FROM_EMAIL = EMAIL_HOST_USER

为使用MariaDB做数据库,还需在__init__.py中添加:

import pymysql
pymysql.install_as_MySQLdb()

接着执行以下命令创建数据表:

python manage.py migrate

然后创建一个用户,用户名是werner,邮箱地址是i@werner.wiki

python manage.py createsuperuser

创建用户

启动Web服务:

python manage.py runserver

在浏览器中访问http://127.0.0.1:8000/admin/,看到如下图所示的登录页面,并没有重置密码的功能。

Django登录页面

Django没有默认开启重置密码功能,从官方文档找到了开启该功能的方法2。我们需要编辑urls.py,引入一些url配置。修改后的urls.py如下所示。

from django.contrib import admin
from django.urls import path
from django.urls import include    # 此行是新增的

urlpatterns = [
    path('admin/', admin.site.urls),
    path('accounts/', include('django.contrib.auth.urls')),    # 此行是新增的
]

然后访问http://127.0.0.1:8000/accounts/password_reset/,看到如下图所示的重置密码页面。

Django重置密码页面

根据文档,Django会生成一个可以重置密码的一次性链接,并把这个链接发到用户的邮箱中。如果邮箱地址不存在,Django不会发送邮件,但仍然显示“密码重置链接已经发送”,以避免攻击者探测一个邮箱地址是否为某个用户所有。

输入邮箱i@werner.wiki测试一下,成功收到了密码重置邮件,如下图所示。

Django重置密码邮件

点击其中的链接就可以重置密码了。至此,环境准备完毕。

复现漏洞

根据漏洞描述我们知道问题出在Unicode大小写转换。Unicode号称万国码,包含各种语言,有些语言的字母在进行大小写转换时就会出现奇怪的现象。如小写德文字母“ß”转换成大写是“SS”,再转换成小写就变成了“ss”3,大小写转换竟然不可逆,甚至连字符数量都发生了变化。在Python中进行测试截图如下。

大小写转换不可逆的示例

在准备环境时我们填写的用户邮箱是i@werner.wiki,刚好土耳其文和阿塞拜疆文中的字母“ı”转换成大写是英文字母“I”,再转换成小写就变成了英文字母“i”。知道以上信息,攻击者就可以发起攻击。首先注册域名werner.wikı(假设这个域名存在),然后搭建邮件服务器,设置邮箱i@werner.wikı,最后在Django重置密码的表单中填入这个邮箱地址,提交后攻击者就可以收到用户werner的密码重置邮件了。如下图所示,Django的确发送了密码重置邮件,但由于收件邮箱的域名无法解析,所以一直处于发送中的状态。

发出密码重置邮件

勉强算是成功复现了漏洞。这里为何不使用ı@werner.wiki呢?因为@werner.wiki使用的邮件系统不支持地址中包含ı。现实中的攻击者常常也会面临这个问题,实际上攻击者很可能无法任意注册和用户同后缀的邮箱,便只能修改邮箱后缀了。在这个例子中顶级后缀wikı是不存在的。但攻击者任然可能成功攻击,比如被攻击的是xxx@baidu.com,攻击者就可以构造xxx@baıdu.com。从阿里云查询到域名baıdu.com还没有被注册。

注册域名

分析

问题代码

通过阅读修复此漏洞的commit4可以找到和该漏洞相关的代码5如下所示:

    def get_users(self, email):
        """Given an email, return matching user(s) who should receive a reset.
        这里的email就是重置密码表单中用户填写的邮箱地址
        """
        active_users = UserModel._default_manager.filter(**{
            '%s__iexact' % UserModel.get_email_field_name(): email,
            'is_active': True,
        })
        return (u for u in active_users if u.has_usable_password())

在我们的复现中UserModel.get_email_field_name()返回值是email,也就是说重置密码功能是通过如下的语句查询用户的:

active_users = UserModel._default_manager.filter(email__iexact=email, is_active=True)

注意在查询邮箱地址时使用了iexact,不区分大小写。我们在Django shell中测试一下,用如下语句确实可以查询到用户:

UserModel._default_manager.filter(email__iexact='i@werner.wikı', is_active=True)

截图如下。

在shell中复现漏洞

那么去掉iexact是否可以呢?测试发现是可以的,截图如下。

区分大小写也可以复现漏洞

这是因为MariaDB数据库默认不区分大小写。我们在创建MariaDB数据库时没有指定COLLATE,便取了默认值utf8_genera_ci,其中ci的含义是case insensitive。若在创建数据库时指定COLLATEutf8_bin(将字符串中的每一个字符用二进制数据存储,区分大小写):

CREATE DATABASE cve_2019_19844_test DEFAULT CHARACTER SET utf8 COLLATE utf8_bin;

再重复上面的测试,发现不管有没有iexact都无法复现漏洞。此外,若是使用sqlite做数据库就无法复现漏洞,而使用PostgreSQL却可以复现漏洞。这么想来这一漏洞的真正成因是数据库特性。

将数据库改回可以复现漏洞的状态,开启MariaDB的SQL日志6,记录到Django实际执行的SQL语句是:

Django实际执行的SQL语句

复制这个语句将它粘贴到MariaDB控制台中执行,发现果然能查到数据,如下图所示。

在控制台中执行SQL语句

MariaDB是运行在Windows上的,一开始我在CMD中登录MariaDB控制台,执行上述SQL语句发现查询不出数据,使用chcp命令将CMD编码改为UTF-8也不行。后来在Linux中登录MariaDB控制台(连接的还是运行在Windows上的同一个MariaDB)测试成功。

总结漏洞成因:Django发送重置密码邮件时会发送到用户输入的邮箱地址,而不是从数据库中查询出的邮箱地址。Django重置密码功能没有特殊处理Unicode字符串。某些数据库(在某种配置下)进行字符串匹配时不区分大小写,会自动进行Unicode大小写转换。Unicode大小写转换不是简单的一对一关系,而是复杂的多对多关系。某些不是英文字母的Unicode字符在进行大小写转换后会变成英文字母,攻击者输入特殊构造的含有这种字符的邮箱地址便可以接收到特定账户的密码重置邮件。

可利用的Unicode大小写转换

似乎若某个Unicode字符本身不是英文字母,但在经过大小写转换后会变成一个或多个英文字母,那么这个Unicode大小写转换就是可以利用的。现在我们想做的是找出所有可利用的Unicode大小写转换。诚然,一个小写字母的大写字母是什么和一个大写字母的小写字母是什么应该由自然语言决定,但在计算机领域,这种转换关系被定义成了几张映射表:一个字母对一个字母的大小写转换映射定义在UnicodeData.txt中,一个字母对多个字母的大小写转换映射定义在SpecialCasing.txt7。遍历这几张映射表,找到了如下表所列的可能可以利用的Unicode大小写转换。

字符 Unicode编码 转换 转后字母 转换后字母编码
ı U+0131 大写 I U+0073
ſ U+017F 大写 S U+0083
ß U+00DF 大写 SS U+0053 U+0053
U+FB00 大写 FF U+0046 U+0046
U+FB01 大写 FI U+0046 U+0049
U+FB02 大写 FL U+0046 U+004C
U+FB03 大写 FFI U+0046 U+0046 U+0049
U+FB04 大写 FFL U+0046 U+0046 U+004C
U+FB05 大写 ST U+0053 U+0054
U+FB06 大写 ST U+0053 U+0054
U+212A 小写 k U+0083

但进一步测试发现,只有前两行,一个字母对一个字母的大写转换是可以利用的。例如把用户邮箱改为ss@werner.wiki,用ß@werner.wiki查询不出来。

一对多映射无法复现漏洞

这应该和MariaDB的底层实现有关,超出了本文范围,按下不表。

如何修复

Django做了两处改动来修复这个漏洞1

  1. 从数据库检索出可能匹配的帐户列表后,再使用专门的Unicode字符串比较函数来比较用户输入的邮箱地址和数据库中检索出的邮箱地址是否相等。这样无论后端使用怎样的数据库都避免了这一漏洞。
  2. 在发送重置密码邮件时,发送到数据库中检索出的邮箱地址,不再发送到用户输入的邮箱地址。这样攻击者就算能绕过Unicode字符串比较函数的检查也无法接收到密码重置邮件。

利用条件

为利用这一漏洞,至少需满足以下条件:

  • Django启用了找回密码功能(默认未启用);
  • 使用的数据库配置为不区分Unicode大小写;
  • 攻击者知道被攻击账户的邮箱地址;
  • 被攻击账户的邮箱地址中要含有字母i或s;
  • 被攻击账户的邮箱系统要支持非ASCII字符的地址且攻击者可以任意注册或可利用的域名存在且没有被注册。

所以在现实世界中这一漏洞可能很难利用,故而危害较低。

总结

原以为这是一个很简单的漏洞,结果花了好几天时间才勉强强复现了这个漏洞,大致搞懂了漏洞成因。

虽然这一漏洞很难利用,但它具有启发意义:若是编程语言中没有特殊处理Unicode字符串而数据库不区分Unicode大小写,那么便可能可以使用ıſ绕过某些安全措施。

更新

(2020年1月4日)在前文中我们说明了Mysql的utf8_genera_ci字符串进行比较时,会忽略大小写,如认为ıiI相等,利用此特性可以实施账号劫持攻击,但实际上Mysql也认为不同语言中的“同一个”字母相同,如认为英语中字母a、瑞典语中字母å、拉脱维亚语中字母ā和立陶宛语中字母ą都相同。一个简单的试验如下图所示。

每个英文字母都有至少一个其他语言中的对应字母8,但可惜的是这一特性无法在Django找回密码中利用,因为像ådmin@werner.wiki这样的邮箱地址是通不过校验的。归根结底是因为Python正则即使设置了re.IGNORECASE也不会认为aå相等,但却会认为ıi相等,å有自己的大写字母是Å,如下图。

发表评论

电子邮件地址不会被公开。 必填项已用*标注

12 + 8 =