CSRF: Векторы атаки и механизмы защиты

Article
Author: mugabe

Cross-Site Request Forgery (CSRF) — это вид атаки, при которой злоумышленник обманом заставляет браузер аутентифицированного пользователя отправить нежелательный HTTP-запрос к веб-приложению. В отличие от XSS, целью CSRF является не кража данных, а выполнение действий от имени пользователя.

Фундаментальная причина уязвимости заключается в том, что браузеры автоматически прикрепляют аутентификационные данные (прежде всего Cookies) к запросам на соответствующие домены, независимо от того, с какого ресурса был инициирован запрос.

Векторы атаки #

Для эксплуатации этой уязвимости злоумышленник создаёт страницу, которая незаметно отправляет запрос на уязвимый сервер. Этой странице не нужно быть подделкой уязвимого сайта – это может быть статья, новость – что угодно. Главное – заставить пользователя посетить её, атака будет осуществлена незаметно, и может не вызывать никаких подозрений.

Рассмотрим способы реализации этой атаки.

Атака через HTML форму #

Самый простой и распространённый вариант – обычная HTML форма.

Например, на сайте нашего ЦУП есть форма запуска ракеты, доступная только руководителю полётов:

 1<!-- Форма на сайте mission-control.internal -->
 2<form
 3  action="/launch"
 4  method="POST"
 5>
 6  <select name="launchpad">
 7    <option value="lc-39a">LC-39A</option>
 8    <option value="lc-39b">LC-39B</option>
 9    <option value="slc-40">SLC-40</option>
10    <option value="slc-41">SLC-41</option>
11  </select>
12  <button type="submit" class="big-red-button">
13    ПУСК
14  </button>
15</form>

Форма обрабатывается бэкендом и инициирует запуск ракеты на указанной площадке:

 1func launchHandler(w http.ResponseWriter, r *http.Request) {
 2  // Проверка сессии 
 3  sessionCookie, _ := r.Cookie("session_id")
 4  session := getSession(sessionCookie)
 5  if session == nil || session.Role != "FlightDirector" {
 6    http.Error(w, "Доступ только для руководителя полетов", http.StatusUnauthorized)
 7    return
 8  }
 9  // Чтение данных из формы
10  launchpad := r.FormValue("launchpad")
11  // Выполнение критического действия
12  spaceAPI.InitiateLaunch(launchpad)
13  fmt.Fprintf(w, "Ракета на площадке %s запущена!", launchpad)
14}

На первый взгляд метод может показаться безопасным – в нём есть проверка сессии и роли.

Но в ней никакой проверки откуда пришёл запрос. Злоумышленник может создать страницу с интересной информацией и отправить ссылку на неё ничего не подозревающему руководителю полётов:

 1<html>
 2  <head>
 3    <title>Фотографии с ежегодной встречи рептилоидов</title>
 4  </head>
 5  <body>
 6    <img src="/elon.jpg" />
 7    <img src="/mark.jpg" />
 8    
 9    <!-- скрытая форма на сайте onlyreptiloids.tld -->
10    <form
11      action="https://mission-control.internal/launch"
12      method="POST"
13      id="attack-form"
14    >
15      <input type="hidden" name="launchpad" value="slc-40" />
16    </form>
17    <script>
18      // Автоматическая отправка формы
19      document.getElementById("attack-form").submit();
20    </script>
21  </body>
22</html>

Жертве достаточно зайти на эту страницу, чтобы форма была автоматически отправлена на атакуемый сайт.

Поскольку Content-Type этой формы – application/x-www-form-urlencoded, браузер без предварительных проверок (CORS preflight) отправит запрос вместе с сессионной кукой.

Атака через GET-запрос #

Стоит помнить, что GET-запросы по определению должны быть идемпотентными – они не должны приводить к изменениям состояния системы, и в контексте CSRF такие запросы по-умолчанию считаются безопасными, защитные механизмы для них не обязательны. Но на практике разработчики часто пренебрегают этим правилом, либо в результате ошибки допускают использование метода GET вместо POST.

Мы допустили такую ошибку в примере выше с реализацией launchHandler – не проверили http-метод и использовали r.FormValue, который извлекает данные не только из формы, но и из query-параметров.

В результате злоумышленнику даже не обязательно отправлять форму – достаточно сформировать ссылку с указанием этих параметров, и, например, указать её в качестве src у картинки:

1<img src="/elon.jpg" />
2<img src="/mark.jpg" />
3<!-- Опасная картинка: -->
4<img src="https://mission-control.internal/launch?launchpad=slc-40" />

Для браузера нет разницы между запросом картинки или вызовом метода API, как только руководитель полётов откроет эту страницу, браузер попытается загрузить "картинку" вместе с куками сессии, а небезопасный метод бэкенда, не отличая GET от POST, выполнит метод.

Атака через XHR (XMLHttpRequest и fetch) #

Этот метод может применяться когда необходимо выполнить сложный запрос (например к JSON-RPC API), который нельзя реализовать через обычную форму.

 1// скрипт на сайте с рептилоидами
 2fetch("https://mission-control.internal/api/adjust-orbit", {
 3  method: "POST",
 4  headers: {
 5    "Content-Type": "text/plain" // Обход preflight-проверки браузером
 6  },
 7  body: JSON.stringify({
 8    "thruster_id": "main-engine",
 9    "action": "burn",
10    "duration_sec": 600
11  }),
12  credentials: "include" // Принудительная отправка кук вместе с запросом
13});

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

  • Небезопасная конфигурация CORS: разрешены запросы с произвольных доменов и разрешена передача учётных данных Access-Control-Allow-Credentials: true).
  • Ослабленная защита Cookies: сессионные куки с атрибутом SameSite=None, либо пользователь использует устаревший браузер.

Методы защиты #

Для защиты от атак CSRF нам необходимо убедиться, что запрос пришёл из доверенного интерфейса.

Внимание!

Примеры кода приведены только для демонстрации идеи. В целях повышения читаемости в них может отсутствовать валидация, обработка ошибок и допущены другие упрощения. Пожалуйста, не используйте их "как есть" в своих проектах. Более подробные и точные примеры можно найти в связанных статьях, посвящённых этим темам.

Современные браузеры поддерживают атрибут SameSite для заголовка Set-Cookie, который ограничивает передачу кук при кросс-доменных запросах:

  • Strict: куки передаются только в рамках одного и того же сайта. Это эффективно для предотвращения большинства атак, но при этом может доставить неудобства пользователю – при переходе на сайт по внешней ссылке (например, из вашего же дашборда на другом домене), пользователь окажется разлогинен.
  • Lax: куки не передаются при кросс-доменных POST-запросах, но разрешены при обычных переходах по ссылкам (GET). Это значение используется в Go по умолчанию в большинстве современных фреймворков и библиотек.
 1// Устанавливаем куку с правильными атрибутами
 2http.SetCookie(w, &http.Cookie{
 3  Name:     "session_id",
 4  Value:    sessionId,
 5  Path:     "/",
 6  HttpOnly: true, // Защита от XSS
 7  Secure:   true, // Только через HTTPS
 8  // Защита от CSRF на уровне браузера:
 9  SameSite: http.SameSiteLaxMode, 
10})

Стоит помнить, что это относительно новые атрибуты, старые браузеры могут их игнорировать, поэтому обязательно стоит применять и другие способы защиты.

Верификация заголовков Origin и Referer #

Сервер может проверять заголовки Origin или Referer, чтобы убедиться, что запрос пришел с доверенного домена.

 1// Пример middleware для проверки Origin
 2func OriginCheckMiddleware(next http.Handler) http.Handler {
 3  return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
 4    // Проверяем только мутирующие методы
 5    if r.Method == http.MethodPost || r.Method == http.MethodPut {
 6      source := req.Header.Get("Origin")
 7      // Если Origin пустой, пробуем Referer
 8      if source == "" {
 9        source = req.Header.Get("Referer")
10      }
11      sourceUrl, _ := url.Parse(source)
12      // Проверяем, совпадает ли Host и Origin/Referer
13      if sourceUrl.Host != req.Host {
14        http.Error(w, "Попытка межсайтового запуска отклонена", http.StatusForbidden)
15        return
16      }
17    }
18    // Обрабатываем запрос дальше
19    next.ServeHTTP(w, req)
20  })
21}

Это неплохой метод защиты, но он тоже не лишён недостатков:

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

Верификация заголовков Sec-Fetch-* #

Помимо Origin и Referer, стоит анализировать заголовки Sec-Fetch-*, которые подставляются современными браузерами и их невозможно подделать с помощью скриптов в браузере.

Так, заголовок Sec-Fetch-Site может принимать значения same-origin, same-site, cross-site и none.

  • same-origin – запрос сделан с того же домена, протокола и порта – самый безопасный уровень.
  • same-site – запрос сделан с поддомена того же сайта (например с api.mission-control.internal на mission-control.internal).
  • cross-site – запрос с любого другого сайта.
  • none – запрос инициирован самим пользователем (ввод в адресную строку, из закладок, и т.п.).
 1// Пример middleware для проверки Sec-Fetch-Site
 2func FetchMetadataMiddleware(next http.Handler) http.Handler {
 3  return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 4    // Проверяем только мутирующие методы
 5    if r.Method == http.MethodPost || r.Method == http.MethodPut {
 6      site := r.Header.Get("Sec-Fetch-Site")
 7
 8      // Разрешаем только запросы с нашего домена или прямые заходы
 9      if site != "same-origin" && site != "same-site" && site != "none" {
10        http.Error(w, "Запрос заблокирован политикой Fetch Metadata", http.StatusForbidden)
11        return
12      }
13    }
14    
15    next.ServeHTTP(w, r)
16  })
17}

Помимо этого полезным может быть анализировать заголовки Sec-Fetch-Mode (как сделан запрос – ссылка, форма, скрипт), Sec-Fetch-Dest (что запрашивают – документ, картинка, и т.п.) и Sec-Fetch-User (участие пользователя – клик или автоматически).

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

Криптографические токены (Challenge-Response) #

Хотя все предыдущие методы позволяют повысить безопасность, но они не лишены недостатков, и должны использоваться лишь как дополнительные методы защиты.

Наиболее надежный и универсальный способ – добавлять в каждый запрос секретное уникальное значение (CSRF-токен).

В зависимости от того, является ли ваша система stateful (с сессией) или stateless (без сессий), могут применяться два различных подхода:

  • Synchronizer Token Pattern: Сервер генерирует токен, сохраняет его в сессии пользователя и вставляет в HTML-форму. При получении запроса сервер сравнивает токен из формы с токеном в сессии.
  • Double Submit Cookie: Токен отправляется и в куке, и в теле запроса. Сервер проверяет их совпадение, не сохраняя состояние на своей стороне (stateless).

В следующих статьях мы подробно рассмотрим каждый подход и детали их реализации.