Ещё один день на тех же пяти серверах: забытый exit(‘stop’), бан собственного редактора и кэш-кризис сразу на двух машинах

Раньше уже разбирал хаотичную первую неделю на пяти серверах клиента после переезда на ISPmanager. Тогда было три пожара в первый же день, повторные инциденты на одном домене по трём разным причинам, security-аудит и баг с таблицей на 5,3 миллиона строк. Неделю спустя, когда казалось, что флот наконец устаканился, случился ещё один день, который стоит разобрать отдельно: забытая строка кода, fail2ban не в ту сторону и кэш-кризис сразу на двух серверах. Три независимых происшествия - и в каждом неочевидная деталь, из-за которой первая версия диагноза оказывалась неверной.

Сайт отдаёт только слово «stop»

Скриншот от клиента: страница profhub.example - белый фон, единственная строка текста «stop». Первая проверка с самого сервера обманула:

curl -sS -o /dev/null -w "HTTP:%{http_code} size:%{size_download}\n" -H "Host: profhub.example" http://127.0.0.1/
# HTTP:200 size:896 - но тело оказалось дефолтной страницей "Welcome to nginx!"

Локальный curl с самого сервера на себя - уже известная на этой инфраструктуре ловушка (упоминал её в прошлой статье), но в этот раз она проявилась иначе: домен резолвился не туда, куда я ожидал. Настоящая публичная запись:

dig +short @8.8.8.8 profhub.example
# 203.0.113.16   <- не .15, хотя весь предыдущий разбор шёл на .15

При этом .16 - второй публичный IP того же физического сервера (ss -ltnp | grep 443 показывает бинды и на .15:443, и на .16:443 от одного и того же процесса nginx). Проверка через правильный адрес сразу воспроизвела баг:

curl -sS -k --resolve profhub.example:443:203.0.113.16 https://profhub.example/ -A "Mozilla/5.0"
# stop
# <!-- WP Optimize page cache - ... - page NOT cached -->

Комментарий кэширующего плагина в ответе - доказательство, что «stop» - настоящий вывод WordPress, а не блокировка на уровне nginx. Дальше - прямой поиск по коду активной темы:

grep -n "stop\|^die\|^exit\|wp_die" wp-content/themes/chromium/functions.php | head
# 5:exit('stop');
<?php

//var_dump('123');

exit('stop');

stat показал: файл правлен накануне в 17:43, владелец - разработчик клиента через FTP, не я. Судя по соседней закомментированной строке, разработчик проверял, что functions.php вообще подключается, поставил exit('stop') как маркер «дошли досюда» и забыл убрать. Без всякого условия это убивало вообще каждую загрузку сайта - не только profhub.example, но и все 7 доменов-алиасов на том же движке (facade-perm.example, facade-kazan.example и ещё пять похожих региональных алиасов), поскольку все шарят один functions.php.

Убрал строки с подтверждением (чужой код, сначала бэкап):

f=wp-content/themes/chromium/functions.php
cp "$f" "$f.bak-$(date +%Y%m%d%H%M%S)"
sed -i "/^exit(.stop.);$/d; /^\/\/var_dump(.123.);$/d" "$f"
php -l "$f"   # No syntax errors detected

Проверка тем же способом (правильный IP через --resolve) - HTTP:200, нормальный HTML вместо «stop».

Независимая проверка того же URL сразу после фикса всё ещё показывала «stop» - не потому что фикс не сработал. У сервиса, которым проверялась доступность, свой 15-минутный кэш на одинаковый URL, а запрос ушёл буквально минуту назад, до правки. Прямой curl с сервера - источник истины в таких случаях, повторный внешний фетч в первые минуты после фикса можно игнорировать.

fail2ban забанил собственного редактора сайта

По запросу клиента проверил, не забанен ли IP его собственного сотрудника - и нашёл его в джейле recon-probe на сервере ridgehub.example. Точное время бана - через постоянное хранилище fail2ban:

fail2ban-client get recon-probe banip --with-time | grep 198.51.100.42
# 198.51.100.42   2026-09-04 15:41:56 + 604800 = 2026-09-11 15:41:56

Архивные access-логи нашлись через zgrep. Картина: реальный залогиненный редактор товаров (правка цен через штатный импорт, стандартные вызовы WooCommerce REST API) - и среди этих легитимных вызовов был GET /wp-json/wp/v2/users/me?context=edit&_locale=user, который WordPress сам автоматически шлёт при каждом открытии редактора товара («кто я»). Причина бана fail2ban-джейлом - сам фильтр:

failregex = ...(wp-json/wp/v2/users|/wp/v2/users\?|cgi-bin/|...)...

Первая альтернатива (wp-json/wp/v2/users, без якоря) матчит вообще любой путь под /users/, включая безобидный /users/me. Вторая альтернатива (/wp/v2/users\?) уже сама по себе покрывает настоящую цель - неавторизованную энумерацию (/wp-json/wp/v2/users?capabilities=edit_posts). Она не матчит /users/me?..., поскольку там после «users» идёт «/me?», а не «?». Первая альтернатива оказалась избыточной и вредной одновременно.

fail2ban-client set recon-probe unbanip 198.51.100.42
sed -i 's#wp-json/wp/v2/users|##' /etc/fail2ban/filter.d/recon-probe.conf
fail2ban-client reload recon-probe

Разбанил, поправил фильтр, раскатал на весь флот.

При проверке остальных исторических банов по тому же джейлу нашёлся второй похожий случай на сервере pavehub.example - реального человека, тоже разбанил. Но заодно обнаружился паттерн, который выглядел похоже, да не совсем: несколько IP из разных дата-центровых блоков с одним и тем же путём, одним и тем же поддельным именем и одним и тем же User-Agent, но каждый раз с другого IP.

Живой человек не пересаживается между дата-центрами на каждый запрос - это автоматический скрипт со спуфленной личностью через ротацию прокси. Раз этот путь без авторизации всё равно ничего не палит, банить его не вредно, но и разбанивать незачем - эти баны оставлены как есть.

Диск на 100% в четвёртый раз - и не только на одном сервере

Классическое «не работает» - но на этот раз виноват не access/error-лог (маленькие, отдельный вотчдог уже держит их в узде) и не MySQL:

df -h /                              # 154G/154G, 100%
du -sh /var/www/.../wp-content/cache
# 71G, 475889 файлов

Тот же паттерн с кэшем, что чинил неделей раньше на другом сайте того же флота, вернулся на fence-hub.example, несмотря на развёрнутый суточный крон-предохранитель. Крон реально отрабатывал каждый день. Но при таком темпе роста (боты со спуфленным Host-заголовком создают бесконечно уникальные ключи кэша, помноженные на ~60 доменов-алиасов) суточный запуск с порогом «старше 3 суток» физически не успевает: файлы накапливаются быстрее, чем доживают до возраста удаления.

Аварийная очистка вернула диск со 100% к 54%. Но сайт после этого не сразу ожил: curl до главной занял 14.5 секунды, load average 23.83 на 8-ядерном сервере, оба пула PHP-FPM забиты под завязку. Проверил сначала MySQL (SHOW PROCESSLIST - все запросы Time=0, ничего не висит) и убедился, что дело не в базе.

Массовая очистка обнулила кэш целиком. Следующая волна запросов - краулеры поисковиков, синхронно проверяющие robots.txt сразу по десяткам доменов-алиасов, плюс обычный трафик - превратилась в «холодный старт»: каждый запрос требует полного рендера вместо отдачи из кэша, и имеющихся воркеров на ~60 доменов не хватило. Ситуация сама выправилась в течение ~30 минут по мере прогрева кэша. Проверив память перед поднятием лимита (своп уже частично занят - сигнал не игнорировать), поднял pm.max_children для этого сайта с 20 до 40, не резко до предела.

Разбор источника нагрузки: реклама, а не боты - но нашёлся настоящий брутфорс

По просьбе клиента разобрал топ-IP и топ-User-Agent за окно инцидента. Бóльшая часть трафика оказалась не мусором, а настоящей платной рекламой: реальные мобильные UA, referrer с меткой клика по контекстной рекламе, заходы на живые товарные страницы по десяткам доменов-алиасов. Именно поэтому холодный кэш ударил так больно - это ценный трафик, не боты.

Но один паттерн оказался настоящей атакой: из 5000 последних запросов 27% - к wp-login.php, два IP дали 96% таких обращений:

962 161.118.255.220
344 51.170.91.251

User-Agent у обоих менялся от запроса к запросу (Firefox → Chrome → Safari подряд - верный признак скрипта, не браузера). 98 запросов из выборки - реальные POST (попытки входа, не просто открытие формы), по разным доменам-алиасам через redirect_to=.

Готового джейла под это не было - существующее правило матчит только xmlrpc.php. В отличие от него, wp-login.php - легитимная страница (админы реально логинятся), поэтому нужен не жёсткий блок на nginx, а именно rate-limit-джейл fail2ban с порогом, оставляющим большой запас для настоящего человека:

[wp-login-flood]
enabled = true
filter = wp-login-flood
logpath = /var/www/httpd-logs/*.access.log
maxretry = 15
findtime = 120
bantime = 86400

Расчёт порога: живой человек за 2 минуты на странице логина - это открытие формы, пара опечаток в пароле, может пара обновлений страницы, от силы 5-7 запросов. 15 запросов за 120 секунд физически недостижимо для человека, но с большим запасом ниже объёма атаки (900+). Срок бана - сутки, а не неделя, как у других джейлов флота: страница легитимная, ошибку срабатывания не хочется тянуть долго. Протестировал фильтр на реальном логе перед раскаткой (1660 совпадений на выборке 20000 строк) - и только потом развернул на все 5 серверов.

При заливке конфига поймал два отдельных технических капкана. Первый: флаг -n у ssh сам обнуляет stdin, из-за чего конфиг, переданный через cat > file < локальный_файл, лёг на сервер пустым файлом - решение в отказе от -n для файловых операций. Второй: при попытке залить конфиг через scp на сервер pavehub.example файл записался нулевыми байтами. Оказалось, диск на этом сервере тоже был на 100% прямо в этот момент, независимо от инцидента на fence-hub.example - той же болезнью с кэшем (90 ГБ в wp-content/cache). После очистки конфиг залился нормально.

Два независимых инцидента с идентичной причиной на двух разных серверах в один день, несмотря на исправно работающий ежедневный крон - явное подтверждение, что порог «старше 3 суток, раз в сутки» не поспевает за темпом роста на высокотрафичных алиас-сетках. Ужесточил порог фронтально по всему флоту до одних суток.

Итог

Три происшествия за один день, и в каждом первая версия объяснения оказывалась не совсем точной: «фикс не сработал» на деле было кэшем сервиса проверки, а не провалом правки; «бан атакующего» на деле был легитимным вызовом собственного редактора; «просто разросшийся кэш» на деле оказался ещё и настоящим брутфорсом на соседнем домене.

Ни один из трёх предохранителей, развёрнутых неделей раньше, не подвёл технически - крон отрабатывал, вотчдог логов держал диск, джейлы fail2ban банили реальных ботов. Но масштаб трафика на алиас-сетках вырос быстрее, чем были рассчитаны их пороги. Это тоже стоило зафиксировать как отдельный вывод, а не разовую починку.

Выводы

  1. Независимая проверка через внешний сервис мониторинга может показывать старое состояние сайта ещё несколько минут после реального фикса, если у неё свой собственный кэш ответов - прямой curl с сервера остаётся источником истины.
  2. Многодомный сервер может иметь несколько публичных IP, и домен не обязан резолвиться на «основной» - прежде чем тестировать сайт локальным curl’ом, стоит проверить реальную DNS-запись и то, на каких адресах реально слушает nginx.
  3. Фильтр fail2ban, написанный на «путь содержит X», легко зацепит легитимные вызовы того же API-неймспейса - правило нужно писать под конкретный опасный паттерн, а не под префикс целого раздела API.
  4. Один и тот же объём трафика с одного IP не значит одного и того же злоумышленника, а высокий объём в целом не значит атаку - разбор конкретных строк лога (мутирующий User-Agent, referrer-метки рекламы, реалистичные мобильные UA) решает, какое лечение подходит.
  5. Предохранитель, рассчитанный на определённый темп роста, может перестать справляться без единой поломки в собственной логике - если объём трафика на защищаемых ресурсах вырос, порог нужно пересчитывать, а не считать однажды настроенную защиту вечной.
  6. Два независимых инцидента с одинаковой причиной на разных серверах в один день - это не совпадение, а сигнал, что проблема системная, а не локальная для одного сайта.

Похожий флот серверов, который вроде бы уже стабилизировался, но раз в неделю подкидывает новый сюрприз - знакомая история? Пишите, сравним.

Оставьте комментарий