Блог

Password Reset Poisoning: как подделка одного заголовка уводит чужой аккаунт.

Poisoning
Linux

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 на неизвестные хосты закрывает атаку ещё до приложения. На уровне кода — домен в письмах всегда из конфига или базы данных, никогда из того что прислал клиент.

Если на вашем сайте есть форма сброса пароля — проверьте прямо сейчас одной командой из раздела выше.

Leave your thought here

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Поддержать автора

Если вам понравилась статья — вы можете поддержать автора.

Crypto donation button by NOWPayments