Форма обратной связи стала открытым релеем: домен забанили за сутки
В пятницу вечером письма от интернет-магазина перестали доходить до части клиентов на Gmail и Mail.ru, а в понедельник утром бухгалтерия пожаловалась, что счета партнёрам "улетают в никуда". Формальных изменений на сервере никто не вносил — ни деплоя, ни обновления Postfix, ни смены DNS. Разбор растянулся на день, потому что первые три версии произошедшего оказались неверными, а настоящая причина пряталась в форме обратной связи, которую последний раз трогали два года назад.
Содержание
Что случилось: сутки без предупреждений
Сайт стоял на обычной связке nginx + PHP-FPM + Postfix на одном VPS, форма обратной связи отправляла заявки на почту менеджеров через встроенную функцию mail(). Никаких алертов на сам сервер не приходило — CPU и память были в норме, диск не заполнялся, nginx отдавал 200-е коды. Первым сигналом стало письмо от почтового провайдера клиента с пометкой "message rejected — domain listed" и жалоба от бухгалтера, что письмо с актом сверки вернулось отправителю.
Проверка домена по нескольким публичным чёрным спискам (Spamhaus, SORBS, локальный blacklist у одного из корпоративных провайдеров) показала: IP сервера уже сутки как в списке минимум одного crns. Разрыв между моментом заражения и моментом, когда об этом узнали, — это и есть первая проблема таких инцидентов: пока никто не жалуется, никто не смотрит в очередь исходящей почты.
Что показали логи и метрики
Первым делом посмотрели состояние очереди Postfix:
postqueue -p | tail -n 20
mailq | grep -c "^[A-F0-9]"
Очередь оказалась не пустой, а забитой — сотни писем со статусом deferred, адресаты сплошь внешние: gmail.com, yandex.ru, mail.ru, outlook.com, причём вперемешку с доменами, которых в CRM компании никогда не было. Обычно через форму обратной связи уходит одно письмо в час-два на внутренний адрес отдела продаж — тут же за ночь скопилось несколько тысяч исходящих сообщений.
Дальше — /var/log/mail.log:
grep "postfix/qmgr" /var/log/mail.log | tail -n 50
grep "postfix/local\|postfix/pickup" /var/log/mail.log | grep -c ""
Бросилось в глаза, что почти все "подозрительные" письма попадали в очередь через postfix/pickup — то есть локальную отправку через sendmail-совместимый бинарник, а не через SMTP-приём снаружи. Это сразу сузило круг подозреваемых: раз письма рождаются локально, значит, их создаёт какой-то процесс на самом сервере, а не внешний клиент, ломящийся на 25-й порт.
Параллельно в access-логе nginx нашлась аномалия:
awk '{print $1}' /var/log/nginx/access.log | grep -c ""
grep "POST /feedback.php" /var/log/nginx/access.log | wc -l
Число POST-запросов на /feedback.php за ночь оказалось на порядок выше обычного, причём с десятков разных IP — похоже на автоматизированный обход, а не на всплеск интереса живых посетителей.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверГипотезы, которые отбросили
Первая версия — взлом админки CMS и рассылка через встроенный почтовый модуль плагина. Проверили /var/log/auth.log и лог самой CMS на неудачные и успешные входы — ни всплеска брутфорса, ни подозрительных сессий администратора не нашли. Плагины и темы сверили по контрольным суммам с чистой поставкой — расхождений не было, файлы не менялись, веб-шелл не подложен.
Вторая версия — Postfix настроен как открытый relay в буквальном смысле, то есть принимает и пересылает почту от кого угодно снаружи. Проверили main.cf:
postconf mynetworks
postconf smtpd_recipient_restrictions
mynetworks был ограничен 127.0.0.0/8 и адресом самого сервера, smtpd_recipient_restrictions включал reject_unauth_destination. Ручная проверка telnet'ом с внешнего хоста на 25-й порт с попыткой отправить письмо на чужой домен закончилась ожидаемым 554 5.7.1 Relay access denied — классический тест на открытый релей провалился (в хорошем смысле), Postfix со своей стороны был настроен правильно.
Третья версия — утечка учётных данных SMTP AUTH и рассылка через авторизованную отправку. Проверили лог на записи sasl_username за сутки — ни одной аутентифицированной сессии, кроме легитимной от самого сайта, найдено не было. Значит, дело не в скомпрометированном пароле от почтового ящика.
Реальная причина: инъекция заголовков в PHP mail()
Сузив круг до локальной отправки через /feedback.php, открыли сам скрипт. Логика была типична для формы, которую писали "на скорую руку" много лет назад:
<?php
$email = $_POST['email'];
$name = $_POST['name'];
$msg = $_POST['message'];
$to = 'sales@example.com';
$subject = 'Новая заявка с сайта';
$headers = "From: {$name} <{$email}>\r\n";
$headers .= "Reply-To: {$email}\r\n";
mail($to, $subject, $msg, $headers);
?>
Поле email подставлялось в строку заголовков без какой-либо проверки. Функция mail() в PHP разделяет заголовки по \r\n, и если это сочетание символов оказывается внутри пользовательского ввода, всё, что идёт после него, PHP воспринимает как новый заголовок письма. Атакующему достаточно отправить в поле email не адрес, а строку вида:
attacker@evil.test%0d%0aBcc:%20victim1@gmail.com,victim2@mail.ru%0d%0aContent-Type:%20text/html
После URL-декодирования это превращается в полноценный набор дополнительных заголовков Bcc: с десятками получателей и даже сменой типа содержимого. Один POST-запрос к форме — и mail() честно отправляет письмо не только на sales@example.com, но и всем адресам из инъектированного Bcc, причём подписанное доменом компании и уходящее через её собственный, вполне легитимный Postfix.
Это и есть суть "формы, ставшей открытым релеем": сам почтовый сервер никого постороннего не пускал и был настроен образцово, но локальный доверенный канал (sendmail/pickup) исполнял ровно то, что ему передавал скомпрометированный PHP-скрипт — а скрипт слепо доверял вводу пользователя. Ботнет автоматически прогнал через форму несколько тысяч запросов за ночь, каждый со своим набором инъектированных получателей, и IP сервера рабочей репутации не пережил: жалобы на спам от получателей (жалобные кнопки в Gmail/Outlook, спам-ловушки провайдеров) агрегируются автоматически, и попадание в чёрный список у одного из крупных RBL происходит быстро — счёт идёт на часы, а не на недели, если письма массово размечаются как спам сразу у нескольких получателей.
Как остановили кровотечение в первый час
Порядок действий был такой:
- Отключили приём формы, не трогая остальной сайт — переименовали
feedback.phpи вернули 503 на этот путь, чтобы боты перестали получать 200 и не пытались повторно:
mv /var/www/site/feedback.php /var/www/site/feedback.php.disabled
- Почистили очередь Postfix от уже накопленных подозрительных писем, чтобы не добивать репутацию их доставкой:
postqueue -p | awk '/^[A-F0-9]/{print $1}' | tr -d '*!' > /tmp/qids.txt
for id in $(cat /tmp/qids.txt); do postsuper -d "$id"; done
- Заблокировали активные IP атакующих через fail2ban с кастомным фильтром на POST-запросы к
/feedback.php— без такого фильтра стандартные джейлы nginx эту активность не ловят, потому что запросы возвращают вполне легитимный 200. - Подали заявку на делистинг IP из чёрного списка через форму самого RBL — обычно это отдельная веб-форма провайдера списка, и обработка занимает от нескольких часов до пары суток, автоматически IP не выпадает.
- Проверили SPF/DKIM/DMARC домена, чтобы убедиться, что легитимная почта хотя бы формально проходит проверки и после снятия из списка доставка восстановится быстрее — если с этим есть нюансы, они разобраны в статье про настройку SPF, DKIM и DMARC на сервере.
Что изменили после разбора
Возвращать форму в старом виде не стали — переписали обработку письма с нуля:
- Валидация email через
filter_varвместо доверия сырому вводу:
$email = filter_var($_POST['email'] ?? '', FILTER_VALIDATE_EMAIL);
if ($email === false) {
http_response_code(400);
exit('Invalid email');
}
- Отказ от ручной сборки заголовков в пользу библиотеки, которая экранирует спецсимволы и не даёт вставить
\r\nв заголовок — PHPMailer сisSMTP()иaddAddress()/addReplyTo()вместо конкатенации строк. - Rate-limit на уровне nginx для эндпоинта формы:
limit_req_zone $binary_remote_addr zone=feedback:10m rate=3r/m;
location = /feedback.php {
limit_req zone=feedback burst=2 nodelay;
limit_req_status 429;
proxy_pass http://php_backend;
}
- Honeypot-поле и минимальное время заполнения формы — простое и бесплатное первое сито против автоматических ботов, ещё до капчи: скрытое поле, которое обычный посетитель не видит и не заполняет, плюс проверка, что между отдачей формы и отправкой прошло не меньше пары секунд.
- Отдельный джейл fail2ban под конкретный путь формы, чтобы повторные попытки с одного IP блокировались автоматически, не дожидаясь ручного вмешательства — принцип настройки таких правил разобран в статье про fail2ban для nginx.
- Мониторинг очереди Postfix — простой cron-скрипт раз в 10 минут проверяет количество писем в очереди и шлёт алерт, если оно резко выросло относительно обычного фона:
COUNT=$(mailq | grep -c "^[A-F0-9]")
if [ "$COUNT" -gt 100 ]; then
echo "Postfix queue spike: $COUNT" | mail -s "ALERT: mail queue" ops@example.com
fi
Здесь есть внутренняя ирония: тот же mail(), который стал источником проблемы, использован для алерта — но уже с полностью статичным текстом без пользовательского ввода, так что риска инъекции нет.
- Перенос транзакционной почты сайта на выделенный IP/релей — вместо отправки напрямую с основного IP сервера, транзакционные письма (подтверждения заказов, уведомления) пошли через relayhost стороннего почтового сервиса. Так репутация основного домена и IP сервера меньше зависит от того, что происходит на уровне одной формы на сайте, а при похожем инциденте в будущем под удар попадёт только служебный релей, а не вся исходящая почта компании.
Отдельно стоит проговорить, чего эти меры не решают полностью. Rate-limit и honeypot снижают объём автоматического злоупотребления, но не останавливают целенаправленную атаку с медленным темпом запросов, имитирующим человека — против такого нужен уже полноценный WAF или капча с поведенческим анализом. А перенос на внешний релей не отменяет необходимости чинить сам код: если инъекция заголовков осталась в скрипте, она просто переедет вместе с формой на новый транспорт.
Если IP уже попал в список, полезно заранее прочитать про восстановление репутации после попадания в чёрные списки — там разобрано, сколько времени обычно занимает делистинг у разных провайдеров и как вести себя, пока IP всё ещё числится в списке. А если при диагностике возникнет обратная ситуация — легитимная почта начнёт получать Relay access denied уже от собственного Postfix, — это отдельная тема, разобранная в статье про ошибку relay access denied, и с переполнением очереди она тоже связана — подробности в материале про переполненную очередь Postfix.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Как быстро понять, что форма используется для инъекции заголовков, если снова заметны подозрительные письма?
Проверьте очередь Postfix (postqueue -p) на письма с получателями, которых не должно быть, и сверьте с access-логом nginx на аномальное число POST-запросов к скрипту формы за короткий период. Если письма создаются через pickup/sendmail, а не приходят по SMTP снаружи — источник почти всегда локальный скрипт.
Достаточно ли просто закрыть форму капчей, не трогая код?
Капча снижает объём автоматических запросов, но не убирает саму уязвимость — если поле email по-прежнему подставляется в заголовки без экранирования, злоумышленник может обойтись одной ручной отправкой через капчу и точечно использовать инъекцию. Валидацию и переход на библиотеку для отправки почты нужно делать в любом случае.
Почему Postfix, который был настроен правильно, не защитил от этой атаки?
Потому что защита от открытого релея в Postfix работает на уровне приёма писем по SMTP от внешних хостов, а атака шла через доверенный локальный канал отправки (sendmail/pickup), которым пользуется сам PHP на этом же сервере. Postfix в принципе не может провалидировать содержимое, которое ему передаёт локальный доверенный процесс — это зона ответственности приложения.
Сколько по времени обычно длится попадание в чёрный список после подобного инцидента?
Зависит от конкретного списка и от того, сколько получателей пожаловались на спам за короткое время — иногда это часы, иногда меньше суток. Формальной гарантии нет, поэтому лучше не доводить до этой стадии — мониторинг очереди и лимиты на форму дешевле, чем восстановление репутации домена.
Нужно ли переносить весь сайт на другой сервер, если IP попал в чёрный список?
Не обязательно — если проблема была в коде формы, а не в самом сервере, достаточно устранить уязвимость и дождаться делистинга. Перенос на новый IP имеет смысл, если делистинг затягивается или репутация IP уже была подпорчена предыдущими арендаторами (актуально для дешёвых shared-тарифов) — в этом случае выделенный сервер с чистым IP снимает вопрос сразу.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →