Открытый порт базы: как одна строка в compose превращает сервер в майнинг-ферму
Разбор настоящего инцидента: от деплоя до заражения прошло восемь часов. Что именно было не так, почему firewall не спас и как проверять, а не надеяться.
miki/otkrytyy-port-bazy-kak-odna-stroka-v-compose-prevraschaet-se · v1
Разбор настоящего инцидента: от деплоя до заражения прошло восемь часов. Что именно было не так, почему firewall не спас и как проверять, а не надеяться.
Реальный случай, все опознавательные детали убраны.
| Когда | Что |
|---|---|
| День 1, 08:07 | Задеплоен compose-файл с прокинутым портом PostgreSQL |
| День 1, 16:29 | Первое срабатывание OOM killer — уже майнер |
| Дни 1–6 | Криптомайнер работает, никто не замечает |
| День 6, 14:14 | Найден и удалён |
| День 6, 14:45 | Порт закрыт |
От деплоя до атаки — восемь часов. Не месяц, не неделя. Восемь часов. Никто сервер не искал: боты круглосуточно перебирают весь диапазон адресов и стучатся в стандартные порты.
Украдено за это время: 2.3 ГБ оперативной памяти и 162% процессора — постоянно, шесть суток.
Виновата была одна строка.
Выглядит безобидно. Но в этой записи не указан адрес хоста, а значит Docker подставляет 0.0.0.0 — «слушать на всех сетевых интерфейсах». То есть на публичном адресе сервера. То есть из интернета.
Три формы записи и что они на самом деле значат:
| Запись | Кто может подключиться |
|---|---|
"5432:5432" | весь интернет |
"127.0.0.1:5432:5432" | только сам сервер |
раздела ports нет вообще | только контейнеры в той же docker-сети |
Правильный ответ для базы данных — третий. Приложению порт наружу не нужен: оно ходит в базу по имени сервиса внутри сети Docker.
Это не магия и не дыра в PostgreSQL. Это штатная возможность, которой воспользовались:
COPY ... TO PROGRAM умеет запускать программы на сервере — так и задумано, это фича для выгрузки данных через внешний обработчик. Доступна суперпользователю и обладателям роли pg_execute_server_program. А postgres из стандартного образа — как раз суперпользователь.
Отсюда правило: доступ к порту базы данных равен возможности выполнять команды на сервере. Не «прочитать данные» — а именно выполнять команды. Относиться к нему надо так же, как к доступу по SSH.
Проверка
Не в compose-файле, а в системе:
Читаем колонку с адресом:
Если ss нет, подойдёт netstat -tlnp.
ss -tlnp | grep -E '5432|6379|3306|27017'- Ни одна база не слушает на 0.0.0.0
- Ты смотрел вывод команды, а не только конфиг
Самая неприятная часть. Типичная логика: «порты открыты, но у меня стоит ufw и всё лишнее закрыто». Это не работает.
Из документации Docker:
Docker routes container traffic in the
nattable, which means that packets are diverted before it reaches theINPUTandOUTPUTchains that ufw uses.
Перевод: Docker направляет трафик контейнеров в таблице nat, и пакеты уходят по назначению до того, как доберутся до цепочек INPUT и OUTPUT, с которыми работает ufw.
То есть ufw deny 5432 создаёт ощущение защиты, ничего при этом не закрывая. Порт останется доступным, а ты будешь уверен в обратном — это хуже, чем знать, что защиты нет.
Вывод: единственная надёжная защита прокинутого порта — не прокидывать его. Не «закрыть файрволом», а не публиковать вовсе.
Проверяется это одной командой с другой машины:
Ответ succeeded означает, что порт открыт, что бы ни показывал ufw status.
Проверка
В compose-файле удали раздел ports целиком у всех сервисов, к которым не должны обращаться снаружи: базы, кэши, очереди, внутренние API.
Затем пересоздать контейнеры, а не просто перезапустить:
После этого снова ss -tlnp — порт должен исчезнуть.
docker compose up -d --force-recreate- Раздел ports убран у всех внутренних сервисов
- Контейнеры пересозданы, а не перезапущены
- ss -tlnp больше не показывает эти порты
- Приложение по-прежнему работает — оно ходит по имени сервиса
Если уже заражён
Типовые признаки майнера:
В разобранном случае процессы принадлежали пользователю postgres и лежали в /tmp — сочетание, которого в норме не бывает.
ps aux --sort=-%cpu | head -10- Проверены процессы, cron и автозагрузка
- Найдено, от какого пользователя работал процесс — это указывает на точку входа
Если кто-то выполнял команды на сервере, он мог прочитать всё, к чему имел доступ: переменные окружения контейнеров, файлы .env, ключи, содержимое базы.
Менять надо:
- пароли всех баз и кэшей на этой машине;
- ключи подписи токенов — иначе выпущенные токены остаются валидными;
- ключи доступа к внешним сервисам: хранилище, платежи, почта;
- ключи развёртывания и токены доступа к репозиториям.
Пароли пользователей приложения — отдельный разговор: если утекла база с хешами, а хеши слабые, их придётся сбрасывать.
- Пароли баз изменены
- Ключи подписи токенов заменены
- Внешние ключи перевыпущены
Самое интересное в этой истории: конфигурация не была придумана. Она была скопирована из официальной документации фреймворка, на котором сделан проект. В разделе про запуск в Docker там стояло ровно "5432:5432" — и никакого предупреждения о том, что пример рассчитан на локальную разработку.
Это типично. Примеры в документации почти всегда пишут под «запустить у себя и посмотреть»: порты наружу, чтобы можно было подключиться клиентом, пароли вроде postgres, никаких ограничений. Для их задачи это правильно.
Отсюда два вывода на будущее.
Первый: пример из документации — это не конфигурация для боевого сервера. Между ними всегда есть работа: убрать порты, вынести секреты, ограничить права.
Второй, более важный: если ты нашёл такое в документации — заведи issue у авторов. Ты не единственный, кто скопировал этот кусок.
Четыре команды, полторы минуты. Инцидент выше стоил шести суток чужого майнинга и полного дня разбора.

