За ночь у меня удалили все 22 виртуальные машины

Привет! Меня зовут Максим Иванков, я развиваю школы робототехники и программирования для детей уже 9 лет. За это время вокруг них выросла достаточно приличная инфраструктура - два физических сервера, около полусотни виртуальных машин, десяток микросервисов и несколько отдельных проектов. Собирал я её сам, и это, как выяснилось, имеет прямое отношение к дальнейшему.
В ночь с третьего на четвёртое июня всё это едва не закончилось. Утром я обнаружил, что сайты не открываются, а на резервном сервере не осталось ни одной виртуальной машины из двадцати двух, причём удалены они были вместе с дисками.
Сегодня разберу этот инцидент по минутам: как туда зашли, что именно сделали, как я всё это восстанавливал и какие достаточно базовые вещи я делал неправильно. Забегая вперёд, ошибки там оказались уровня учебника, из тех, про которые все всё прекрасно знают и всё равно наступают.
Как выглядела инфраструктура
Чтобы дальше было понятно, стоит коротко рассказать про устройство.
Есть два физических сервера, назову их основным и резервным, и на каждом стоит гипервизор Proxmox, внутри которого крутятся виртуальные машины - примерно по два десятка на каждом. На виртуалках живут микросервисы, у каждого свой PostgreSQL, и всё это упаковано в докер.
Идея была вполне здравая: если один сервер падает, то прод переезжает на второй. Между ними настроена потоковая репликация баз, DNS управляется через Cloudflare, так что переключение в теории делается обычной сменой записей.
Плюс была отдельная небольшая VPS в Финляндии, которая использовалась как исходящий прокси для рабочих задач и параллельно держала точку для VPN-приложения. Вот с неё, собственно, всё и началось.
Но прежде чем перейти к самой ночи, стоит сказать пару слов о том, кто всё это строил.
Кто всё это строил
Прежде чем разбирать ошибку, стоит честно сказать, кто вообще стоял за этой инфраструктурой, потому что это многое объясняет.
Я не программист в привычном смысле слова. Не девопс, не системный администратор и не разработчик. Профильного образования у меня нет вообще - по диплому я недоучившийся инженер-энергетик, и линии электропередач между городами мне ближе, чем гипервизоры.
Программировать я учился полностью самостоятельно. Ни каких курсов ни когда не проходил, учился по учебникам и видеоурокам в интернете, и главное - постоянно старался делать свои проекты. Причём не учебные пет-проекты, а работающие коммерческие штуки, которыми потом реально пользовались.

Путь был примерно такой, и он, думаю, многим знаком. Начал с HTML и CSS, как достаточно многие в то время. JavaScript изучил вскользь, потом понадобилось взаимодействие с базами данных, и судьба занесла меня в PHP, на котором я наделал прилично разных проектов.
Хостинг сначала был обычный, с готовой панелью управления внутри. Потом несколько раз брал VPS и разворачивал всё с нуля сам, но это была небольшая работа без каких либо серьёзных архитектурных шагов.
Всё изменилось, когда я замахнулся на проект на три головы выше своего тогдашнего уровня - написать собственную онлайн-школу, платформу с моими курсами по программированию и робототехнике, по которой я буду вести занятия в физическом детском центре.
Стало понятно, что на прежнем стеке это не построить. Изучил React и сделал на нём весь фронт, бэкендом отдельных сервисов выступил Flask на Python. Начал с архитектуры: прошёл несколько курсов, прочитал пару десятков книг, разобрался, что такое REST API и как вообще устроены платформы. Первые пару недель ушли просто на проектирование - рисовал карты, продумывал интерфейс, собирал прототипы страниц в Фигме.
Опыт до этого был совсем другого рода. Семь лет занятий по робототехнике и программированию, накопленные учебные программы, и даже собственный оцифрованный учебник - обычный исполняемый файл на Electron, который я раскидывал по компьютерам в классе. Ученики его открывали и видели уроки с описанием, что и как делать. Ни какого интерактива, авторизации, личных кабинетов и профилей там, естественно, не было.
А теперь надо было сделать всё это плюс внутреннюю экономику и задания с автоматической проверкой. Система была заметно сложнее, чем мои тогдашние возможности её реализовать. Я учился прямо по ходу дела, изучал технологии и паттерны, что то делал правильно, что то неправильно, а о чём то просто не знал.
Команды у меня не было. Всё с нуля я проектировал сам, и до сих пор так и работаю - я не знаю, что такое созвоны, дейлики и прочие странные слова. Я многостаночник, который умеет много где и на разный процент.
Когда дошло до вопроса, где всё это хостить, разбираться тоже хотелось самому. Так появился первый сервер, а примерно через полгода второй.
Основной - это два процессора Xeon E5-2667 v4, тридцать два потока и 251 гигабайт памяти DDR4 с коррекцией ошибок. Резервный - IBM System x3550 M4, восемьдесят ядер Xeon E7-4870 на четырёх сокетах и 503 гигабайта памяти.
Второй сервер появился, кстати, по вполне прозаичной причине: на первом периодически отключают электричество, иногда на сутки. Нужен был кто то, кто подхватит прод. По иронии именно он и стал целью, и он же в итоге всё и спас - только наоборот, прод переехал обратно на первый.
Зачем я всё это рассказываю. Дальше будет список ошибок, и все они базовые. Мне кажется важным, чтобы читатель понимал: это не история про то, как опытный админ проглядел тонкость. Это история про человека, который строил инфраструктуру по ходу того, как её изучал, и на определённом этапе она стала сложнее, чем его представления о том, как её защищать.
Ошибка, которую я допустил
Начну сразу с главного, потому что дальше вся эта история просто разматывается из одной точки.
У меня был один пароль. На всё.
Один и тот же пароль стоял на SSH всех виртуальных машин, на веб-панелях обоих гипервизоров, на той самой VPS и ещё в паре мест, при этом никакой ротации и никакой разницы между окружениями не было вообще. Плюс он был в открытом виде записан в рабочих файлах документации, и когда я потом делал проверку, нашлось 347 файлов, где он так или иначе фигурировал.
Это самая базовая и самая глупая ошибка, какую вообще можно совершить, она описана в любом учебнике по безопасности первым же пунктом. Я про неё прекрасно знал и всё равно наступил, просто потому что так было удобно, а инфраструктура росла постепенно, и каждый следующий сервер получал ровно те же настройки, что и предыдущий.
Второй ошибкой был секрет подписи токенов, зашитый прямо в исходники значением по умолчанию.

Выглядело это примерно так:
SECRET = os.environ.get('JWT_SECRET', 'K7x...9fQ')
Конструкция настолько привычная, что глаз её вообще не цепляет. Но смысл у неё при этом достаточно неприятный: если переменной окружения нет, то сервис не падает, а спокойно стартует с запасным значением прямо из кода. То есть секрет живёт в репозитории, а не в окружении, и любая утечка исходников автоматически становится утечкой прода.
Хуже того, этот секрет был общим на все десять сервисов, то есть один ключ подписывал токены вообще везде.
Хроника ночи
Теперь по порядку, что происходило.

Первое событие вообще выпадает из логики атаки, но именно оно оказалось достаточно показательным. Днём третьего июня умерла потоковая репликация баз между серверами, просто взяла и отвалилась. Никакого мониторинга на это у меня не было, алерт никуда не пришёл, и я узнал об этом уже постфактум, когда разбирал инцидент.
Дальше вечером был вход на ту самую прокси-VPS, где стоял парольный доступ по SSH, без ограничения попыток и без fail2ban. Пароль, понятное дело, знакомый - тот самый, единственный.
Через полчаса был вход на веб-панель резервного гипервизора, и тем же самым паролем. На гипервизоре при этом стояли настройки, которые я сейчас перечитываю с содроганием: разрешён вход под root, разрешена парольная аутентификация, fail2ban не установлен, а файрвол пропускает вообще всё.
А дальше начинается самое интересное, потому что атакующий не полез сразу всё ломать. Примерно в три ночи по Москве пошли действия через обычное публичное API моего же сайта.
Имея на руках секрет подписи, он сгенерировал себе токен с идентификатором первого пользователя, то есть моим. И дальше просто слал обычные HTTP-запросы, на которые система отвечала ему как владельцу проекта.
За несколько минут в базе появилось пятнадцать банов, включая бан меня самого на моём же сайте, четыре оскорбительные записи в разделе вопросов и ответов, четырнадцать чужих записей, помеченных как выполненные от моего имени, и два с половиной десятка скрытых сообщений и комментариев.
А вот уже после этого он вернулся на гипервизор, создал через веб-панель несколько своих виртуалок, поставил майнер в автозапуск и повесил системный юнит, который циклом прошёлся по всем идентификаторам машин с командой уничтожения вместе с дисками.
Двадцать две виртуальные машины примерно за десять минут.
Проснулся я часа через три и обнаружил, что вообще ничего не работает.
Почему обычная форензика тут не работает
Отдельно стоит рассказать про расследование, потому что это оказалась, пожалуй, самая интересная техническая часть всей истории.
Проблема была в следующем. Гипервизор уничтожен вместе со всеми логами, и смотреть журналы входа физически негде, а прод при этом не был взломан вообще. На основной сервер ни кто не заходил, никакие пароли там не подбирались, никаких подозрительных сессий не было.
Всё, что атакующий сделал с данными, он сделал через штатное публичное API с абсолютно валидным токеном. И система честно записала это как мои собственные операции - с моим идентификатором, с нормальными временными метками, без единого признака вторжения.

То есть искать чужой доступ тут бесполезно, потому что чужого доступа не было в принципе. Был мой доступ, которым воспользовался не я.
Сработало в итоге вот что. Практически в каждой таблице у меня есть служебные поля - кем забанено, кем скрыто, кем изменён статус, и обычно они нужны для админки, чтобы понимать, кто что делал. Оказалось, что именно они и есть единственный источник правды в такой ситуации.
Запросы были примерно такого вида:
SELECT * FROM publish_bans
WHERE banned_by = 1
AND created_at > '2026-06-03 23:30';
Прогнав такие выборки по всем таблицам с фильтром по окну атаки, я получил точный список того, что было сделано. Пятнадцать банов, четырнадцать подменённых записей, двадцать восемь скрытых сообщений и комментариев.
И главное, я получил ответ на вопрос, куда ещё он мог залезть. Из десяти баз следы нашлись только в одной, а в остальных восьми не было ни одной записи в окне атаки. То есть атака была направлена на один конкретный сервис, а всё остальное он попросту не тронул.
Это достаточно сильно упростило восстановление, потому что вместо тотального отката всего подряд я откатывал ровно одну базу.
Как восстанавливал
Дальше по шагам, и тут я, честно говоря, до сих пор считаю, что мне достаточно сильно повезло.
Первое - локализация. Прокси-VPS, через которую был вход, я потушил у провайдера, а резервный сервер просто выключил физически.
Второе - переезд прода. Все восемнадцать доменов через API Cloudflare переключил на основной сервер, и это заняло около часа с учётом ожидания обновления записей.
Третье - ротация всего. Пароли на восемнадцати виртуалках, причём теперь разные на каждой, тридцатидвухсимвольные. Пароли ко всем девяти базам. Секрет подписи токенов на всех десяти сервисах - после этого токен атакующего просто перестал существовать. Токен управления DNS, ключи доступа к внешнему хранилищу.
Четвёртое - откат базы. У меня были почасовые дампы, и в итоге нашёлся дамп, сделанный за тринадцать минут до начала атаки. Откатил базу на него, а пользовательский контент, который появился уже после, влил обратно поверх через отдельную схему.
Пятое - закрытие дыр. Отключил парольную аутентификацию, оставил только ключи. Запретил вход под root. Ограничил количество попыток. Поставил fail2ban. Настроил файрвол по принципу «запрещено всё, кроме явно разрешённого».
Шестое - переустановка. Резервный сервер я переставил полностью с нуля, потому что доверять машине, на которой стоял чужой майнер и чужие системные юниты, нельзя в принципе.
И отдельным пунктом - чистка тех самых 347 файлов от паролей.
Сколько это заняло на самом деле
Тут хочу привести честные цифры, потому что в планах аварийного восстановления обычно пишут достаточно красивые, а реальность выглядит несколько иначе.

Обнаружение заняло около трёх часов, просто потому что я в это время спал. Никакого мониторинга, который разбудил бы меня ночью, у меня тогда не было.
Дальше локализация полчаса, переключение DNS час, работающий прод через шесть часов, полностью вычищенная база через двенадцать, закрытая защита через четырнадцать и готовый к восстановлению резервный сервер через восемнадцать.
И вот что здесь важно понимать. Все эти цифры получены при условии, что второй сервер уже стоял, был настроен и на нём лежали копии данных. Если бы его не было, то счёт шёл бы не на часы, а на недели, и часть данных не вернулась бы вообще никогда.
Что спасло, а что чуть не убило

Начну с того, что спасло.
Второй физический сервер. Достаточно банально, но именно это превратило катастрофу в просто очень неприятный день, потому что прод переехал за час.
Внешнее хранилище бэкапов. И вот тут самое интересное. До этого копии баз лежали на виртуальной машине внутри резервного сервера, то есть на том самом железе, которое в итоге уничтожили. Атакующий снёс их вместе со всем остальным, даже не зная, что это были бэкапы.
Спасло меня в итоге только то, что за пять дней до атаки я поднял выгрузку во внешнее хранилище. Совершенно случайное совпадение, просто в какой-то момент дошли руки, и не сделай я этого - разговор был бы совсем другой.
Почасовые дампы. Именно они дали возможность откатиться на тринадцать минут до атаки, а не на сутки.
Узость атаки. Из десяти баз пострадала одна.
Теперь то, что чуть не убило. Единый пароль на всё, секрет в коде значением по умолчанию, парольный вход и root-логин на гипервизоре без каких-либо ограничений, бэкапы на том же самом железе. И умершая за сутки до атаки репликация, которую я не заметил просто потому, что ни какого мониторинга не было.
Выводы, которые я сделал
Постараюсь без банальностей и только про то, что реально поменял у себя.
Секрет не должен иметь значения по умолчанию. Сервис обязан падать при старте, если секрета нет, потому что падение - это ошибка, которую видно сразу и чинишь за минуту. А вот стартовавший с запасным ключом сервис - это дыра, о которой ты узнаёшь через полгода.
Один секрет - один сервис. Общий ключ подписи на десять сервисов означает, что утечка из самого незначительного из них компрометирует все остальные.
Бэкап на том же железе - это не бэкап. Это просто копия, которая умирает вместе с оригиналом, и правило трёх копий существует далеко не просто так, а внешнее хранилище в нём совсем не опция.
Тишина мониторинга не равна отсутствию проблем. Репликация умерла за сутки до атаки, и я узнал об этом только при разборе инцидента. Если система не умеет сообщать, что она сломалась, то её состояние вам просто неизвестно, а вовсе не хорошо.
Служебные поля в базе - это готовый инструмент расследования. Я добавлял их когда то для админки, а пригодились они совсем для другого. Если у вас в таблицах пишется, кто и когда совершил действие, то вы сможете восстановить картину даже полностью без логов.
Парольная аутентификация на сервере - это вход. Не удобство и не временное решение, а именно вход, которым однажды кто-нибудь воспользуется.
И последнее, самое неприятное. Я ведь всё это знал заранее, каждый пункт из этого списка - совершенно общеизвестная вещь, про которую написано в любом руководстве. Но знать и делать - это разные вещи, и вот эту дистанцию я в итоге прошёл за одну ночь.
Подведём итоги
Инфраструктуру я строил сам, без профильного образования, курсов и команды, учась по ходу дела. На определённом этапе она стала сложнее моих представлений о том, как её защищать.
Один пароль стоял на SSH всех машин, обеих панелей гипервизоров и внешней VPS. Он же в открытом виде лежал в 347 файлах документации.
Секрет подписи токенов был зашит в код значением по умолчанию и общим на десять сервисов. Утечка кода означала утечку прода.
Прод при этом не взломали: атакующий сгенерировал токен владельца и работал через штатное публичное API, а система записала всё как мои операции.
Двадцать две виртуальные машины удалены циклом вместе с дисками примерно за десять минут. Обнаружил я это через три часа, потому что спал.
Расследование пошло не по логам, а по служебным полям в базе - кем забанено, кем скрыто, с фильтром по окну атаки. Из десяти баз следы нашлись в одной.
Бэкапы лежали на уничтоженном сервере. Спасло только внешнее хранилище, поднятое за пять дней до атаки по чистой случайности.
Фактическое время: прод поднят за шесть часов, база вычищена за двенадцать, защита закрыта за четырнадцать. И это при уже готовом втором сервере.
Другие статьи серии
Спасибо, что дочитали. Буду рад услышать похожие истории в комментариях - особенно интересно, кто как решает вопрос с хранением секретов и есть ли у вас мониторинг, который реально будит ночью.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.