作为铁杆Django用户,发现昨天Django进行了更新,且修复了一个SQL注入漏洞。在我印象里这应该是Django第一个SQL注入漏洞,且的确是可能在业务里出现的漏洞,于是进行了分析。
0x01 什么是JSONField {#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:
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中存储了一些文章信息:
{
"title": "Article Title",
"author": "phith0n",
"tags": ["python", "django"],
"content": "..."
}
我要查询作者是phit0n
的所有文章,就可以使用Django的queryset:
Collection.objects.filter(detail__author='phith0n').all()
非常简单,和我们正常的queryset完全一样,只不过这里的detail是一个JSONField,而下划线后的内容代表着JSON中的键名,而不再是常规queryset时表示的"外键"。
同理,如果我想查询所有含有python
这个tag
的文章,可以这样编写queryset:
Collection.objects.filter(detail__tags__contains='django').all()
JSONField的强大让我们能灵活地在关系型数据库与非关系型数据库间轻松地切换,因此在我们的很多业务中都会使用到这个功能。
0x02 SQL注入漏洞何来 {#0x02-sql}
那么,是什么问题导致了这个漏洞?
我们直接看到JSONField的实现:
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/
- Lookup
- Transform
我们以上面给出过的一个例子来说明这两者的区别:
.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语句表示就是:
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
对象:
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语句。这里原本生成的语句应该是:
WHERE (field->'[key_name]') = 'value'
但这里可见,[key_name]
位置的json字段名居然是......字符串拼接!
这就是本漏洞出现的原因。
0x03 如何复现这个漏洞 {#0x03}
分析了原因,复现的方法就呼之欲出了。
根据上面的分析可知,transform是生成SQL查询中"键名"的部分,那么如果我们控制了queryset查询的键名,即可注入任意SQL语句了。
但是熟悉Django的同学也应该知道,Django的queryset使用方法是编写如下查询语句:
.filter(detail__author='phith0n')
这个detail__author
用户是无法控制的,通常只有值才能被控制。
但是如果你参与过pwnhub在2017年的一场比赛,应该记得我当时构造了一种比较特殊的查询方法,ORM注入:
就是如果你能控制filter方法的参数名,就能通过外键的方式来获取其他表的一些敏感信息。
当时的场景就是,开发者把用户传入的整个对象都传入filter函数了:
data = json.loads(request.body.decode())
stu = models.Student.objects.filter(**data).first()
此时,用户即可控制filter的键名,在这种情况下,借助我们这次的漏洞即可完成SQL注入利用。
有的人可能觉得这种场景不是很常见,我们来思考一个更加常见的场景。
0x04 Django-Admin SQL注入漏洞 {#0x04-django-admin-sql}
我们创建一个Django项目并创建一个model,其中包含一个JSONField字段:
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自带的后台管理应用中:
admin.site.register(models.Collection)
此时,进入后台就可以对Collection模型进行管理了。进入列表页面:
此时,我们直接修改GET参数,加入一个查询语句 detail__a%27b=1
:
可见,已注入单引号导致SQL报错。
此时,后端执行的代码其实就是:
Collection.objects.filter(**dict("detail__a'b": '1')).all()
复现这个漏洞其实就是这么简单。原因是,Django-Admin中就支持用户控制queryset的查询键名,我在2017年在微博中说到过这一点,不过当时没有测过JSONField,sad。
总的来说,如果你的应用使用了JSONField,且用户可以进入应用的Django-Admin后台,就可以进行SQL注入。同时,通过Postgresql的一些特性(如命令执行方法),即可getshell。