Как мы превращаем реальный инцидент в практическое обучение для команды
15 января 2025: Клиент банка сообщил, что с его счёта произошёл несанкционированный перевод на сумму 50 000 ₽ на неизвестный счёт. Расследование показало, что перевод был совершён через легитимную сессию клиента в интернет-банке russianbank.ru, но он утверждает, что не совершал этого действия.
Root Cause: CSRF (Cross-Site Request Forgery) — злоумышленник заставил браузер клиента отправить запрос на перевод денег без его ведома.
CSRF — это атака, при которой злоумышленник заставляет браузер жертвы отправить запрос на целевой сайт без её ведома. Главная особенность: браузер автоматически добавляет cookie аутентификации к каждому запросу на домен, для которого они были установлены.
Это фундаментальное поведение веб-браузеров, заложенное в спецификации HTTP:
russianbank.ru, он автоматически прикрепляет все cookie для этого домена. Браузер не проверяет, откуда пришёл запрос (с russianbank.ru или с evil.com).1. Условие для атаки:
2. Что делает злоумышленник:
1. GET-based CSRF (простейший):
<img src="https://russianbank.ru/api/transfer?to=attacker&amount=50000"/>
2. POST-based CSRF (наш кейс):
<form action="https://russianbank.ru/api/transfer" method="POST">
<input name="toAccount" value="attacker-account"/>
</form>
<script>document.forms[0].submit();</script>
3. JSON-based CSRF (современные API):
<script>
fetch('https://russianbank.ru/api/transfer', {
method: 'POST',
credentials: 'include', // отправить cookie
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ toAccount: 'attacker', amount: 50000 })
});
</script>
⚠️ Этот вариант частично блокируется CORS, но может работать если сервер неправильно настроен.
❌ "У нас HTTPS" — НЕ ЗАЩИЩАЕТ
HTTPS шифрует трафик, но не проверяет легитимность источника запроса. CSRF работает поверх HTTPS.
❌ "У нас HttpOnly cookie" — НЕ ЗАЩИЩАЕТ
HttpOnly защищает от XSS (JavaScript не может прочитать cookie), но браузер всё равно автоматически отправляет эти cookie с CSRF-запросом.
❌ "Мы проверяем Referer" — НЕНАДЁЖНО
Некоторые браузеры и корпоративные прокси удаляют Referer. Это не должно быть единственной защитой.
❌ "У нас только POST, не GET" — НЕ ЗАЩИЩАЕТ
Злоумышленник может отправить POST-запрос через скрытую форму (как в нашем кейсе).
1. CSRF Token (Synchronizer Token Pattern)
Сервер генерирует уникальный токен для каждой сессии/запроса. Токен передаётся в форме (не в cookie!). Сервер проверяет совпадение токена из cookie и из тела запроса.
2. SameSite Cookie Attribute
Set-Cookie: JSESSIONID=xxx; SameSite=Strict — браузер не отправит cookie при запросе с другого домена.
3. Double Submit Cookie Pattern
CSRF-токен хранится и в cookie, и в заголовке/параметре запроса. Злоумышленник не может прочитать cookie (SOP), значит не может подделать токен.
4. Custom Headers для AJAX
Для API: требовать кастомный заголовок (например, X-Requested-With: XMLHttpRequest). CORS не позволит простому HTML-форме добавить кастомный заголовок.
5. Проверка Origin/Referer (дополнительно)
Как дополнительный слой защиты: проверять, что Origin или Referer совпадает с доменом сервера.
CSRF часто комбинируется с другими атаками:
💡 Ключевой вывод:
CSRF работает не из-за бага в браузере, а из-за фундаментального дизайна HTTP. Единственная надёжная защита — явная проверка на сервере, что запрос пришёл из легитимного источника (через CSRF token или SameSite cookie).
Детальная визуализация атаки — как это выглядело изнутри
Клиент залогинен в интернет-банке russianbank.ru. В браузере есть активная сессия с cookie JSESSIONID=7f8a9b2c1d.
// Cookie в браузере клиента
Cookie: JSESSIONID=7f8a9b2c1d; Domain=russianbank.ru; HttpOnly; Secure
Злоумышленник отправляет клиенту email: "Вы выиграли iPhone 17! Кликните здесь". Клиент переходит на сайт злоумышленника free-iphone-russia.com.
На странице free-iphone-russia.com находится невидимая HTML форма, которая автоматически отправляется при загрузке страницы:
<html>
<body>
<h1>Поздравляем! Вы выиграли iPhone 17!</h1>
<!-- Скрытая форма, автоматически отправляющаяся -->
<form id="attack" action="https://russianbank.ru/api/transfer" method="POST">
<input type="hidden" name="toAccount" value="40817810099910004312"/>
<input type="hidden" name="amount" value="50000"/>
</form>
<script>
// Автоматически отправляем форму
document.getElementById('attack').submit();
</script>
</body>
</html>
Браузер видит, что запрос идёт на russianbank.ru и автоматически прикрепляет cookie сессии JSESSIONID=7f8a9b2c1d. Это стандартное поведение браузера — он не знает, что запрос инициирован злоумышленником.
POST /api/transfer HTTP/1.1
Host: russianbank.ru
Cookie: JSESSIONID=7f8a9b2c1d ← Браузер добавил автоматически!
Content-Type: application/x-www-form-urlencoded
Origin: https://free-iphone-russia.com
toAccount=40817810099910004312&amount=50000
Backend russianbank.ru видит валидную сессию (JSESSIONID=7f8a9b2c1d) и выполняет перевод. Сервер думает, что это легитимный запрос от клиента.
// Уязвимый код — НЕТ CSRF защиты@PostMapping("/api/transfer")public ResponseEntity<String> transfer(@RequestParam String toAccount,@RequestParam BigDecimal amount,HttpSession session) {// Проверяем только сессию (этого недостаточно!)User user = (User) session.getAttribute("user");if (user == null) {return ResponseEntity.status(401).body("Unauthorized");}// ⚠️ ПРОБЛЕМА: Нет проверки CSRF токена!// Запрос может прийти откуда угодноbankService.transfer(user.getAccountId(), toAccount, amount);return ResponseEntity.ok("Transfer successful");}
✅ Перевод выполнен успешно. 50 000 ₽ ушли со счёта клиента на счёт злоумышленника. Клиент даже не видел, что произошло — всё случилось в фоне при загрузке страницы free-iphone-russia.com.
Разбор уязвимого кода и архитектурных ошибок
// ❌ CSRF защита была явно отключена в конфигурации
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf().disable() ← ⚠️ КРИТИЧЕСКАЯ ОШИБКА!
.authorizeHttpRequests(auth -> auth
.anyRequest().authenticated()
);
return http.build();
}
}
Spring Boot + Spring Security — правильное решение
Включаем встроенную CSRF защиту Spring Security и правильно настраиваем frontend для отправки токенов.
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// ✅ CSRF защита включена по умолчанию в Spring Security
// Но мы явно настраиваем для ясности
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
)
.authorizeHttpRequests(auth -> auth
.anyRequest().authenticated()
);
return http.build();
}
}
// JavaScript на frontend
function getCsrfToken() {
// Spring Security кладёт токен в cookie XSRF-TOKEN
const name = "XSRF-TOKEN";
const match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)'));
return match ? match[2] : null;
}
// При отправке формы добавляем токен в заголовок
fetch('/api/transfer', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-XSRF-TOKEN': getCsrfToken() ← ✅ Добавляем токен!
},
body: JSON.stringify({
toAccount: 'recipient-123',
amount: 50000
})
});
@Configuration
public class CookieConfig {
@Bean
public CookieSerializer cookieSerializer() {
DefaultCookieSerializer serializer = new DefaultCookieSerializer();
serializer.setSameSite("Strict"); ← ✅ SameSite=Strict
serializer.setUseHttpOnlyCookie(true);
serializer.setUseSecureCookie(true);
return serializer;
}
}
Что теперь обязательно всегда
.csrf().disable() без явного обоснования и approval от security team
X-XSRF-TOKEN заголовок
Стоп-кран, который не даст этой уязвимости вернуться
Если кто-то случайно отключит CSRF защиту или забудет проверить токен — этот тест упадёт и заблокирует деплой в production.
@SpringBootTest
@AutoConfigureMockMvc
class CsrfProtectionTest {
@Autowired
private MockMvc mockMvc;
@Test
void transferWithoutCsrfToken_shouldBeForbidden() throws Exception {
// Arrange: создаём authenticated сессию
MvcResult loginResult = mockMvc.perform(post("/login")
.param("username", "testuser")
.param("password", "password"))
.andExpect(status().isOk())
.andReturn();
String sessionCookie = loginResult.getResponse().getCookie("JSESSIONID").getValue();
// Act: пытаемся сделать перевод БЕЗ CSRF токена
// (имитируем CSRF атаку)
mockMvc.perform(post("/api/transfer")
.cookie(new Cookie("JSESSIONID", sessionCookie))
.param("toAccount", "attacker-123")
.param("amount", "50000"))
// Assert: ожидаем 403 Forbidden
.andExpect(status().isForbidden()); ← ✅ Запрос должен быть отклонён!
}
@Test
void transferWithValidCsrfToken_shouldSucceed() throws Exception {
// Arrange: получаем CSRF токен
MvcResult result = mockMvc.perform(get("/api/csrf"))
.andExpect(status().isOk())
.andReturn();
String csrfToken = result.getResponse().getCookie("XSRF-TOKEN").getValue();
// Act: делаем перевод С валидным CSRF токеном
mockMvc.perform(post("/api/transfer")
.header("X-XSRF-TOKEN", csrfToken)
.param("toAccount", "recipient-123")
.param("amount", "1000"))
// Assert: ожидаем успех
.andExpect(status().isOk()); ← ✅ С токеном запрос проходит
}
}
Если тест падает — релиз блокируется. Никто не сможет задеплоить код без CSRF защиты.
Интеграция: GitHub Actions / GitLab CI / Jenkins
Мы превращаем каждый ваш реальный инцидент в такое же практическое обучение + стоп-кран в CI/CD за 2-5 дней.
Забронировать 20-мин DiscoveryПокажем демо и дадим расчёт под ваш стек