В статье рассмотрен один из распространненых видов атак называемый Cross-site request forgery (CSRF). На простом примере будет показано как заставить пользователя выполнить действия на чужом незащищенном сайте при помощи своего сайта а также как защитить сайт от такой аттаки в Django Framework.

Вы поймете зачем нужно защищать свои страници от csrf аттак, к чему может привести их незащищенность и всегда ли так важна защита от csrf.

Возьмем надуманный пример: у нас есть сайт который переводит деньги на любой указанный счет, причем делает это без дополнительных подтверждений - то есть залогиненому юзеру разрешено моментально перевести деньги. 

Все настройки в settings - дефолтные.

urls.py:

from django.conf.urls import url
from main.views import mainview, login, logout

urlpatterns = [
    # url(r'^admin/', include(admin.site.urls)),
    url(r'^$', mainview, name="mainview"),
    url(r'^login$', login, name="login"),
    url(r'^logout$', logout, name="logout")
]

views.py:

import logging

from django.core.urlresolvers import reverse
from django.http.response import HttpResponse, HttpResponseRedirect
from django.template.base import Template
from django.template.context import Context
from django.views.decorators.csrf import csrf_exempt

logger = logging.getLogger(__name__)

@csrf_exempt
def mainview(request):
    if request.method == "POST" and \
                    'amount' in request.POST and \
                    'receiver' in request.POST:

        if not request.session.get("auth", False):
            return HttpResponseRedirect(reverse("login"))

        t = Template(
            "Payment success {} $ to {} <br>".format(request.POST['amount'], request.POST['receiver']) +
            "<a href='{% url \"mainview\" %}'>Back</a>"
        )

        resp = t.render(Context({}))
        return HttpResponse(resp)
    else:

        t = Template(
            "Send money: "
            "<form method='POST' action='/'>"
            "  Amount   : <input name='amount' type='number' value=''></input> <br>"
            "  Receiver : <input name='receiver' value=''></input> <br>"
            "  <button>Pay</button>"
            "</form>"
            "{% if is_auth %}"
            "   <a href='{% url \"logout\" %}'>Logout(user)</a>"
            "{% else %}"
            "   <a href='{% url \"login\" %}'>Login</a>"
            "{% endif %}")

        args = {"is_auth": request.session.get("auth", False)}
        resp = t.render(Context(args))
        return HttpResponse(resp)


@csrf_exempt
def login(request):
    if request.method == "POST" \
            and 'name' in request.POST \
            and 'pass' in request.POST:
        if (request.POST['name'], request.POST['pass']) == ("user", "pass"):
            request.session['auth'] = True
            return HttpResponseRedirect(reverse("mainview"))
        else:
            return HttpResponseRedirect(reverse("login"))
    else:
        t = Template(
            "Log in: "
            "<form method='POST' action='/'>"
            "  Name <input name='name'></input> <br>"
            "  Pass <input name='pass'></input> <br>"
            "  <button>Login</button>"
            "</form>")

        args = {}
        resp = t.render(Context(args))
        return HttpResponse(resp)


def logout(request):
    del request.session['auth']
    return HttpResponseRedirect(reverse("mainview"))

У нас тут есть 3 вью для логина, логаута и основной для перевода денег. Для примера мы не используем стандартную систему аутентификации джанго а просто сохраняем в сессию ключ auth равный True , в случае если пользователь ввел единственный в системе логин user и пароль pass

Принцип работы вью login такой - если оно запрашивается методом GET то оно выводит форму с двумя полями ввода и кнопкой. При нажатии на кнопку на этот же урл (в этот же вью) отправляется форма методом POST. Когда view получает POST-запрос он вытаскивает из него логин и пароль, сравнивает с правильными и, если все ок, то пишет в сессию значение auth = True. Соотвтетственно дальше в любом вью можно узнать авторизирован ли пользователь проверив наличие ключа auth равного True в сессии. Если же пользователь ошибся с данными он грубо посылается на повторную попытку ввести логин и пароль без объяснения причин (хотя вообще неплохо бы показать юзеру сообщение что логин или пароль неправильные).

Вью logout вообще простое - оно сразу удаляет ключ auth из сессии.

Главное вью mainview при GET запросе выводит форму для ввода суммы перевода и получателя (например это банковский счет). При пост запросе оно первым делом сразу же проверяет авторизирован ли пользователь - если да, то выводит сообщение об успешной отправке денег. Если нет то жестко отправляет на аутентификацию. 

Если проанализировать это приложение то понятно что вся безопастность базируется на том что отправить деньги может только залогиненный пользователь. А факт того что он залогинен проверяется исходя из сессии. Во встроенной системе аутентификации Django принцип такой же. Сессия привязана к куки установленному в браузер,  поэтому выполнив логин один раз пользователь будет оставаться залогиненным в системе некоторое время (по дефолту 2 недели). Ну или пока не очистит куки и таким образом отвяжется от сессии.

Теперь создадим еще один сайт - сайт хакера с одним только вью:

import logging

from django.http.response import HttpResponse
from django.template.base import Template
from django.template.context import Context

logger = logging.getLogger(__name__)


def hackerview(request):

    t = Template(
        "<form method='POST' action='http://moneysite.local:8000'>"
        "<input hidden name='amount' type='number' value='1000'></input> <br>"
        "<input hidden name='receiver' value='SuperHacker'></input> <br>"
        "<button>Make me happy</button>"
        "</form>"
        )

    args = {}
    resp = t.render(Context(args))
    return HttpResponse(resp)

в urls.py достаточно сделать этот вью главным, точно так же как mainview в прошлом проекте.

Перед запуском сайтов в /etc/hosts (в Windows c:\Windows\System32\drivers\etc\hosts) нужно добавить

127.0.0.1       moneysite.local
127.0.0.1       hackersite.local

чтобы домены резолвились локально в 127.0.0.1 , на котором мы запустим сайты.

Первый сайт запускаем на moneysite.local:8000:

 

manage.py runserver moneysite.local:8000

 

Сайт хакера (второй) аналогично запускаем на hackersite.local:8888 

Сначала заходим в раузере на http://moneysite.local:8000 , авторизируемся и можем попробовать перевести деньги, после чего можем выйти.

Теперь заходим на http://hackersite.local:8888/ , представляем что это сайт хакера а мы пользователь  сайта платежа и нажимаем кнопку. 

Как видим результат:

Итого - хакер имея свой сайт, заставив пользователя нажать на кнопку на этом своем сайте, перевел деньги на какой-нибудь свой счет. При этом ему не нужно иметь исходники. Все что ему нужно дак это понять кикие POST-данные отправить на какой URL для того или иного действия.

Для этого ему всего лишь нужно получить аккаунт на вашем сайте (moneysite.local:8000), а это нетрудно сделать если на нем есть открытая регистрация. Зарегистрировавшись он может просто открыть консоль веб-разработчика в браузере (к примеру в хроме) и нажимая на кнопки/ссылки изучить запрос который он хочет выполнить:

С точки зрения безопастности причина проста, мы то проверяли в POST-е аутентифицирован ли юзер - ответ будет да, все верно, куки сессии установлено (браузер же сохраняет и отправляет куки для домена, по этому при попытке "запостить" что-то на домен moneysite.local:8000 браузер любезно вложил в запрос куки сессии котооле он будет хранить у себя 2 недели с момента входа). Djangо достала сессию по айди из куки, поняла что юзер аутентифицирован, и все сделала. Проблема в том что мы не знаем что юзер перешел на сайт именно с нашего сайта а не с чужого.

Конечно, вы скажете что пример глупый - любая платежная система должна выполнять поддтверждение, хотя бы через обычный диалог "Да" / "Отмена", а еще лучше через СМС. Но теперь представьте что на больших сайтах есть сотни POST-запросов маленьких и больших, и выполняя определенные их них на чужих компьютерах можно добиться весьма интересных последствий в т.ч. можно запросто скомпрометировать сервис.

Итак подитожем:

Как "взломать" пользователя используая CSRF-уязвимость

Представьте что вы - хакер.

1. Выберете уязыимый сайт. На этом сайте не должно быть защиты от CSRF которую мы рассмотрим ниже, как ее распознать вы узнаете дальше.

2. Выберете жертву-пользователя этого сайта. То есть вы должны точно знать что пользователь пользуется этим сайтом и знать/предпологать что он там уже аутентифицирован.

Warning

ПС: первые два пункта могут быть переставлены - вы можете отталкиваться от конкретной жертвы-пользователя и выясняете техничискими методами или методами социальной инженерии(чаще) какими сайтами пользуется человек, отсеиваете среди них те на которых есть защита от CSRF.

Возможен и вариант более универсальный: для аттаки большого колличества пользователей-жертв вы выбираете какой-нибудь очень популярный сайт (например социальную сеть), убедившись что в ней нет защиты вы пишите сайт который якобы предназначен для какого-то взаимодействия с этим сайтом (скачивание музыки, фоток, накрутка  лайков etc). Таким образом, раскрутив свой сайт, вы получаете на него аудиторию юзеров - потенциальных жертв которые точно пользуются уязвимым сайтом, и вероятнее всего они остаются аутентифицированными на уязыимом сайте длительное время.

3. Зарегистрируйтесь на уязвимом сайт и изучите запросы которые выполняются без подтверждения.

4. Используйте эти данные для того что бы составить форму у себя на сайте. В сложной ситуации когда вам необходимо сразу два или более запросов вы можете предполагать что пользователи потом вернуться на ваш сайт и разбить действия на несколько этапов, сгенерировать несколько страниц и вести учет на какие урлы конкретный пользователь уже отправил данные а на какие нет.

5. Промотивируйте пользователя выполнить действия на вашем сайте которые приведут к выполнению запросов на уязвимом сайте. По возможности желательно позаботиться о том чтобы максимально скрыть от пользвоателя (или оправдать) факт перехода на другой сайт. На url сверху многие почти не смотрят а вот визуальную страницу может заметить. Для решения можно как минимум открывать сайт-жертву в новой вкладке (<form target="_blank"), как максимум сделать свой сайт немного похожим на жертву чтобы не было контрастного отличия.

6. Пользователи-жертвы сами на своих аутентифицированных браузерах исполнят все что вам нужно. Вам даже не нужно знать логины/пароли сайтов.

Как защититься от CSRF

Думаю самое время понять как защитить наш сайт от подобных аттак в Django.

Самый надежный способ сделать это состоит в том что нам нужно каким-то образом отличить данные которые отправляет страница хакера от того что отправляет наша собственная страница (Ведь страници это то что 100%ов будет отличаться).

Для этого для каждой новой сессии можно сгенерировать рандомный ключ (называется обычно что-то вроде CSRFToken) на сервере и добавить его в форму в html в виде невидимого поля, чтобы пользователь это поле потом отправил нам на сервер обратно вместе с полезными данными.

Но получая этот ключ мы должны подтвердить что это именно наш ключ, который мы отправили при гет-запросе формы. Для этого мы можем использовать куки. Мы устанавливаем куки в браузер с этим же ключем для каждой сессии. Теперь если хакер захочет подделать запрос то в его запросе сгенерированный нами ключ всетаки придет в куках на сервер (как я говорил, потому что браузер отправляет куки привязываясь к домену, на каждом запросе) а вот отправить нужный токен в поле формы он уже не сможет! Даже если он узнает что такое поле есть, узнать его значение он не сможет потому что оно случайно для каждой сессии. Вытащить куки для чужого сайта со своего в безопастном браузере конечно же невозможно поэтому хакер ни как не узнает какое число должен отправить в форме. 

Q&A

1Q: Почему бы просто напросто не проверять реферрер, ведь тогда мы узнаем откуда пришел юзер с нашего сайта или с сайта хакера?

1A: Это действительно проще и очевиднее но к сожалению это тоже не безопастно, см https://intsystem.org/security/stripping-referer-in-redirect/

2Q: Почему нужно испольовать рандомное число для каждой сессии? Мы же можем прописать в форму постоянное число и тогда не понадобится устанавливать куки, а мы сразу будем сравнивать на сервере поле с формы с константой.

2A: Тогда хакер изучая ваш сайт и запросы сможет увидеть это число и поняв что оно не меняется сам использует его же, чем пройдет валидацию. Но вообще обычно хакеры изучая сайт увидив слово вроде CSRFToken или просто что-то невнятно-рандомное сразу понимают что сайт защищен от CSRF аттак. Только самый отчаявшийся хакер будет перебирать сессии в надежде что токен не перегенерируется. Кроме того некоторые сайты используют и более "жесткий" вариант при котором токн генерируется не на сессию а вообще каждый раз при выдаче формы. Он более затратный и в целом его использование не оправдано если только вы не допускаете утечек токена каким-то глупым образом.

3Q: Зачем мы также сделали безопастной отправку данных формы логина? Защищать ее вроде как бессмысленно хакер же все равно не знает логин и пароль пользователя.

3A: Представьте что вы пользователь-жертва. Хакер знает ваш логин и зарегистрировал визуально похожий. Большинство сайтов посчитают логины Tom и Tоm абсолютно разными, потому что в одном латинская буква "o" а в другом киррилическая :

>>> (list("Tom".encode('UTF-8')), list("Tоm".encode("UTF-8")))
([84, 111, 109], [84, 208, 190, 109])

Киррелическая как видим в юникоде занимает 2 байта. И конечено же как Python так и СУБД сравнивая эти логины вернут False. Дак вот, дальше хакер провацирует или подбирает момент когда вы выходите из  сайта, а потом осуществляет аттаку на форму логина, только с логином и паролем, который зарегистрировал он сам. Вы видите что вошли под своим логином поскольку он похож, и работаете с сайтом дальше как ни в чем не бывало, оставляете на сайте приватную информацию, которую хакер потом получит.

Пора перейти к практике. В Django практически все вышеописанное (и даже больше) надежно сделано за нас по-этому нам надо поменять совсем немного. На данный момент у нас есть два уязвимых вью с декораторами csrf_exempt. Эти декораторы принуждают джанго не проверять токены. Давайте исправим это и сделаем все что нужно для защиты у обоих вью.

import logging

from django.core.urlresolvers import reverse
from django.http.response import HttpResponse, HttpResponseRedirect
from django.template.base import Template
from django.template.context import Context, RequestContext
from django.views.decorators.csrf import csrf_exempt

logger = logging.getLogger(__name__)

# !!! убрали декоратор csrf_exempt
def mainview(request):
    if request.method == "POST" and \
                    'amount' in request.POST and \
                    'receiver' in request.POST:

        if not request.session.get("auth", False):
            return HttpResponseRedirect(reverse("login"))

        t = Template(
            "Payment success {} $ to {} <br>".format(request.POST['amount'], request.POST['receiver']) +
            "<a href='{% url \"mainview\" %}'>Back</a>"
        )

        resp = t.render(Context({}))
        return HttpResponse(resp)
    else:

        t = Template(
            "Send money: "
            "<form method='POST' action='/'>"
            "{% csrf_token %}"  # !!! тут добавилось это
            "  Amount   : <input name='amount' type='number' value=''></input> <br>"
            "  Receiver : <input name='receiver' value=''></input> <br>"
            "  <button>Pay</button>"
            "</form>"
            "{% if is_auth %}"
            "   <a href='{% url \"logout\" %}'>Logout(user)</a>"
            "{% else %}"
            "   <a href='{% url \"login\" %}'>Login</a>"
            "{% endif %}")

        args = {"is_auth": request.session.get("auth", False)}
        resp = t.render(RequestContext(request, args))  # !!! Обратите внимание что тут уже RequestContext а не просто Context
        return HttpResponse(resp)


# !!! убрали декоратор csrf_exempt
def login(request):
    if request.method == "POST" \
            and 'name' in request.POST \
            and 'pass' in request.POST:
        if (request.POST['name'], request.POST['pass']) == ("user", "pass"):
            request.session['auth'] = True
            return HttpResponseRedirect(reverse("mainview"))
        else:
            return HttpResponseRedirect(reverse("login"))
    else:
        t = Template(
            "Log in: "
            "<form method='POST' action='/'>"
            "  {% csrf_token %}"  # !!! тут добавилось это
            "  Name <input name='name'></input> <br>"
            "  Pass <input name='pass'></input> <br>"
            "  <button>Login</button>"
            "</form>")

        args = {}
        resp = t.render(RequestContext(request, args)) # !!! Обратите внимание что тут уже RequestContext а не просто Context
        return HttpResponse(resp)


def logout(request):
    del request.session['auth']
    return HttpResponseRedirect(reverse("mainview"))

В каждом вью мы поменяли три вещи:

1. Убрали декоратор csrf_exempt чтобы Django начал сравнивать токены.

2. Мы добавили в темплейты код {% csrf_token %} который выводит число в качестве скрытого инпута:

3. Поменяли Context на RequestContext. Без этого токен не зарендерится тегом {% csrf_token %} . Как известно контекст - это объект со словарем переменная:значение который попадает в темплейт при рендере и потом используется для подстановки переменных в темплейттегах. Обычный Context, который мы использовали ранее, добавляет в себя значения которые передаются ему в конструктор (args). А RequestContext к этим значениям добавляет еще и данные из request. Добавляет он их исходя из настройки context_processors но даже если она не прописана в settings.py как у нас, он все равно добавляет туда данные из контекст процессора django.template.context_processors.csrf, что обязательно нужно при рендере тега {% csrf_token %} . Стоит сказать что вместо использования RequestContext в коде разных программистов можно встретить другие способы передачи токена. Например args.update(csrf(request)). который записывает токен для текущей сессии в дикт, либо же django.middleware.csrf.get_token(request) который просто возвращает его. Возможно, это может понадобиться если вы программируете на каком-нибудь single-page фреимворке, но в другом случае (по моему мнению), использование RequestContext более удобное.

Запустив сайт вы можете убедиться в том, что функциональность сайта не нарушена, зато при попытке нажать на кнопку на сайте хакера вы увидете абсолютно логичную ошибку:

Forbidden (403)
CSRF verification failed. Request aborted.
Help
Reason given for failure:

CSRF token missing or incorrect.

 

Post в XMLHttpRequest

Обычный пост от формы с типом Content-Type:application/x-www-form-urlencoded, использованный ранее, обязательно нуждается в защите как было показано выше.

Что касается POST с другими форматами например JSON и XML, которые могут быть отправлены на сервер только с помощью XMLHttpRequest, то они на первый взгляд в защите не нуждаются, потому что такие запросы уже защищены политикой SOP (Same-origin policy). Подробно рассматривать не будем но суть в том что сайт с доменом proto://xx.yyy[:zzz] не сможет выполнить запрос на сайт любого другого домена. Важна именно тройка протокол, домен, порт. То есть запросы на одинаковые домены с разными протоколами http и https будут блокироваться SOP. Поэтому аттака становится невозможной впринципе. 

Но есть способ чтобы отправить форму (обычные формы не защищены SOP так как они существовали за долго до появления SOP) с любым типом из обычной HTML используя аттрибут ENCTYPE. Такой запрос можно отличить от валидного анализируя запрос специальным образом но если это не риализовано на сервере то аттаку сделать все же удасться.

Django, вероятно, чтобы исключить подобные мелкие моменты, все же требует свой токен для всех типов Content-type. Только вот обновлять постоянно токен в данных как написано в документации не всегда удобно. По этому Django в XMLHttpRequest позволяет прописать токен не в дату а в хедер запроса с названием X-CSRFToken. Это удобней лишь потому что некоторые фреимворки вроде jQuery позволяют автоматический настроить отсылку хедера для каждого запроса.

Чтобы реализовать написанное в коде вы должны вытащить значение установленного токена из куки (домен ваш поэтому у себя вы можете такое сделать). Потом вы настраиваете ваш фреимворк что бы он отправлял хедер с куки каждый раз. Вот код из документации что бы не искать:

// using jQuery          
function getCookie(name) {
    var cookieValue = null;
    if (document.cookie && document.cookie !== '') {
        var cookies = document.cookie.split(';');
        for (var i = 0; i < cookies.length; i++) {
            var cookie = jQuery.trim(cookies[i]);  
            // Does this cookie string begin with the name we want?
            if (cookie.substring(0, name.length + 1) === (name + '=')) {
                cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
                break;
            }
        }
    }
    return cookieValue;
}
var csrftoken = getCookie('csrftoken');

function csrfSafeMethod(method) {
    // these HTTP methods do not require CSRF protection
    return (/^(GET|HEAD|OPTIONS|TRACE)$/.test(method));
}
$.ajaxSetup({
    beforeSend: function(xhr, settings) {
        if (!csrfSafeMethod(settings.type) && !this.crossDomain) {
            xhr.setRequestHeader("X-CSRFToken", csrftoken);
        }
    }
});

По факту же с точки зрения FrontEnd-разработчика такой подход не сильно оправдан учитывая что многие вообще не используют jQuery и все равно им придется писать функцию которая будет устанвливать токен для каждого запроса. А функцие getCookie вооще не нужен jQuery только из-за функции trim, которая может быть заменена ванильной функцией .trim() у строки, которая есть в не самых древних браузерах. Либо даже если нужна поддержка старых браузеров, функция легко заменяется регекспом:

if(!String.prototype.trim) {  
  String.prototype.trim = function () {  
    return this.replace(/^\s+|\s+$/g,'');  
  };  
} 

 

Для разрешения SOP можно использовать CORS-запросы

Иногда бывают ситуации когда все же необходимо выполнить запрос на домен отличный от домена страници с которой выполняется код. Это применяется в сервисах авторизации, например по протоколу OAuth2, который используется вчастности в этом блоге. Вы можете войти через учетные записи Google или Вконтакте чтобы оставлять комментарии от своего имени не регистрируясь на сайте. Очевидно что для этого нужно взаимодействовать с провайдерами авторизации которыми есть гугл и ВК.

Чтобы произвести запрос игнорируя SOP нужно использовать запрос CORS (Cross-origin resource sharing). Эта политика в отличаи от SOP наоборот позволяет выполнять кросс запросы. Такие запросы должны быть разрешены на сервере, и клиент явно делая XHR должен это указать. Большинство браузеров позволяют выполнить CORS запрос при помощи обычного объекта XMLHttpRequest (который используют Vanilla-программисты, либо который используется внутри jQuery или других фреимворков). То есть составляя запрос JS программист все делает как обычно, такой запрос ничем не будет отличаться от обычного. Единственное что при необходимости прикрепить куки к запрашиваемуму домену (именно куки сохраненные для запрашиваемого домена), то в объект XHR нужно установить аттрибут withCredentials = True. Это не хедер в запросе а просто аттрибут для браузера. Что бы браузер когда будет делать запросы GET, POST и т.д. знал что к ним нужно прикреплять куки. Если withCredentials не устанавливать, куки прикреплятся не будут, что и нужно не всегда.

Как это работает изнутри:

Первый раз перед любым  СORS запросом браузер отправляет сначала запрос OPTIONS. В терминологии эти запросы называют preflighed. Вот вам реальный пример того что я увидел перед отправкой CORS GET запроса:

 

То есть сервер получив OPTIONS, выставляет в ответ Access-Control-Allow-Origin (ACAO) с перечнем списка всех ориджинов которые могут выполнять запрос.

Access-Control-Allow-Origin: http://www.foo.com http://www.bar.com http://www.lol.com 

Тут же сервер может использовать wildcard типо *. Но понятно что из-зв понятий безопастности такое делать нельзя, разьве что на дебаг серверах там или как часть большего домена, типа *.четатам.com

Получая ответ, браузер понимает что этому домену можно выполнять запрос, только тогда посылает  обычный запрос на сервер.

Более подробно про CORS и виді запросов в которіх он применяется смотрите тут

Заключение

Безопастность - вещь о которой можно заботиться вечно, латая во фреимворке или проекте кучу мелких дыр. Если вы прочитаете всю документацию по CSRF https://docs.djangoproject.com/en/1.10/ref/csrf/#ajax вы поймете что на самом деле все немного сложнее чем описанно тут, что при генерации токена используется соль и что в Django предусмотрена защита еще от многих экзотических случаев в дополнение к базовым методам взлома описанным здесь. Также там есть интерестная инфа про случаи аттаки с поддоменов на топ домен.

Надеюсь что информации в статье достаточно для понимания аттаки и тому как с ней бороться, но для более подробного изучения лучше обратится к исходному коду и документации.