Спам ушёл через форму обратной связи: разбор дыры в сайте
Обычная форма «Напишите нам» на сайте — это, по сути, открытая труба на ваш почтовый сервер: заполнил поля, нажал «Отправить» — и письмо ушло с вашего IP и вашего домена. Если труба не заужена ни CAPTCHA, ни лимитами, ни валидацией, рано или поздно её находит бот и начинает гнать через неё чужой спам сотнями писем в час. Расскажу, как отличить такую атаку от обычного роста заявок, что искать в логах и какими средствами закрыть дыру, не отпугнув живых посетителей.
Содержание
Как это выглядит на практике
Механика form-to-email спама простая. На сайте есть форма с полями вроде «имя», «email», «тема», «сообщение». По нажатию кнопки backend (PHP mail(), PHPMailer, обработчик на Node.js через nodemailer, скрипт на Python с smtplib — неважно) собирает письмо и отправляет его через локальный MTA (Postfix, Exim, Sendmail) или через SMTP-релей на адрес администратора сайта. Форма не проверяет, что её заполняет человек, и не ограничивает частоту отправки — значит, к ней может стучаться скрипт.
Дальше есть два разных сценария, и их важно различать, потому что защита от них разная:
- Форма как генератор писем на ваш собственный ящик. Бот заполняет поля «сообщение» ссылками на казино, реплики часов или фарму и жмёт «Отправить» сотни раз в сутки. Письма реально уходят одному получателю — вам, на info@ или sales@. Ваш SMTP при этом только принимает и подписывает эти письма, репутация страдает не так резко, но почтовый ящик становится непригодным для работы, а очередь на сервере растёт.
- Форма как открытый релей. Хуже: если в поле «email отправителя» или «тема» нет фильтрации, а backend вставляет введённые пользователем данные прямо в заголовки письма (
From,Reply-To,Subject, иногдаCc/Bcc), злоумышленник инъекцией заголовков дописывает в письмо дополнительныеTo:иBcc:— и одно нажатие кнопки на форме рассылает письмо уже не вам, а тысяче случайных адресов через ваш SMTP. Это классическая уязвимость email header injection, детально разобранная в статье «Форма обратной связи стала открытым релеем» — если подозреваете именно инъекцию заголовков, там разбор конкретного случая по шагам.
Оба сценария бьют по одному месту — по репутации вашего IP и домена как отправителя почты, просто с разной скоростью.
Диагностика: аномальный рост писем от формы
Первый сигнал — это не жалобы получателей, а собственные логи. Проверяйте по порядку.
Очередь Postfix. Если письма формы уходят через локальный Postfix, смотрите размер очереди и её состав:
postqueue -p | tail -20
mailq | grep -c '^[A-F0-9]'
Резкий рост числа писем в очереди за короткий промежуток — тревожный признак. Сравните с обычным фоном: если форма в норме генерирует 5–10 писем в день, а вы видите 40 писем за последний час, это уже не «наплыв клиентов».
Журнал Postfix по отправителю. Ищите конкретный адрес, с которого отправляет скрипт формы (обычно это учётка сайта, www-data или адрес вида noreply@ваш-домен):
grep 'from=<noreply@example.com>' /var/log/mail.log | wc -l
grep 'from=<noreply@example.com>' /var/log/mail.log | awk '{print $1, $2, $3}' | uniq -c
Если этот адрес обычно отправляет единицы писем в сутки, а тут счётчик за час скачет на порядки — источник почти наверняка форма.
Логи веб-сервера по обработчику формы. Найдите путь к скрипту-обработчику (/contact.php, /api/feedback, /wp-admin/admin-ajax.php?action=contact_form... и т.п.) и посчитайте обращения к нему:
grep "POST /contact" /var/log/nginx/access.log | wc -l
grep "POST /contact" /var/log/nginx/access.log | awk '{print $1}' | sort | uniq -c | sort -rn | head -20
Характерная картина: десятки POST-запросов с одного IP или с пула IP за минуты, пустой или подозрительный Referer, одинаковый User-Agent вроде python-requests/2.x или curl/8.x — живой человек так форму не заполняет.
Содержимое писем. Откройте несколько «подозрительных» писем и посмотрите на тело: набор нерелевантных ссылок, бессвязный текст на смеси языков, призывы вроде «купите сейчас» или тема, никак не связанная с содержанием формы (например, поле «Тема» подставлено ботом как «Re: Ваш заказ №48291» — типичный приём, чтобы письмо выглядело легитимным для получателя и обходило часть спам-фильтров).
Если вы уже увидели резкий рост очереди или попадание IP в чёрные списки, порядок действий по стабилизации сервера в целом хорошо описан в «Сервер начал рассылать спам ночью» — там разбор именно ситуации «сервер уже не мой».
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверПочему это ударяет по репутации домена
Форма отправляет письма от имени вашего домена и через ваш SMTP или релей провайдера. Получающие серверы (Gmail, Mail.ru, Yandex, корпоративные фильтры) видят это как обычную почту с вашего IP — и ведут статистику: сколько писем ушло, какой процент помечен как спам, сколько «отказов» (bounce) на несуществующие адреса.
Когда через форму начинают литься десятки-сотни писем в час на случайные адреса (в сценарии открытого релея) или просто спам-контент на ваш собственный ящик, который вы сами же помечаете как спам в веб-клиенте, — репутационные метрики резко проседают:
- растёт доля писем, помеченных получателями как спам;
- растёт доля bounce (если релей рассылает на несуществующие адреса);
- IP и домен попадают в DNSBL (Spamhaus, SORBS и аналоги) — и тогда уже вся ваша легитимная почта, включая уведомления клиентам, начинает биться или падать в спам.
Второе особенно неприятно: пострадает не только форма, а вообще вся исходящая почта с сервера — транзакционные письма, счета, уведомления. Если репутация уже испорчена, план восстановления по шагам — делистинг, снижение объёма, прогрев — расписан в «Восстановление доменной репутации после блокировки».
Защита: CAPTCHA и honeypot
Первый рубеж — отсечь ботов до того, как форма вообще отправит письмо.
CAPTCHA (Google reCAPTCHA v3, Cloudflare Turnstile или аналог) хорошо режет массовых автоматических ботов, но у неё есть цена: часть живых пользователей раздражается или не проходит проверку (особенно на мобильных сетях или через VPN), а v3-варианты требуют внешнего JS-скрипта и вызывают вопросы к приватности у части аудитории. Это осознанный компромисс, а не серебряная пуля — целенаправленный человек-оператор или качественная сервис-ферма CAPTCHA всё равно обходят.
Honeypot-поле — более лёгкий и незаметный для пользователя приём. В форму добавляется дополнительное поле, скрытое через CSS (не через type="hidden" — некоторые боты его игнорируют, а через display:none или вынос за пределы экрана), с нейтральным именем вроде website или middle_name. Человек его не видит и не заполняет, а простые боты, которые парсят и заполняют все поля формы, попадаются:
<div style="position:absolute; left:-9999px;" aria-hidden="true">
<label for="website">Оставьте это поле пустым</label>
<input type="text" id="website" name="website" tabindex="-1" autocomplete="off">
</div>
На backend — простая проверка:
if (!empty($_POST['website'])) {
// поле заполнено — это бот, отбрасываем без отправки письма
http_response_code(200); // отвечаем ботy как обычно, не подсказывая, что его вычислили
exit;
}
Honeypot не требует внешних сервисов, не мешает доступности (важно для скринридеров — не забудьте aria-hidden и явный label) и отсекает значительную часть примитивных ботов бесплатно. Комбинация «honeypot + CAPTCHA только если honeypot сработал подозрительно» — разумный баланс между удобством и защитой.
Дополнительно помогает time-trap: если форма отправлена быстрее, чем физически возможно её заполнить (например, меньше чем за 3 секунды после загрузки страницы), это тоже признак бота — метка времени рендера формы передаётся скрытым полем и сверяется на backend.
Rate limiting на уровне формы и сервера
Даже если бот прошёл honeypot, частоту отправки нужно ограничивать отдельно — это защищает и от медленных, «человекоподобных» ботов, и от единичного скомпрометированного скрипта, который вдруг начал слать без остановки.
На уровне nginx — ограничение по IP на конкретный путь обработчика формы:
limit_req_zone $binary_remote_addr zone=contact_form:10m rate=2r/m;
location /contact {
limit_req zone=contact_form burst=3 nodelay;
limit_req_status 429;
proxy_pass http://backend;
}
Это ограничит одну и ту же форму двумя запросами в минуту с одного IP, с небольшим запасом на всплеск. Как работает механизм limit_req и token bucket за ним, а также почему наивная реализация иногда бьёт по офисам за NAT сильнее, чем по ботам — отдельно разобрано в «Как работает rate limiting».
На уровне backend — лимит не только по IP, но и по получателю/содержимому, потому что распределённый ботнет легко обходит лимит по IP:
$key = 'contact_form_' . $_SERVER['REMOTE_ADDR'];
$attempts = (int) apcu_fetch($key);
if ($attempts >= 3) {
http_response_code(429);
exit('Слишком много попыток. Попробуйте позже.');
}
apcu_store($key, $attempts + 1, 3600); // окно в час
Для продакшена вместо APCu лучше Redis с TTL — так лимит переживает рестарт PHP-FPM и работает одинаково на нескольких воркерах:
$redis->multi()
->incr($key)
->expire($key, 3600)
->exec();
if ($redis->get($key) > 3) {
http_response_code(429);
exit;
}
Лимит на стороне MTA тоже не будет лишним как последний рубеж — Postfix умеет ограничивать число писем от одного отправителя в единицу времени через smtpd_client_connection_rate_limit и анти-флуд плагины, но это уже защита сервера в целом, а не конкретно формы, и её стоит настраивать отдельно от формы, а не вместо неё.
Валидация полей и защита от инъекции заголовков
Отдельный класс проблем — не спам как таковой, а превращение формы в инструмент для инъекции произвольных заголовков письма. Это происходит, когда значения из полей формы конкатенируются прямо в заголовки без экранирования переноса строки.
Уязвимый паттерн (не делайте так):
$subject = $_POST['subject'];
$from = $_POST['email'];
mail('info@example.com', $subject, $message, "From: $from");
Если в поле «Тема» или «Email» пользователь передаст \r\nBcc: spam-list@evil.com, символы переноса строки добавят в заголовки письма новую директиву — и PHP mail() покорно отправит копию письма по указанному адресу. Это и есть механизм email header injection, который превращает вашу форму в открытый релей.
Что делать:
- Никогда не передавайте пользовательский ввод напрямую в заголовки письма. Значение поля «email отправителя» должно попадать только в
Reply-To, а не вFrom(вFromставьте свой технический адрес, напримерnoreply@ваш-домен) — так подмена заголовков через это поле теряет смысл. - Отфильтруйте переносы строк из всех полей, которые хоть как-то используются при формировании заголовков:
function sanitize_header($value) {
return trim(preg_replace('/[\r\n]+/', ' ', $value));
}
$subject = sanitize_header($_POST['subject']);
$from_email = sanitize_header($_POST['email']);
- Валидируйте формат email серверной функцией, а не только фронтенд-скриптом (клиентскую проверку легко обойти прямым POST-запросом мимо браузера):
if (!filter_var($from_email, FILTER_VALIDATE_EMAIL)) {
http_response_code(400);
exit('Некорректный email');
}
- Ограничьте длину полей на backend (не только
maxlengthв HTML — это тоже клиентская проверка): тема — 200 символов, сообщение — разумный потолок вроде 5000. Спам-боты часто пытаются впихнуть в поле «сообщение» простыню из сотен ссылок — жёсткий лимит длины сам по себе отсекает часть таких попыток. - Используйте библиотеку для отправки почты (PHPMailer, Symfony Mailer, nodemailer) вместо голого
mail()— они по умолчанию правильно экранируют заголовки и не дают собрать письмо с произвольной инъекцией через конкатенацию строк.
Проверка, что дыра действительно закрыта
После правок не поленитесь проверить форму так, как её проверял бы атакующий, — иначе легко закрыть один вектор и оставить соседний открытым.
Тест на инъекцию заголовков:
curl -X POST https://example.com/contact \
-d "name=Test&email=test@test.com%0d%0aBcc:victim@evil.com&subject=Test&message=Test"
Если после этого запроса в заголовках отправленного письма (смотрите через postcat в очереди или в логах MTA) появился лишний Bcc, значит, санитизация не сработала — переносы строк всё ещё проходят.
Тест на honeypot:
curl -X POST https://example.com/contact \
-d "name=Test&email=test@test.com&website=http://spam.example&subject=Test&message=Test"
Заполненное honeypot-поле должно приводить к тихому отбросу без отправки письма — проверьте по очереди Postfix, что письмо не появилось.
Тест на rate limiting:
for i in {1..10}; do
curl -s -o /dev/null -w "%{http_code}\n" -X POST https://example.com/contact \
-d "name=Test&email=test@test.com&subject=Test&message=Test"
done
После третьего-четвёртого запроса подряд должны пойти коды 429 — если все десять прошли с 200, лимит не сработал (проверьте, что ключ лимита действительно привязан к IP, а не, например, к сессии, которую бот каждый раз создаёт заново).
Полезно повторить тесты через неделю: боты периодически перепроверяют старые дыры, и если атака вернулась той же схемой — где-то остался запасной путь, например старый обработчик /old-contact.php, забытый при переезде на новую форму.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
CAPTCHA обязательна, или honeypot достаточно сам по себе?
Для формы с невысоким потоком заявок honeypot плюс серверный rate limiting часто хватает и без внешнего сервиса CAPTCHA. CAPTCHA стоит добавлять, если honeypot не справляется — вы видите, что боты его успешно обходят (например, заполняют форму через headless-браузер, а не простым HTTP-запросом).
Почему письма от формы стали уходить в спам у меня самого, хотя раньше доходили нормально?
Если через ту же форму (и, соответственно, тот же отправляющий адрес) прошёл спам-трафик, почтовые провайдеры могли снизить репутацию этого конкретного адреса или домена. Проверьте, что у отправляющего домена корректно настроены SPF, DKIM и DMARC — их отсутствие или ошибка в записи дополнительно роняет доверие к письмам даже без всякого спама через форму.
Нужно ли ограничивать rate limiting по email из поля формы, а не только по IP?
Да, это разумное дополнение: ботнет с сотен IP легко обходит лимит по адресу, но если один и тот же email-адрес (настоящий или поддельный) используется в сотнях отправок за час, это тоже повод для временной блокировки — храните хеш от email в том же Redis с отдельным ключом и своим порогом.
Как понять, что дыру уже эксплуатируют прямо сейчас?
Смотрите размер очереди в реальном времени: watch -n5 'mailq | tail -1' и число активных SMTP-соединений: ss -tn state established '( dport = :25 or dport = :587 )' | wc -l. Резкий стабильный рост — сигнал действовать немедленно.
Стоит ли просто отключить форму, пока разбираешься?
Да, это нормальный первый шаг при активной эксплуатации — временно верните на обработчик формы статус 503, разберитесь спокойно, внесите все правки разом (honeypot, rate limiting, санитизация заголовков) и включайте форму обратно уже с тестами из раздела выше.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →