Django JSONField SQL注入漏洞(CVE-2019-14234)分析与影响

2020-10-15 14:25:27 浏览数 (1)

作为铁杆Django用户,发现昨天Django进行了更新,且修复了一个SQL注入漏洞。在我印象里这应该是Django第一个SQL注入漏洞,且的确是可能在业务里出现的漏洞,于是进行了分析。

0x01 什么是JSONField

Django是一个大而全的Web框架,其支持很多数据库引擎,包括Postgresql、Mysql、Oracle、Sqlite3等,但与Django天生为一对儿的数据库莫过于Postgresql了,Django官方也建议配合Postgresql一起使用。

相比于Mysql,Postgresql支持的数据类型更加丰富,其对JSON格式数据的支持也让这个关系型数据库拥有了NoSQL的一些特点。在Django中也支持了Postgresql的数据类型:

  • JSONField
  • ArrayField
  • HStoreField

这三种数据类型因为都是非标量,且都能用JSON来表示,我下文就用JSONField统称了。

我们可以很简单地在Django的model中定义JSONField:

代码语言:javascript复制
from django.db import models
from django.contrib.postgres.fields import JSONField


class Collection(models.Model):
    name = models.CharField(max_length=128, default='default name')
    detail = JSONField()

    def __str__(self):
        return self.name

然后,我们在视图中,就可以对detail字段里的信息进行查询了。

比如,detail中存储了一些文章信息:

代码语言:javascript复制
{
  "title": "Article Title",
  "author": "phith0n",
  "tags": ["python", "django"],
  "content": "..."
}

我要查询作者是phit0n的所有文章,就可以使用Django的queryset:

代码语言:javascript复制
Collection.objects.filter(detail__author='phith0n').all()

非常简单,和我们正常的queryset完全一样,只不过这里的detail是一个JSONField,而下划线后的内容代表着JSON中的键名,而不再是常规queryset时表示的“外键”。

同理,如果我想查询所有含有python这个tag的文章,可以这样编写queryset:

代码语言:javascript复制
Collection.objects.filter(detail__tags__contains='django').all()

JSONField的强大让我们能灵活地在关系型数据库与非关系型数据库间轻松地切换,因此在我们的很多业务中都会使用到这个功能。

0x02 SQL注入漏洞何来

那么,是什么问题导致了这个漏洞?

我们直接看到JSONField的实现:

代码语言:javascript复制
class JSONField(CheckFieldDefaultMixin, Field):
    empty_strings_allowed = False
    description = _('A JSON object')
    default_error_messages = {
        'invalid': _("Value must be valid JSON."),
    }
    _default_hint = ('dict', '{}')

    # ...

    def get_transform(self, name):
        transform = super().get_transform(name)
        if transform:
            return transform
        return KeyTransformFactory(name)

JSONField继承自Field,其实Django中所有字段都继承自Field,其中定义了get_transform函数。

编写过自定义Field的同学应该知道,Django中有以下两个概念:

如果你不知道,可以参考一下这篇文档:https://docs.djangoproject.com/en/2.2/ref/models/lookups/

  1. Lookup
  2. Transform

我们以上面给出过的一个例子来说明这两者的区别:

代码语言:javascript复制
.filter(detail__tags__contains='django')

这个queryset中,__tags是transform,而__contains是lookup。

他们的区别是:transform表示“如何去找关联的字段”,lookup表示“这个字段如何与后面的值进行比对”。

正常情况下,transform一般用来在通过外键连接两个表,比如.filter(author__username='phith0n')可以表示在author外键连接的用户表中,找到username字段;lookup很多时候是被省略的,比如.filter(username='phith0n')表示找到用户名为phith0n的用户,这个被省略的lookup其实就是__exact

用伪SQL语句表示就是:

代码语言:javascript复制
WHERE `users`[1] [2] 'value'

位置[1]是transform,位置[2]是lookup,比如transform是寻找外键表的字段username,lookup是exact(也就是等于),那么生成的SQL语句就是WHERE users.username = 'value'

那么,在JSONField中,lookup实际上是没有变的,但是transform从“在外键表中查找”,变成了“在JSON对象中查找”,所以自然需要重写get_transform函数。

get_transform函数应该返回一个可执行对象,你可以理解为工厂函数,执行这个工厂函数,获得一个transform对象。

JSONField用的工厂函数是KeyTransformFactory类,其返回的是KeyTransform对象:

代码语言:javascript复制
class KeyTransformFactory:
    def __init__(self, key_name):
        self.key_name = key_name

    def __call__(self, *args, **kwargs):
        return KeyTransform(self.key_name, *args, **kwargs)

class KeyTransform(Transform):
    operator = '->'
    nested_operator = '#>'

    def __init__(self, key_name, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.key_name = key_name

    def as_sql(self, compiler, connection):
        key_transforms = [self.key_name]
        previous = self.lhs
        while isinstance(previous, KeyTransform):
            key_transforms.insert(0, previous.key_name)
            previous = previous.lhs
        lhs, params = compiler.compile(previous)
        if len(key_transforms) > 1:
            return "(%s %s %%s)" % (lhs, self.nested_operator), [key_transforms]   params
        try:
            int(self.key_name)
        except ValueError:
            lookup = "'%s'" % self.key_name
        else:
            lookup = "%s" % self.key_name
        return "(%s %s %s)" % (lhs, self.operator, lookup), params

Django的model最本质的作用是生成SQL语句,所以transform和lookup都需要实现一个名为as_sql的方法用来生成SQL语句。这里原本生成的语句应该是:

代码语言:javascript复制
WHERE (field->'[key_name]') = 'value'

但这里可见,[key_name]位置的json字段名居然是……字符串拼接!

这就是本漏洞出现的原因。

0x03 如何复现这个漏洞

分析了原因,复现的方法就呼之欲出了。

根据上面的分析可知,transform是生成SQL查询中“键名”的部分,那么如果我们控制了queryset查询的键名,即可注入任意SQL语句了。

但是熟悉Django的同学也应该知道,Django的queryset使用方法是编写如下查询语句:

代码语言:javascript复制
.filter(detail__author='phith0n')

这个detail__author用户是无法控制的,通常只有值才能被控制。

但是如果你参与过pwnhub在2017年的一场比赛,应该记得我当时构造了一种比较特殊的查询方法,ORM注入:

就是如果你能控制filter方法的参数名,就能通过外键的方式来获取其他表的一些敏感信息。

当时的场景就是,开发者把用户传入的整个对象都传入filter函数了:

代码语言:javascript复制
data = json.loads(request.body.decode())
stu = models.Student.objects.filter(**data).first()

此时,用户即可控制filter的键名,在这种情况下,借助我们这次的漏洞即可完成SQL注入利用。

有的人可能觉得这种场景不是很常见,我们来思考一个更加常见的场景。

0x04 Django-Admin SQL注入漏洞

我们创建一个Django项目并创建一个model,其中包含一个JSONField字段:

代码语言:javascript复制
class Collection(models.Model):
    name = models.CharField(max_length=128, default='default name')
    detail = JSONField()

    def __str__(self):
        return self.name

然后在admin.py里,我们将其加入到Django-Admin,也就是Django自带的后台管理应用中:

代码语言:javascript复制
admin.site.register(models.Collection)

此时,进入后台就可以对Collection模型进行管理了。进入列表页面:

此时,我们直接修改GET参数,加入一个查询语句 detail__a'b=1

可见,已注入单引号导致SQL报错。

此时,后端执行的代码其实就是:

代码语言:javascript复制
Collection.objects.filter(**dict("detail__a'b": '1')).all()

复现这个漏洞其实就是这么简单。原因是,Django-Admin中就支持用户控制queryset的查询键名,我在2017年在微博中说到过这一点,不过当时没有测过JSONField,sad。

总的来说,如果你的应用使用了JSONField,且用户可以进入应用的Django-Admin后台,就可以进行SQL注入。同时,通过Postgresql的一些特性(如命令执行方法),即可getshell。

0 人点赞