
Почему Docker-контейнеры по умолчанию дыра в безопасности
Вы набираете docker run -d -p 80:80 nginx, и через секунду у вас работает веб-сервер. Красиво. А дальше в голове происходит фокус, который никто не заказывал: слово «контейнер» тихо подменяется словом «песочница».
Подмена бесплатная и очень удобная. Если внутри песочница, то можно не думать о правах, не смотреть, от кого запущен процесс, не разбираться, что торчит наружу. Оно же в контейнере.
Проблема в том, что изоляцию обеспечивает не слово, а конфигурация. А конфигурация по умолчанию собрана не под безопасность, а под то, чтобы у вас всё запустилось с первого раза и вы не ушли к конкурентам. Это разумная цель для продукта и отвратительная для вашего сервера.
Docker containers are, by default, quite secure; especially if you run your processes as non-privileged users inside the container.
Документация Docker, раздел о безопасности
Перечитайте вторую половину фразы. Там условие, которое по умолчанию не выполнено.
🔓 Прав выдано по умолчанию
14 из ~40
🧱 Закрыто системных вызовов
44 из 300+
🔑 Образов с чужими ключами
8,5% из 337 тысяч
Кому пригодится: если держите на сервере хоть один контейнер; если ставили что-то из docker-compose.yml, найденного в интернете; или если у вас настроен ufw и вы считаете, что он работает.
Содержание
- Что контейнер изолирует, а что нет
- Внутри вы root, и это тот же root
- Четырнадцать прав, которые выдали молча
- Сорок четыре вызова из трёхсот
- Ваш файрвол выключен, а вы не в курсе
- Сокет демона: самый короткий путь к root
- Откуда вы вообще берёте образы
- Побеги из рантайма
- Что делать по-честному
- Частые ошибки
- Короткий итог
Что контейнер изолирует, а что нет
Начнём с механики, потому что без неё дальше будет непонятно, откуда берутся проблемы.
Контейнер собран из двух вещей, обе из которых есть в ядре Linux уже много лет. Пространства имён (namespaces) дают процессу отдельный вид на систему: свой список процессов, своя сетевая конфигурация, своё дерево файловой системы, своё имя хоста. Контрольные группы (cgroups) ограничивают, сколько ресурсов этот процесс может съесть: память, процессор, ввод-вывод.
Обе штуки хорошие. И обе отвечают на вопрос «что процесс видит» и «сколько он берёт», а вовсе не на вопрос «что процесс может сделать».
Вот о чём не думают вообще: ядро одно. Виртуальная машина приносит с собой собственное ядро, и чтобы выбраться из неё, нужно пробить гипервизор. Контейнер работает на вашем ядре, через ваши системные вызовы, с вашими драйверами. Граница между контейнером и хостом проходит не по железу, а по набору проверок внутри одного и того же ядра.
Из этого следует простая вещь. Любая дыра в ядре, которую можно дёрнуть системным вызовом, доступна процессу в контейнере ровно так же, как процессу на хосте. Плюс есть отдельный жанр уязвимостей, где ошибка живёт в самом рантайме и граница не пробивается, а аккуратно обходится. К этому вернёмся отдельным разделом.
Внутри вы root, и это тот же root
Документация Docker говорит без оговорок: «The default user within a container is root (uid = 0)».
То есть ваш nginx, ваш PostgreSQL и ваш питоновский скрипт по умолчанию работают от суперпользователя. Не от «какого-то отдельного контейнерного суперпользователя», а от пользователя с идентификатором ноль, и ядро видит именно ноль, потому что пространство имён пользователей по умолчанию выключено. В документации это сказано прямо: «This facility is available but not enabled by default».
Что это меняет на практике. Пока процесс сидит внутри своих пространств имён, ноль мало что ему даёт. А вот как только появляется точка соприкосновения с хостом, ноль превращается в ноль. Самая частая такая точка висит в каждом втором docker-compose.yml:
volumes:
- ./data:/data
Файлы, которые контейнер создаёт в /data, на хосте принадлежат root. И наоборот: процесс в контейнере работает с смонтированной папкой с правами суперпользователя, игнорируя любые права, которые вы там расставили. Если смонтировано что-то интереснее папки с данными, последствия масштабируются мгновенно.
Документация описывает крайний случай с редкой для технических текстов прямотой:
you can start a container where the /host directory is the / directory on your host; and the container can alter your host filesystem without any restriction.
Лечится одной строкой в Dockerfile:
RUN adduser -D -u 10001 app
USER 10001
Или флагом --user 10001:10001 при запуске, если образ чужой и править его не хочется. Будьте готовы, что с первого раза упадёт: процессу внезапно не хватит прав на запись в те места, куда он привык писать от root. Это и есть та самая цена, из-за которой в гайдах написано иначе.
Четырнадцать прав, которые выдали молча
Права суперпользователя в Linux давно разобраны на части, которые называются capabilities. Всего их около сорока. Контейнеру по умолчанию достаётся четырнадцать, и список из документации выглядит так:
- AUDIT_WRITE
- CHOWN
- DAC_OVERRIDE
- FOWNER
- FSETID
- KILL
- MKNOD
- NET_BIND_SERVICE
- NET_RAW
- SETFCAP
- SETGID
- SETPCAP
- SETUID
- SYS_CHROOT
Названия ничего не говорят, поэтому переведу три самых занятных.
DAC_OVERRIDE означает «обходить проверки прав на чтение, запись и выполнение файлов». Всех. Это полномочие, из-за которого права на файлы внутри контейнера работают примерно как рекомендация.
NET_RAW означает доступ к сырым и пакетным сокетам. Из полезного им пингуют. Из остального им подделывают пакеты и слушают трафик в своей сети, а в одной сети у вас обычно живут соседние контейнеры: база, кэш, внутреннее API.
SETUID и SETGID позволяют процессу произвольно менять свой идентификатор пользователя. Красивое следствие: если вы прописали USER 10001, но оставили эти права, процесс при желании вернётся в ноль сам.
Отдельно про флаг, который вы встретите в каждой второй инструкции в интернете. --privileged, по словам документации, «gives all capabilities to the container», открывает доступ ко всем устройствам хоста и перенастраивает AppArmor или SELinux так, чтобы контейнеру было можно почти всё, что можно процессам на хосте. Этот флаг стоит в гайдах не потому, что он нужен, а потому что с ним точно не придётся отвечать в комментариях на вопрос «почему у меня не запускается».
Правильный подход обратный: сначала отобрать всё, потом вернуть минимум.
docker run -d \
--user 10001:10001 \
--cap-drop=ALL \
--cap-add=NET_BIND_SERVICE \
--security-opt no-new-privileges \
--read-only --tmpfs /tmp \
-p 127.0.0.1:8080:80 \
nginx
Строчка --cap-add=NET_BIND_SERVICE тут нужна, чтобы процесс без прав мог занять порт 80 внутри контейнера. Многим приложениям не нужно даже это: если внутри слушается порт выше 1024, добавлять не надо ничего.
Сорок четыре вызова из трёхсот
Третий слой защиты называется seccomp и отвечает за то, какие системные вызовы процессу разрешены. Документация формулирует так: профиль по умолчанию «disables around 44 system calls out of 300+».
Звучит как «закрыли одну дверь из семи». Но тут важна механика, а она устроена лучше, чем выглядит из этой фразы: профиль работает как белый список. Действие по умолчанию стоит SCMP_ACT_ERRNO, то есть «запретить», а дальше перечислены разрешённые вызовы. Сорок четыре закрытых вызова это те, которые регулярно нужны чему-то полезному и при этом опасны, так что их отсутствие заметно.
Так что сорок четыре из трёхсот плюс я бы записал Docker скорее в плюс. Это единственный слой из четырёх, который по умолчанию настроен в сторону безопасности, а не в сторону «чтобы запустилось».
Ровно поэтому обиднее всего следующее. Флаг --privileged этот профиль отключает целиком, вместе со всем остальным. Один флаг ради того, чтобы контейнер поднялся с первой попытки, сносит единственный слой, который работал без вашего участия.
Ваш файрвол выключен, а вы не в курсе
Вот этот раздел ради статьи и писался. Остальное про права внутри, а здесь про то, кто снаружи достаёт до ваших контейнеров.
Вы поставили ufw, разрешили 22 и 443, остальное закрыли, проверили ufw status, увидели Status: active и успокоились. Дальше запустили контейнер с -p 5432:5432, потому что так было в инструкции к образу. И теперь ваша база открыта всему интернету, а ufw показывает, что всё в порядке.
Это не баг и не ваша невнимательность. Так работает.
When you publish a container's ports using Docker, traffic to and from that container gets diverted before it goes through the ufw firewall settings. 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. Effectively ignoring your firewall configuration.
Документация Docker, раздел о файрволах
Перевожу. Docker правит таблицу nat, и пакет уходит на контейнер раньше, чем доходит до цепочек, в которых живут ваши правила. Ufw не обманули, его просто не спросили.
Теперь про то, насколько это старая история.
📋 Первый тикет
март 2014, 157 комментариев
закрыт в 2018
📋 Второй тикет
апрель 2016, 239 комментариев
открыт до сих пор
Десять лет и два тикета. Поведение по умолчанию за это время не поменялось.
Вторая половина проблемы в том, куда публикуются порты. Опять цитата: «when a container's ports are mapped without any specific host address, the Docker daemon publishes ports to all host addresses (0.0.0.0 and [::])». То есть -p 5432:5432 означает «на все интерфейсы, включая внешний», а вовсе не «на локалхост». И на IPv6 тоже, что отдельно весело, если вы про IPv6 на сервере вспоминали последний раз никогда.
Что с этим делать. Самое дешёвое: везде, где порт нужен только локально, писать адрес явно.
-p 127.0.0.1:5432:5432
Это одна правка в docker-compose.yml, и она закрывает большую часть сюрпризов. Если хочется поменять поведение сразу для всего, есть настройка демона в /etc/docker/daemon.json:
{
"default-network-opts": {
"bridge": {
"com.docker.network.bridge.host_binding_ipv4": "127.0.0.1"
}
}
}
После этого -p 8080:80 без адреса будет означать локалхост, а не весь интернет. Как по мне, это должно было быть поведением по умолчанию с самого начала, но имеем что имеем.
И проверка, которая занимает одну команду. Не ufw status, он вам соврёт:
ss -tulpn
Каждая строчка с 0.0.0.0 или * в адресе это то, до чего дотягивается интернет. Смотреть надо снаружи и регулярно, об этом подробно в статье про свой мониторинг.
Одна хорошая новость. В версии 28.0 Docker наконец начал по умолчанию отбрасывать трафик к неопубликованным портам контейнеров. До этого, если на хосте была разрешающая политика в цепочке FORWARD и включено перенаправление пакетов, сосед по локальной сети мог добавить маршрут к подсети 172.17.0.0/16 и постучаться прямо в контейнер, минуя публикацию портов вообще. В собственном блоге Docker описывает это аккуратно: «unpublished ports could also be remotely accessible under certain conditions». Публичного интернета это не касалось, только локальной сети, зато в многопользовательских сетях и офисах работало прекрасно.
Сокет демона: самый короткий путь к root
Демон Docker работает от суперпользователя, и его сокет это пульт от хоста. Кто дотянулся до сокета, тот может запустить контейнер, смонтировать в него корень файловой системы и делать на машине что угодно.
Документация даже не пытается это смягчать. Про группу пользователей сказано: «The docker group grants root-level privileges to the user». Про сертификаты для удалённого доступа ещё яснее: «anyone with the keys can give any instructions to your Docker daemon, giving them root access to the machine hosting the daemon. Guard these keys as you would a root password!».
Отсюда два практических вывода.
Добавить человека в группу docker означает выдать ему root без пароля и без записи в логах sudo. Иногда это именно то, что вы хотели. Но пусть это будет осознанным решением, а не побочным эффектом строчки из гайда по установке.
Второе касается монтирования сокета в контейнер. Конструкция
volumes:
- /var/run/docker.sock:/var/run/docker.sock
встречается в каждом втором compose-файле для панелей управления, систем сборки и прочих удобных штук. И означает она ровно одно: этот контейнер теперь имеет права root на хосте. Со всем, что в нём есть, включая зависимости, которые вы не смотрели.
Отдельный разговор про порт 2375. Это открытый наружу API демона без шифрования и без аутентификации. Включают его обычно потому, что так было в инструкции по настройке удалённого доступа, а разбираться с сертификатами долго.
Как это выглядит со стороны атакующего, подробно разобрано в отчётах Trend Micro и Akamai за 2025 год. Схема такая: массовое сканирование интернета по порту 2375, на найденной машине создаётся контейнер из обычного образа Alpine, в него монтируется файловая система хоста, дальше закрепление через правку конфигурации SSH и загрузка полезной нагрузки из домена в Tor. Внутри вредоноса лежит masscan, чтобы искать следующие такие же машины, и логика, которая закрывает API от других атакующих. То есть дверь за собой запирают, чтобы соседи не отобрали добычу. На мой вкус, это самая выразительная деталь всей истории.
Проверить, не торчит ли у вас это наружу, можно не выходя из консоли:
ss -tlpn | grep -E '2375|2376'
Если пусто, хорошо. Если нет, вы знаете, что делать.
Откуда вы вообще берёте образы
Допустим, вы прочитали всё выше и всё настроили. Остался вопрос, который обычно не задают: а что внутри образа, который вы скачали?
Три исследования дают представление о масштабе.
Фальшивых репозиториев больше, чем кажется. JFrog в 2024 году разобрала публичную часть Docker Hub при общем числе около пятнадцати миллионов репозиториев. Выяснилось, что примерно 4,6 миллиона из них «безобразные»: там нет самого образа, только описание. Из этой кучи около 2,81 миллиона, то есть примерно каждый пятый публичный репозиторий, удалось связать с кампаниями по распространению вредоносного и мошеннического. Запустить такое нельзя, потому что запускать там нечего. Расчёт был на то, что человек придёт из поиска и перейдёт по ссылке из описания.
Запускаемые вредоносные образы тоже есть. Unit 42 в своём разборе нашла тридцать образов с десяти аккаунтов, суммарно скачанных более двадцати миллионов раз. Насчитанная добыча около двухсот тысяч долларов в криптовалюте, намайненной на чужих серверах. Экономику этого жанра я подробно разбирал в «Кому я нужен»: там же объясняется, почему счёт за электричество и ресурсы в такой схеме приходит не тому, кто получает доход.
А в собственных образах лежат ваши ключи. Отдельная работа, где проанализировали 337 171 образ с Docker Hub и ещё 8 076 приватных реестров, даёт цифру: в 8,5% образов нашлись секреты. Конкретно 52 107 приватных ключей и 3 158 ключей API. И это не абстрактная утечка: авторы проверили и обнаружили 275 269 серверов TLS и SSH, которые используют для аутентификации уже утёкшие ключи.
Восемь с половиной процентов. То есть примерно каждый двенадцатый образ несёт в себе чей-то ключ, потому что COPY . . на этапе сборки взял всё, включая .env и id_rsa, а удаление файла следующим слоем ничего не удаляет: предыдущий слой остаётся в образе целиком.
Три скучных правила закрывают почти всё.
Фиксируйте образ по хешу, а не по тегу. Тег latest это подвижная цель, и кто сегодня за ним стоит, вы не знаете. nginx@sha256:... однозначен.
Заведите .dockerignore раньше, чем первый COPY. Минимум .git, .env, *.pem, id_*, node_modules.
Проверяйте, что внутри. docker scout cves <образ> или trivy image <образ> занимают секунды и показывают список известных дыр в том, что вы собираетесь запустить.
Побеги из рантайма
Отдельный жанр, про который любят писать новости: уязвимости в runc, то есть в той программе, которая собственно создаёт контейнер.
| Когда | Что | Механика |
|---|---|---|
| Январь 2024, «Leaky Vessels» | CVE-2024-21626 | утечка файлового дескриптора: рабочую директорию процесса подсовывают так, что она указывает за пределы контейнера |
| Ноябрь 2025 | CVE-2025-31133, CVE-2025-52565, CVE-2025-52881 | гонка в bind-монтировании: /dev/null, которым runc закрывает опасные файлы, подменяют на символическую ссылку |
Во второй истории механика изящнее. runc закрывает опасные файлы в /proc, подменяя их на /dev/null. Если выиграть гонку и подменить сам /dev/null на ссылку, запись уходит не в никуда, а туда, куда ссылка указывает. Среди интересных целей /proc/sys/kernel/core_pattern, который задаёт, что система выполнит при падении процесса.
Теперь честная оценка, потому что паниковать тут не надо.
Такие дыры требуют уже имеющегося доступа к сборке или запуску контейнера, и чинят их патчем в течение дней. Главное же в том, что в очереди они стоят далеко позади всего предыдущего. Порядок, в котором до вас доберутся, примерно такой:
- Открытый наружу порт базы, который опубликовал
-pна все интерфейсы. - Смонтированный сокет демона.
- Процесс от root, которому разрешено всё.
- И только потом экзотическая гонка в bind-монтировании.
Четвёртый пункт понадобится тому, у кого первые три уже закрыты.
Правило тут одно и скучное: обновляйте docker и containerd вместе с системой, а не когда вспомните. Окно между публикацией дыры и её массовым использованием сейчас измеряется часами, про это есть цифры в статье про экономику взлома.
Что делать по-честному
Короткий список, потому что длинные никто не выполняет. По убыванию отношения пользы к потраченному времени.
Привяжите порты к локалхосту. Одна правка в compose-файле, закрывает самый частый способ отдать базу интернету.
ports:
- "127.0.0.1:5432:5432"
Проверьте, что реально слушает наружу. ss -tulpn, и смотреть на 0.0.0.0. Не доверяйте ufw status, он про контейнеры не знает.
Уберите процессы из-под root. USER 10001 в Dockerfile или --user при запуске. Упадёт с первого раза, это нормально, разбираться с правами на файлы придётся один раз.
Отберите права и верните минимум. --cap-drop=ALL, дальше по одному по мере необходимости. Плюс --security-opt no-new-privileges.
Выкиньте --privileged из всех своих команд. Если без него что-то не работает, нужен конкретный флаг: --device для устройства, --cap-add=NET_ADMIN для сетевых настроек. Документация прямо советует второе вместо первого.
Проверьте, где у вас смонтирован сокет демона. grep -r docker.sock по вашим compose-файлам. Каждое совпадение это выданный root.
Фиксируйте образы по хешу и заведите .dockerignore. Чтобы не тащить чужое и не отдавать своё.
Обновляйте рантайм вместе с системой. Автоматически, без «посмотрю на выходных».
А если контейнеры у вас не для продакшена, а чтобы что-то попробовать, есть вариант радикальнее: rootless mode. Демон работает от обычного пользователя, и большая часть этой статьи для него перестаёт быть актуальной. Ценой некоторых ограничений, но для стенда они почти не чувствуются.
Частые ошибки
- Считать контейнер песочницей. Ядро одно, граница проходит внутри него. Виртуальная машина и контейнер защищают по-разному.
- Верить, что ufw защищает опубликованные порты. Не защищает, и так задумано. Два тикета за десять лет это не изменили.
- Писать
-p 5432:5432по привычке. Это все интерфейсы, включая внешний и IPv6. Адрес надо указывать явно. - Оставлять процессы под root, потому что иначе падает. Падает оно один раз, а права на файлы чинятся один раз.
- Копировать
--privilegedиз гайда. Этот флаг выдаёт все права и сносит профиль seccomp, то есть единственный слой, который работал сам. - Монтировать сокет демона, не подумав. Это выдача root на хосте тому, что внутри контейнера, со всеми его зависимостями.
- Открывать порт 2375 для удобства. По нему ходят сканеры, а найденную машину запирают от других атакующих, чтобы не делиться.
- Собирать образ через
COPY . .. Примерно каждый двенадцатый публичный образ содержит чей-то приватный ключ, и попасть в эту статистику проще, чем кажется. - Гнаться за новостями про побеги из
runc, имея при этом открытую наружу базу. Начинать надо с того, что ломают в первую очередь.
Где это держать
Одна практическая мысль про площадку, раз уж весь текст про то, что торчит наружу.
Контейнеры штука дешёвая, и именно поэтому их принято складывать в одну кучу: рядом боевое приложение, база, панель, пара вещей «попробовать» и что-то ещё, запущенное полгода назад и забытое. Пока всё на одной машине, любая ошибка в правах или в публикации порта касается сразу всего.
Разнести это по отдельным машинам стоит дешевле, чем кажется. Мне под такие задачи хватает младшего тарифа на serv.host промокод promo22382: отдельная копеечная машина под то, что хочется потрогать без страха, и отдельная под то, что должно работать. Плюс для контейнеров полезно иметь чистый адрес, потому что пойманный майнер утащит репутацию адреса за собой, а выбираться из чёрных списков долго, я про это писал.
Короткий итог
- Контейнер это не виртуальная машина. Ядро общее, изоляцию дают пространства имён и контрольные группы, а они отвечают на вопрос «что видно», а не «что можно».
- Процесс внутри по умолчанию root (uid 0), и это тот же ноль, что на хосте, потому что пространство имён пользователей по умолчанию выключено.
- Прав выдаётся четырнадцать из примерно сорока, и среди них
DAC_OVERRIDE, обходящий проверку прав на файлы, иNET_RAWдля сырых сокетов. - Профиль seccomp единственный слой, настроенный в сторону безопасности: белый список, около 44 закрытых вызовов. Флаг
--privilegedотключает его целиком. - Опубликованные порты обходят ufw. Пакет уходит в контейнер через таблицу
natраньше, чем доходит до ваших правил. Тикету про это десять лет, и он открыт. -p 5432:5432означает все интерфейсы, включая внешний и IPv6. Пишите127.0.0.1:5432:5432.- Сокет демона равен root на хосте. Группа
docker, сертификаты для удалённого доступа и смонтированныйdocker.sockэто три способа выдать его, не заметив. - Открытый порт 2375 находят сканированием и закрывают от конкурентов, чтобы майнить на вашей машине без помех.
- В 8,5% образов лежат чьи-то секреты: 52 107 приватных ключей на выборке в 337 тысяч образов, и часть этих ключей используется на живых серверах.
- Побеги из
runcсуществуют, но стоят в очереди после открытой наружу базы и процесса под root. Чинятся обновлением.
Общая мысль такая. Docker не обманывает, он честно оптимизирован под то, чтобы у вас всё заработало с первой попытки, и в этом он великолепен. Безопасность по умолчанию такой цели мешает, поэтому её по умолчанию и нет. Все нужные ручки существуют, документированы и крутятся за один вечер. Их просто надо повернуть руками, потому что за вас это никто не сделал и не собирался.
Вообще я микроразработчик, со мной можно связаться как угодно, я открыт ко всему
Сторонний мой проект: Zapret2UI.
Источники
- Docker: Docker security, про поверхность атаки демона и пространства имён пользователей
- Docker: runtime privilege и Linux capabilities, полный список прав по умолчанию
- Docker: Seccomp security profiles, про белый список и 44 вызова из 300+
- Docker: Packet filtering and firewalls, официально про несовместимость с ufw
- Docker: Port publishing and mapping, про публикацию на 0.0.0.0 и смену адреса по умолчанию
- Docker: post-installation, предупреждение про группу docker
- Docker: Protect the Docker daemon socket
- Docker Engine v28: Hardening Container Networking by Default
- moby #22054: Docker Network bypasses Firewall, no option to disable (открыт с 2016 года)
- Akamai: разбор вредоноса, который ищет открытые API Docker и запирает их за собой
- The Hacker News: сводка по кампании с портом 2375, masscan и Tor
- JFrog: миллионы вредоносных репозиториев в Docker Hub
- Unit 42: 30 образов, 20 миллионов скачиваний, 200 тысяч долларов намайненного
- Secrets Revealed in Container Images: исследование 337 171 образа, 8,5% с секретами
- Red Hat: Leaky Vessels, CVE-2024-21626 в runc
- Sysdig: разбор трёх уязвимостей runc от ноября 2025 года
- Docker: Rootless mode
Mira el resto de artículos de la base de conocimientos: los temas vecinos también están explicados.
