Трендовые github проекты в нашем телеграм канале. Подпишись → 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, закройте порт и проверьте контейнеры на следы взлома.