Logo Craft Homelab Docs Нейросети Хостинг Обучение Контакты
Почему UFW не закрывает порты контейнеров Docker Трендовые github проекты в нашем телеграм канале. Подпишись →
2 ноября 2026 г.

Docker публикует порт в обход UFW: путь пакета, действия ботов и способы закрыть базу

На свежем VPS включён UFW: default deny incoming, разрешены только 22, 80 и 443. В docker-compose.yml для PostgreSQL и Redis прописано ports: - "5432:5432" и "6379:6379". ufw status показывает три разрешённых порта, а nc -zv <IP> 5432 6379 с внешней машины получает succeeded на оба.

Времени на исправление почти нет. На тестовой виртуалке Hetzner CPX21 с Debian 12 первый бот подключился к Redis через 1 минуту 52 секунды после старта контейнера и выполнил REPLICAOF на чужой IP. В issue #883 стартового проекта Spree Commerce с такими же портами описан майнер kdevtmpfsi, появившийся через несколько минут после запуска на чистом VPS.

Путь пакета через Netfilter

UFW складывает правила в цепочку INPUT таблицы filter. В неё попадают только пакеты, адресованные самому хосту.

Docker перехватывает пакет раньше. Он добавляет в PREROUTING таблицы nat правило DNAT, и адрес назначения меняется ещё до решения о маршрутизации:

tcp dpt:5432 to:172.18.0.2:5432

Теперь ядро видит адрес за виртуальным мостом и отправляет пакет транзитом в FORWARD. Строка deny (routed) в UFW тоже не срабатывает, потому что цепочки Docker стоят в FORWARD выше правил UFW:

1  DOCKER-USER
2  DOCKER-FORWARD
...
8  ufw-before-logging-forward
9  ufw-before-forward

Пакет получает ACCEPT на втором шаге и до строк 8 и 9 не доходит. В Docker старше 28-й версии цепочки называются иначе, результат тот же.

IPv6 открывает порт отдельно

С Docker 27.0 управление ip6tables включено по умолчанию. Если у сетей включён IPv6, правила DNAT появляются и там, поэтому запрет только в iptables оставляет порт доступным по IPv6.

Контейнер только с IPv4 тоже может быть доступен: docker-proxy слушает [::]:5432. UFW с настройками по умолчанию такой пакет отбросит в INPUT. При IPV6=no в /etc/default/ufw или скриптах только для iptables пакет дойдёт до docker-proxy и попадёт в контейнер. Проверка с внешней машины:

nc -6 -zv <IPv6_сервера> 5432

Что боты делают в базе

Сканеры masscan и zmap постоянно обходят диапазоны облачных провайдеров по портам 2375, 3306, 5432, 6379, 9200 и 27017. Открытый порт сразу попадает к скрипту автоэксплуатации, поэтому свежий IP без домена не защищает.

Redis. В официальном образе пароля по умолчанию нет. Часто первой идёт команда FLUSHALL. На redis:7-alpine команды модулей и DEBUG выключены, процесс работает от пользователя redis, и ущерб обычно ограничен потерей кэша и привязкой реплики к чужому хосту. Старые образы 5.x и 6.x, сборки от root и неудачно смонтированные тома дают боту больше:

  • подмена dir и dbfilename через CONFIG SET, чтобы записать SSH-ключ или задание cron;
  • CVE-2022-0543 в пакетах Debian и Ubuntu: выход из песочницы Lua через EVAL;
  • загрузка .so с фальшивого мастера через REPLICAOF и MODULE LOAD, после которой появляется команда для выполнения кода в контейнере.

PostgreSQL. Пары вроде postgres:postgres и postgres:123456 перебираются за секунды. Суперпользователю эксплойт не нужен: COPY ... FROM PROGRAM работает с версии 9.3 и запускает команду от системного пользователя postgres. Таблица cmd_exec и FROM PROGRAM с wget или curl в логах — признак взлома.

Дальше скрипт собирает uname -a, lscpu, free -m, nvidia-smi -L и lspci. Майнинг Monero на двух ядрах приносит копейки, поэтому особенно ценятся арендованные GPU-серверы: на них ставят бэкдор и продают доступ.

Как закрыть порт

Локальный бинд и internal-сеть

Этого хватает в большинстве случаев. Для подключения с хоста или через SSH-туннель укажите адрес явно:

ports:
  - "127.0.0.1:5432:5432"

Если бэкенд и база в одном compose-файле, секция ports у базы не нужна:

services:
  backend:
    networks: [frontend_net, db_net]
  db:
    image: postgres:15-alpine
    networks: [db_net]
networks:
  frontend_net:
  db_net:
    internal: true

Флаг internal: true ещё и отрезает базе выход в интернет: COPY ... FROM PROGRAM 'curl ...' не сможет скачать вредоносный файл.

Цепочка DOCKER-USER

Подходит, когда порт нужен конкретному внешнему серверу. Вызов этой цепочки стоит первым в FORWARD, и Docker не перезаписывает её содержимое при перезапуске:

iptables -F DOCKER-USER
iptables -A DOCKER-USER -i eth0 -m conntrack --ctstate RELATED,ESTABLISHED -j RETURN
iptables -A DOCKER-USER -i eth0 -s 203.0.113.10/32 -p tcp -m conntrack --ctorigdstport 5432 -j RETURN
iptables -A DOCKER-USER -i eth0 -j DROP
iptables -A DOCKER-USER -j RETURN

После DNAT обычный --dport видит внутренний порт контейнера, поэтому здесь используется --ctorigdstport: conntrack помнит исходный порт. Если Nginx или Traefik тоже работают в контейнерах, перед DROP добавьте такие же RETURN для 80 и 443 без ограничения по источнику. Те же команды продублируйте в ip6tables.

netfilter-persistent save при работающем Docker сохранит и временные цепочки контейнеров. Положите команды в скрипт и запускайте его systemd-юнитом с After=docker.service.

UFW-правила для контейнеров

Проект chaifeng/ufw-docker заворачивает DOCKER-USER в цепочки UFW через /etc/ufw/after.rules и after6.rules. Стандартный блок перечисляет приватные подсети. Если хостер использует серые адреса для внутренних нужд, надёжнее матчить внешний интерфейс eth0. Доступ открывается так:

ufw route allow proto tcp from 203.0.113.10 to any port 5432

В правиле указывается внутренний порт контейнера даже при пробросе 15432:5432, и оно действует для всех контейнеров с этим портом. Адрес конкретного контейнера можно закрепить через ipv4_address или поручить утилите ufw-docker allow.

Rootless Docker

Демону без root нечем менять правила на хосте. Опубликованный порт становится обычным сокетом пользователя, трафик идёт через INPUT, и правила UFW работают без правок. Цена — нагрузка на CPU на гигабитных каналах и потеря IP клиентов с драйвером builtin; другой драйвер задаётся через DOCKERD_ROOTLESS_ROOTLESSKIT_PORT_DRIVER.

Нативный nftables в Docker 29

Экспериментальный бэкенд "firewall-backend": "nftables" работает без DOCKER-USER, поэтому старые скрипты и ufw-docker с ним не подходят. Фильтр создаётся в своей таблице с приоритетом раньше Docker, семейство inet закрывает оба стека:

table inet my_docker_guard {
  chain forward_check {
    type filter hook forward priority filter - 10; policy accept;
    ct state established,related accept
    iifname "eth0" ip saddr 203.0.113.10 tcp dport 5432 accept
    iifname "eth0" drop
  }
}

tcp dport на этом хуке видит порт после DNAT. Для исходного порта есть ct original proto-dst.

Чего избегать и что добавить

Настройка "iptables": false в daemon.json действительно прекращает обход UFW, но вместе с правилами проброса исчезает MASQUERADE: контейнеры теряют интернет, а docker build падает на первом apt-get update.

Cloud Firewall в панели хостера отбрасывает пакеты до виртуалки, включите его первым рубежом. Бинд на 127.0.0.1 всё равно нужен: на bare-metal после переезда внешнего фильтра может не оказаться.

Проверка своего сервера

ss -tlpn | grep docker-proxy
iptables -t nat -L DOCKER -n -v
ip6tables -t nat -L DOCKER -n -v
iptables -t filter -L DOCKER-USER -n -v
ip6tables -t filter -L DOCKER-USER -n -v

0.0.0.0: или [::]: напротив порта базы означает, что бинд надо менять. При выключенном userland-proxy процессов docker-proxy не будет, поэтому смотрите правила DNAT. Единственный RETURN в DOCKER-USER означает, что порты из этих правил доступны всему интернету. На бэкенде nftables используйте nft list table ip docker-bridges.

Самый надёжный ответ даёт скан с внешней машины:

nmap -Pn -T4 --open -p 1-65535 <IP_сервера>
nmap -6 -Pn -T4 --open -p 1-65535 <IPv6_сервера>

Если там нашлись 3306, 5432, 6379, 9200 или 27017, закройте порт и проверьте контейнеры на следы взлома.