Как майнер попал в прод, и что мы после этого перестроили в безопасности
Один из контейнеров, которому полагалось простаивать в фоне, вместо этого держал загрузку CPU на пределе. Майнеры прожорливы к процессору по своей природе, и спрятать это невозможно: рано или поздно нагрузка перестаёт сходиться с тем, чем сервис должен заниматься.
Внутри нашёлся CVE-2025-55182 — React2Shell: pre-auth RCE в протоколе RSC Flight у Next.js, CVSS 10.0, раскрыт 3 декабря 2025-го, уже эксплуатируется в дикой природе именно такими майнинг-нагрузками — предсказуемо, ведь украденное процессорное время монетизировать проще всего остального, что даёт pre-auth RCE. Next.js закрыл дыру в 15.0.5, 15.1.9, 15.2.6, 15.3.6, 15.4.8, 15.5.7 и 16.0.7.
Залатать этот контейнер было делом минут. Важнее было понять, сколько ещё сервисов во флоте несут ту же уязвимость. В проде около 40 сервисов, заметная доля на Next.js — вручную читать версию в package.json на каждом было не вариантом при нужной скорости. Небольшая автоматизация поверх Trivy сняла проблему скорости — прогнать весь флот параллельно оказалось быстрее, чем заходить в контейнеры по одному, — а дальше пошёл методичный аудит: тот же класс уязвимости проверялся на каждом образе, и по каждому найденному кандидату — одна и та же процедура: дерево процессов, CPU, установленные соединения.
#Что нашёл аудит
Trivy прошёлся по всем self-hosted сервисам и поднял ещё три контейнера с той же уязвимостью: cal (calcom/cal.com) на Next.js 15.3.3, formbricks (ghcr.io/formbricks/formbricks:v3.1.5) на 15.1.2, homarr на 15.4.5. У homarr нашлась и вторая проблема: CVE-2025-49844, use-after-free в Lua-интерпретаторе встроенного Redis 8.0.3-r0, закрытый только в 8.0.4-r0.
Все три прошли ту же проверку, что и первый контейнер: docker top на дерево процессов, docker stats на CPU, список установленных соединений — майнер не может не засветиться хотя бы в одном из трёх. Триаж вышел чистым: только ожидаемые процессы, CPU у нуля, соединения ограничены loopback и собственными базами. Ни майнера, ни лишних соединений, ни следа, что до контейнеров добрались раньше, чем их нашли.
Патчи проверяли не в проде: разовый docker run --rm на образ-кандидат и grep по node_modules/next/package.json внутри, только чтение — тег на слово верить нельзя. У cal.com и homarr дело было в отставании локального кеша, примерно на год и на одиннадцать месяцев соответственно, — ре-пул сразу подтянул пропатченные 16.1.5 и 16.2.9.
С formbricks хуже: линейка тегов v3.x умерла 17 месяцев назад, когда проект перешёл на теги без префикса, и навсегда осталась на уязвимом 15.1.2, пока актуальный стабильный 5.1.4 давно стоит на пропатченном 16.2.6. Переезд с v3 на v5 — два мажора сразу плюс два новых базовых сервиса, Hub и Cube, по гайду самого вендора, так что его развели отдельным проектом со своим таймлайном.
#cal.com лёг на ровном месте
Передеплой самого cal.com упёрся в отдельный баг — обычный вендорский, в свежей на тот момент сборке v6.2.0, никак не связанный с безопасностью. У cal двухступенчатый пайплайн: cal-db-migrate прогоняет Prisma-миграции, и только после их успеха стартует приложение. Миграции прошли чисто, а seed, запущенный следом в том же контейнере, тут же упал с Cannot find module './seed.ts' — баг разрешения путей в самом новом образе.
cal не поднимется, пока migrate не отработает без единой ошибки, а seed был частью того же шага, — так cal.deployka.ru не отвечал несколько минут. Спасло то, что seed заливает только дефолтные данные: на проде с реальными строками его можно спокойно пропустить. Пропуск снял блокировку сразу, а настоящим фиксом стало убрать npx prisma db seed из migrate насовсем.
Деталь на будущее: кнопка передеплоя на платформе умеет подсунуть закешированный compose из своей базы и отрапортовать успех вхолостую — честный git clone форсирует только прямой POST на deploy-вебхук с пейлоадом в форме пуша.
#OpenBao
Самое крупное структурное изменение — self-hosted OpenBao, Vault-совместимый секрет-менеджер, раскатанный на весь флот, около 40 сервисов. Логика простая: контейнер с секретами открытым текстом в окружении отдаёт их любому, кто получил в нём выполнение кода, — что и произошло с майнером. Теперь каждый секрет спрятан за ссылкой bao://, обычный конфиг как был в переменных окружения, так там и остался.
В каждом контейнере первым процессом, PID 1, стоит baoenv — статически слинкованный Go-шим: резолвит ссылки bao://mount/path?field через AppRole-креды сервиса, вычищает их из окружения и только потом делает exec в приложение, которое получает уже готовые, зарезолвленные значения.
Роллаут ненадолго что-то сломал: разбивку общей Docker-сети на сети под каждый сервис — подготовку к сетевой изоляции — выкатили в один репозиторий, а во второй, зависимый, забыли. Около 30 уже мигрировавших сервисов оказались в одном передеплое от потери пути к секрет-стору. Заметили в тот же день, починили тем, что секрет-стор какое-то время держали и в старой сети, и во всех новых. Ни один потребитель не сломался.
#Изоляция баз данных
Раньше на каждое хранилище был один суперпользовательский credential, общий для всех сервисов, — удобно для скорости, плохо для безопасности: скомпрометированный сервис мог прочитать и изменить данные любого соседа, не только свои. Теперь у каждого сервиса своя роль с минимальными правами, привязанная к его собственной базе.
Скрипт, который это раскатывал, нёс баг мимо ревью: шёл с ON_ERROR_STOP, а перенос владения затрагивал сразу несколько таблиц за проход; Postgres отклоняет смену владельца у последовательностей под identity- и serial-колонками, и первая же отклонённая операция обрывала весь батч — так скрипт репортил успех по каждой базе, реально почти ничего не перенося. Поймали на живом переключении, когда вылезла настоящая ошибка прав там, где её быть не должно.
На последней проверке 9 из 54 потребителей баз переведены на свои credentials; инфраструктура под остальное — колоночная аналитика, кеш, вторая реляционная база — готова, но каждый следующий перевод теперь перепроверяют вручную.
#Сетевая изоляция
Сервисы на одном хранилище делили одну Docker-сеть, и скомпрометированный сервис мог дотянуться до чужой базы, просто зная хостнейм. Своя сеть на каждый сервис закрывает этот путь, но несколько раз проявила один и тот же тонкий баг Compose.
Собственное хранилище сервиса, например его личный Postgres, подключается к сети, через которую сервис достаёт секрет-стор, и Compose даёт этому контейнеру алиас, совпадающий с generic именем сервиса, — а если имя типовое, вроде postgres, оно тихо перекрывает такой же алиас настоящего общего хранилища для любого стороннего контейнера в той же сети. Соединение прилетает не в тот контейнер — ошибка, неотличимая со стороны приложения от обычного неверного пароля.
Так ловили четыре раза, один раз — на identity-provider самой платформы: обходной путь потребовал пересоздать контейнер, а пересоздание заодно применило второе, не связанное изменение в сетях — уже закоммиченное, но ни разу не применённое, из того же файла. Сервис аутентификации упал на две минуты, починили ручным network reconnect. Вывод: перед пересозданием контейнера критичного сервиса смотреть весь конфиг-файл на другие уже закоммиченные, но не применённые правки — пересоздание накатывает файл целиком.
#Rootless и по мелочи
Где получалось, образы перевели с root на непривилегированного пользователя: при повторной компрометации процесс внутри будет ограничен правами обычного пользователя, и радиус поражения окажется меньше уже на уровне контейнера. nginx этого блога — пример: nginxinc/nginx-unprivileged, uid 101 по умолчанию, никакого root-мастер-процесса, слушает непривилегированный порт внутри, а перевод порта наружу берёт на себя маппинг на хосте.
В пайплайн сборки добавились govulncheck, gosec и gitleaks — рядом с тем же Trivy, что поймал три контейнера выше, теперь тот же класс уязвимостей проверяется на каждой сборке. Все помеченные образы обновили: cal.com и homarr сразу же, formbricks — отдельным треком, когда переезд на v5 будет спланирован как следует.
Три из этих шести изменений по пути поймали собственный баг: разбивка сетей чуть не оборвала доступ тридцати сервисов к секрет-стору, скрипт переноса владения молча проваливал перенос прав, alias в Compose путал соединения между чужими базами. Но именно из-за всех шести вместе следующая компрометация одного контейнера обойдётся заметно дешевле, чем эта.