Хранили копии семь дней, а заражение нашли на девятый
У вас наверняка есть бэкапы. Вопрос не в этом. Вопрос в том, окажется ли под рукой хоть одна чистая копия в тот момент, когда она реально понадобится. Ниже — разбор реального инцидента конца августа 2026 года: сервер снимал ежедневные копии и хранил их семь дней, а заражение нашли только на девятый день после проникновения. К этому моменту ротация уже вытолкнула из хранилища все копии, снятые до заражения — восстанавливаться оказалось не из чего. Разберём, что показали логи, какие версии произошедшего отбросили по пути, в чём была настоящая причина и что в итоге поменяли в процессе бэкапов.
Содержание
Что случилось
Сервер — обычный продакшн-VPS: Nginx перед парой Node-сервисов, PostgreSQL, Redis для кеша сессий, деплой через CI по SSH. Ежедневно в 02:00 отрабатывал бэкап через borg create в отдельное хранилище, borg prune держал только последние семь ежедневных архивов — так исторически настроили ещё на этапе первого разворачивания сервера и с тех пор не пересматривали.
В четверг утром внешний мониторинг (проверка доступности плюс алерт на нагрузку) прислал два уведомления подряд: время ответа /api/health выросло с обычных 40–60 мс до 300–500 мс, а на графике CPU появилась ровная полка около 70% на одном из ядер, которая держалась без спадов — не похоже на обычный трафик, у которого нагрузка скачет вслед за запросами.
$ uptime
09:14:32 up 41 days, 3:12, 2 users, load average: 3.81, 3.42, 2.90
$ top -b -n1 | head -15
PID USER PR NI VIRT RES %CPU %MEM COMMAND
8841 www-data 20 0 612m 88m 71.3 1.1 node
2210 syslog 20 0 88m 12m 0.3 0.1 rsyslogd
9903 postgres 20 0 340m 120m 1.2 1.5 postgres
Процесс node с PID 8841 не был частью деплоя — по номеру порта он не слушал ничего из известных приложений, а в списке systemd-юнитов такого сервиса не числилось.
Что показали логи и метрики
Первым делом подняли историю метрик за две недели, чтобы понять, когда всё началось на самом деле, а не когда сработал алерт.
$ journalctl --since "9 days ago" --until "8 days ago" -u nginx | tail -30
$ zgrep -h "GET /wp-content\|POST /xmlrpc\|\.\./\." /var/log/nginx/access.log*.gz | wc -l
По логам Nginx девятидневной давности нашлась серия запросов к статическому эндпоинту загрузки файлов от одного и того же внешнего адреса — сначала пробные обращения с кодами 403 и 404, а затем один запрос с кодом 200, после которого через 40 секунд с того же адреса пришёл GET на новый путь в директории загрузок, которого раньше в логах не было.
$ grep "auth.log" -r /var/log/auth.log* | grep "Accepted"
$ last -F | head -20
По auth.log подозрительных SSH-входов не нашли — вход был не через SSH, а через уязвимость в веб-приложении, которая позволяла загрузить файл в директорию, из которой сервер отдавал содержимое как исполняемый код. Классический веб-шелл, оставленный в директории аплоадов, а не что-то экзотическое.
$ file /var/www/app/public/uploads/.cache/sys_a1f3.php
uploads/.cache/sys_a1f3.php: PHP script, ASCII text
$ stat /var/www/app/public/uploads/.cache/sys_a1f3.php
Modify: 2026-08-19 03:41:07
Дата модификации файла совпала с найденной серией запросов — девятью днями раньше момента, когда сработал алерт мониторинга. Всё это время файл спокойно лежал в директории загрузок, ничем себя не выдавая.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверГипотеза первая: списали на свежий деплой
Первая версия — банальная: накануне вечером выкатывали обновление, добавили новый воркер для фоновой обработки очередей. Решили, что рост CPU и задержек связан именно с ним: возможно, воркер зациклился или неэффективно обрабатывает большую очередь после деплоя.
$ systemctl status app-worker.service
$ journalctl -u app-worker.service --since "2 hours ago"
Логи воркера были чистыми, без ошибок и повторов, а его собственное потребление CPU держалось в пределах нормы — 5–8%. Отключили воркер полностью, подождали пять минут — нагрузка на CPU не изменилась. Гипотезу отбросили: посторонний процесс жил независимо от деплоя.
Гипотеза вторая: заподозрили легитимную фоновую задачу
Вторая версия — что кто-то из команды поднял на сервере разовую задачу вручную (например, миграцию данных или переиндексацию) и забыл про неё. Проверили список активных cron-задач и запущенных screen/tmux-сессий.
$ crontab -l -u www-data
$ crontab -l -u deploy
$ ls /etc/cron.d/
$ who
Ничего похожего не нашли: ни в пользовательском crontab, ни в /etc/cron.d/ подозрительных записей не было, активных screen- или tmux-сессий с чужими командами тоже. Стали смотреть на сам процесс 8841 внимательнее — через /proc.
$ ls -la /proc/8841/cwd
$ cat /proc/8841/cmdline | tr '\0' ' '
$ readlink /proc/8841/exe
Рабочая директория процесса указывала на /var/www/app/public/uploads/.cache/ — то есть внутрь загрузок, где не должно быть исполняемого кода вообще. Вторую гипотезу тоже отбросили: это была не забытая легитимная задача, а посторонний процесс, запущенный извне.
Настоящая причина: веб-шелл, который девять дней ничем себя не выдавал
Сложив факты, картина сложилась так. У приложения был эндпоинт загрузки пользовательских файлов, который проверял расширение файла по имени, но не проверял его реальное MIME-содержимое и позволял сохранять файл в директорию, из которой Nginx отдавал .php-файлы на исполнение (конфиг когда-то скопировали со старого проекта и не подрезали location-блок под текущую структуру). Атакующий нашёл эту связку, залил веб-шелл под безобидным именем в скрытую поддиректорию кеша и первые дни им почти не пользовался — судя по логам, заходил через шелл лишь эпизодически, разведывал окружение, искал конфиги с доступами к БД.
На девятый день с момента заливки атакующий запустил через шелл процесс, который начал майнить криптовалюту, — отсюда и ровная полка загрузки CPU, которая и попала в мониторинг. До этого момента заражение не создавало заметной нагрузки и не отправляло аномального трафика, поэтому не срабатывало ни на одну из штатных проверок.
$ sha256sum /var/www/app/public/uploads/.cache/sys_a1f3.php
$ grep -r "eval(\|base64_decode(\|system(" /var/www/app/public/uploads/.cache/sys_a1f3.php
В самом файле — типичная для веб-шеллов конструкция: небольшой загрузчик, который принимает команду параметром в запросе и исполняет её через system(), предварительно декодируя base64-payload. Ничего сложного, стандартный инструмент из тех, что массово раскладывают по уязвимым точкам входа автоматизированные сканеры.
Почему бэкапы не спасли
Первым инстинктивным шагом после обнаружения заражения было — откатиться на бэкап до момента компрометации. Тут и выяснилась вторая часть проблемы.
$ borg list /mnt/backup-repo
2026-08-27_02-00 # день обнаружения
2026-08-26_02-00
2026-08-25_02-00
2026-08-24_02-00
2026-08-23_02-00
2026-08-22_02-00
2026-08-21_02-00
borg prune был настроен с ключом --keep-daily 7 и без --keep-weekly и --keep-monthly — то есть в репозитории физически не могло быть архива старше семи дней, он удалялся автоматически на каждом прогоне. Веб-шелл появился на сервере 19 августа, а самый старый доступный архив на момент обнаружения датировался 21 августа — двумя днями позже заражения. В каждом из семи хранившихся архивов уже лежал файл sys_a1f3.php.
Формально бэкапы работали исправно: снимались каждую ночь, репозиторий не был повреждён, восстановление технически было бы успешным — просто восстановило бы сервер вместе с веб-шеллом. Это тот случай, когда сама по себе рабочая процедура бэкапа не решает задачу, если у неё недостаточная глубина хранения: про то, как проверять именно работоспособность копий, у нас есть отдельный разбор — как проверить, что бэкап рабочий. Здесь бэкап был рабочим, но бесполезным для этой конкретной задачи — вернуться в состояние до компрометации.
Дальше пришлось не «нажать восстановить», а разбирать сервер вручную: сверять хеши файлов приложения с эталонными из git-репозитория, вычищать директорию загрузок построчно, менять все секреты, к которым у процесса www-data теоретически был доступ (креды БД, ключи для внешних API, SSH-ключ деплоя), и только после этого поднимать сервис обратно.
$ git diff --stat HEAD -- app/
$ find /var/www/app/public/uploads -name "*.php" -newer /var/www/app/composer.lock
$ pg_dump --schema-only appdb | diff - schema_reference.sql
Сверка по git показала, что сам код приложения не менялся — атакующий работал только в директории загрузок, которая не версионируется. Это отчасти повезло: не пришлось поднимать весь сервер с нуля, восстанавливать копии проще, чем чистить каждую директорию руками. Но при этом стало ясно, что при более глубокой ротации откат занял бы пять минут вместо суток разбора.
Что изменили после инцидента
Часовая ротация в семь дней — довольно распространённая настройка по умолчанию во многих гайдах и шаблонах, но она рассчитана на защиту от случайной порчи данных или сбоя диска, а не на сценарий, где источник проблемы обнаруживают не сразу. Заражение может неделями не создавать заметной нагрузки, если атакующий не спешит — как и получилось в этом случае. Подробно про то, как выбирать глубину хранения под разные риски, разобрано в статье сколько хранить бэкапы и какая ротация нужна; здесь перечислим только то, что поменяли конкретно на этом сервере.
Ротацию углубили. Вместо плоских семи ежедневных копий теперь хранится многоуровневая схема:
borg prune \
--keep-daily 14 \
--keep-weekly 8 \
--keep-monthly 6 \
/mnt/backup-repo
Это даёт до полугода глубины для отката к состоянию до заражения, если инцидент обнаружится с задержкой, при разумном приросте объёма хранилища — еженедельные и ежемесячные копии не растут линейно с ежедневными за счёт дедупликации Borg.
Добавили офсайт-копию отдельным контуром. Раньше был единственный репозиторий бэкапов на соседнем VPS того же провайдера. Теперь копия реплицируется ещё и на внешнее хранилище с более длинным хранением и отдельными учётными данными, недоступными с продакшн-сервера на запись — по правилу «3-2-1», которое разобрано в статье правило 3-2-1 для бэкапов недорого. Смысл в том, что даже если атакующий получит доступ к продакшн-серверу и попытается дотянуться до бэкапов, у него не будет прав на их изменение или удаление.
Включили контроль целостности файлов приложения. Через aide настроили ежедневную сверку хешей директорий, где исполняемого кода в норме быть не должно (директории загрузок, кеша, временных файлов):
# /etc/aide/aide.conf.d/50_uploads
/var/www/app/public/uploads NORMAL
$ aide --check
Алерт по расхождению приходит в тот же канал, что и мониторинг нагрузки — это должно было сработать на девятый день раньше, чем нагрузка от майнера, потому что файл появился на сервере на неделю раньше, чем начал майнить.
Закрыли исполнение PHP в директории загрузок на уровне Nginx — той самой конфигурационной дыры, через которую всё началось:
location ^~ /uploads/ {
location ~ \.php$ {
deny all;
}
}
Периодически проверяют сервер на майнеры и посторонние процессы отдельным скриптом, а не только по алертам от системы мониторинга — как именно это делать, подробно расписано в статье как проверить сервер на майнер и вирусы.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Почему бэкап технически «работал», но не помог восстановиться?
Потому что задача бэкапа — не просто «файл существует и не битый», а «есть версия данных до момента, когда всё сломалось». Ротация в семь дней гарантирует первое, но не гарантирует второе, если инцидент обнаруживают позже недели.
Какую глубину ротации закладывать по умолчанию?
Единой цифры нет — зависит от того, как быстро вы в принципе способны заметить проблему на конкретном сервисе. Если мониторинг покрывает только доступность и нагрузку, а не целостность файлов, закладывайте запас в несколько недель, а не дней: пока не появится независимый механизм обнаружения, который сработает быстрее.
Как понять, что в бэкапах уже нет чистой копии?
Единственный надёжный способ — сверять контрольные суммы файлов приложения с эталоном (например, с git-репозиторием) на момент архива, а не полагаться на дату создания бэкапа как на гарантию чистоты.
Обязательно ли использовать именно Borg для многоуровневой ротации?
Нет, принцип «daily + weekly + monthly» реализуется практически в любом инструменте бэкапов — Restic, Duplicati, Kopia, urbackup и других — разница только в синтаксисе флагов хранения.
Что делать, если после чистки сервера нет уверенности, что вычистили всё?
Безопаснее поднять новый сервер с нуля из чистого образа и накатить туда только проверенный код из git плюс данные из бэкапа за пределами окна заражения, а не пытаться «долечить» скомпрометированный сервер — это дольше по времени, но заметно надёжнее.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →