Побег из контейнера: как выходят на хост и как это закрыть
Контейнер — это процесс в отдельных пространствах имён поверх общего с хостом ядра. Большинство «побегов» — вовсе не взлом изоляции, а розданные ключи: доступ к демону, проброшенный сокет, флаг --privileged. Настоящие побеги через ошибку в рантайме или ядре реже, но опаснее. Разберём оба класса и то, чем от каждого защищаются.
Полное техническое изложение
TL;DR
- Контейнер — это обычная программа на том же ядре, что и хост; ядро просто показывает ей урезанный мир. Виртуальной машины с отдельным ядром тут нет.
- Поэтому большинство «побегов» — вообще не взлом. Ключи от хоста отдали сами: пользователь в группе
docker, проброшенный сокет демона, флаг--privileged. Это по документации даёт доступ к хосту. - Настоящий побег — реже: ошибка в рантайме (
runc), который работает на хосте с правами root, может дать root на хосте. - Защита: не раздавать группу
dockerи сокет, убрать--privileged, работать без root, обновлятьrunc. Для недоверенного кода — другая граница (gVisor, микро-VM): она сужает доступ к ядру хоста, хотя и не делает побег невозможным.
Контейнер — это не отдельная машина
Легко подумать, что контейнер — маленькая виртуальная машина. Это не так. Контейнер — обычный процесс на общем с хостом ядре. Ядро показывает ему свой кусочек: свои процессы, свою сеть, свою файловую систему. Но ядро — то же самое, что и у хоста. Изоляция контейнера — это разметка внутри одной квартиры, а не отдельный дом.
Из этого следуют два очень разных способа «сбежать» из контейнера на хост.
Способ первый: ключи отдали сами
Чаще всего никто ничего не ломает — доступ к хосту выдали настройками.
Группа docker — это root. Демон Docker работает от root, и кто может им
командовать, тот управляет хостом. Документация Docker прямо просит пускать к
демону только доверенных людей. А запустить контейнер с примонтированным корнем
хоста — штатная возможность:
docker run -v /:/host --rm -it alpine chroot /host sh
# это root-шелл в файлах хоста
Ничего не взломано: команда сделала то, что обещано. Поэтому добавить человека в
группу docker — всё равно что выдать ему sudo без пароля.
Проброшенный сокет и --privileged — то же самое, только в другой форме. Если в
контейнер пробросили /var/run/docker.sock, изнутри можно командовать демоном
хоста. Флаг --privileged снимает почти все ограничения контейнера. И то и
другое — открытая дверь, а не взлом.
Способ второй: ошибка в общем рантайме
Это уже настоящий побег: не открытую дверь нашли, а пробили стену. Рантайм
runc работает на хосте с правами root и сам собирает контейнер, поэтому ошибка
в нём даёт выход наружу; ошибка в общем ядре — другой путь с тем же итогом. Так
работали два известных бага в runc (CVE-2019-5736 и CVE-2024-21626): первый
давал root на хосте, второй — рабочий каталог в файловой системе хоста, а по
описанию самого проекта его последствия доходили до полного побега. Оба давно
исправлены; важно вовремя обновляться.
Картинка ниже показывает две модели рядом — контейнер с общим ядром и микро-VM с гипервизором — и какую границу пробивает каждый класс.
Как закрыть
- Не раздавать ключи. Группа
docker— только доверенным; не пробрасыватьdocker.sock; убрать--privileged. - Сузить права. Работать под непривилегированным пользователем, сбросить лишние привилегии, включить rootless-режим — тогда даже побег не даёт root на хосте.
- Обновлять
runcи не выключать защиту ядра (seccomp, AppArmor/SELinux). - Для недоверенного кода — другая граница. gVisor, Kata или микро-VM (Firecracker): системные вызовы кода обслуживает их собственное ядро (у gVisor — в пользовательском пространстве, у Kata и Firecracker — за гипервизором), и путь runc-побегов упирается в него, а не в хост. Но у этой границы свои компоненты (у gVisor — Sentry, у микро-VM — гипервизор и VMM), и ошибка в них — тоже путь наружу: их тоже обновляют.
TL;DR
Контейнер — это обычный процесс, которому ядро выдало отдельные пространства
имён, cgroups и урезанный набор capabilities. Ядро при этом общее с
хостом. Отсюда два разных класса «побега», и путать их нельзя.
Класс 1 — розданные ключи. Чаще всего никакого взлома нет: управление хостом
отдали сами. Пользователь в группе docker, проброшенный в контейнер
/var/run/docker.sock, флаг --privileged, монтирование / с хоста — всё это
по документации даёт полный доступ к хосту. Ломать нечего: дверь открыта.
Класс 2 — ошибка в общем рантайме или ядре. Такое случается реже и даётся
труднее, но именно это — «настоящий побег»: ошибки в runc (CVE-2019-5736,
CVE-2024-21626) открывают хост: первая прямо даёт root, вторая — рабочий
каталог в файловой системе хоста, а по advisory проекта последствия доходят до
полного побега. Это ошибки рантайма — программы, которая работает на хосте с
правами root и сама собирает контейнер; ошибка ядра — отдельный механизм с тем
же итогом.
Чем закрывается. Класс 1 — гигиеной прав: не раздавать группу docker,
не пробрасывать сокет, убрать --privileged, сбросить лишние capabilities,
rootless-режим. Класс 2 — обновлением runc и ядра, а для недоверенного кода —
другой границей: gVisor, Kata, микро-VM (Firecracker). Они сужают прямой доступ
к ядру хоста и рвут конкретный путь runc-побегов, но не делают побег
невозможным: в доверенной базе остаются свои компоненты — Sentry у gVisor,
гипервизор и VMM у микро-VM.
Что такое контейнер и где у него граница
Контейнер — не виртуальная машина. Это процесс на том же ядре, что и хост,
которому ядро показывает урезанную картину мира: свои идентификаторы процессов,
свою сеть, свою файловую систему — это пространства имён (namespaces).
Сколько ресурсов ему можно брать, решают контрольные группы (cgroups). Что ему
позволено делать в ядре, ограничивает набор привилегий (capabilities).
Ключевое здесь — слово «общее». Все три механизма живут в одном ядре с хостом. Поэтому изоляция контейнера — это не стена виртуальной машины, а разметка внутри одной квартиры. NIST в руководстве по безопасности контейнеров прямо признаёт их границу слабее, чем у гипервизора. Из этого и растут оба класса побега: где-то разметку отдали добровольно, где-то её пробила ошибка.
Что здесь считается доверенным
До перечня защит стоит договориться о рамке, иначе спор «безопасен ли контейнер» не имеет ответа. NIST в руководстве по безопасности контейнеров рамку задаёт дважды.
Первое — про класс границы, и сказано это прямо:
While containers provide a strong degree of isolation, they do not offer as clear
and concrete of a security boundary as a VM. Because containers share the same
kernel and can be run with varying capabilities and privileges on a host, the
degree of segmentation between them is far less than that provided to VMs by a
hypervisor.
Хотя контейнеры дают сильную степень изоляции, они не предлагают такой же ясной и осязаемой границы безопасности, как виртуальная машина. Поскольку контейнеры разделяют одно и то же ядро и могут запускаться на хосте с разными привилегиями и наборами capabilities, степень разделения между ними существенно меньше той, которую гипервизор даёт виртуальным машинам.
Причина названа там же, в разделе про общее ядро:
Although containers provide strong software-level isolation of resources, the use
of a shared kernel invariably results in a larger inter-object attack surface
than seen with hypervisors, even for container-specific OSs. In other words, the
level of isolation provided by container runtimes is not as high as that provided
by hypervisors.
Хотя контейнеры дают сильную программную изоляцию ресурсов, использование общего ядра неизбежно даёт бо́льшую межобъектную поверхность атаки, чем у гипервизоров, — даже для специализированных под контейнеры ОС. Иначе говоря, уровень изоляции, который дают рантаймы контейнеров, не так высок, как у гипервизоров.
Второе — о том, где документ проводит границу собственного рассмотрения. Это ровно тот список, который стоит держать в голове, читая любые обещания изоляции:
All other risks involving the core components, as well as risks involving
non-core container technology components, including developer systems, testing
and accreditation systems, administrator systems, and host hardware and virtual
machine managers, are outside the scope of this document.
Все прочие риски, связанные с базовыми компонентами, а также риски, связанные с небазовыми компонентами контейнерной технологии, включая системы разработчиков, системы тестирования и аттестации, системы администраторов, аппаратуру хоста и менеджеры виртуальных машин, находятся вне области действия этого документа.
Отсюда практический вывод, который и есть модель угроз в одну строку: всё, что делит с вами ядро, считайте одинаково доверенным. Не «изолированным», а именно доверенным — потому что граница между ними слабее той, что даёт гипервизор, и настраивается она вами, а не производителем.
Из этого же следует, что рантайм — часть границы. Прямого требования «держите
рантайм свежим» в документе нет; есть две соседние формулировки, из которых оно
выводится. Уязвимость рантайма ведёт к побегу: While relatively uncommon, vulnerabilities within the runtime software are particularly dangerous if they allow "container escape" scenarios
(Хотя это относительно редко, уязвимости в программном обеспечении рантайма особенно опасны, если они допускают сценарии „побега из контейнера“).
И отсюда обязанность: The container runtime must be carefully monitored for vulnerabilities, and when problems are detected, they must be remediated quickly
(Рантайм контейнеров должен внимательно отслеживаться на предмет уязвимостей, и при обнаружении проблем они должны устраняться быстро).
А из «одинаково доверенного» следует правило размещения, названное в документе явно:
Only group containers with the same purpose, sensitivity, and threat posture on a
single host OS kernel to allow for additional defense in depth. While most
container platforms do an effective job of isolating containers from each other
and from the host OS, it may be an unnecessary risk to run apps of different
sensitivity levels together on the same host OS.
Группируйте на одном ядре ОС хоста только те контейнеры, у которых одинаковы назначение, чувствительность и модель угроз, — это даёт дополнительную эшелонированную защиту. Хотя большинство контейнерных платформ эффективно изолируют контейнеры друг от друга и от ОС хоста, запуск приложений разных уровней чувствительности вместе на одной ОС хоста может быть излишним риском.
То есть решение «где это запускать» принимается не после настройки, а до неё: по тому, совпадают ли назначение, чувствительность и модель угроз у соседей.
Класс 1: побег, которого не было, — розданные ключи
Самая частая «дыра» — не дыра. Управление хостом отдали через конфигурацию, и атакующему остаётся им воспользоваться. Разберём три типовых случая; во всех трёх изоляция ядра ни при чём.
Группа docker — это root на хосте
Демон Docker работает от root. Тот, кто может отдавать ему команды, фактически
управляет хостом. Документация Docker говорит это прямо: Only trusted users
should be allowed to control your Docker daemon
(Управлять демоном Docker должно быть позволено только доверенным пользователям). Членство в группе docker
даёт ровно такое управление — без sudo, без пароля.
Почему это равно root, видно из другого места той же документации. Docker
позволяет смонтировать любой каталог хоста внутрь контейнера без ограничения
прав: 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/host — это корень / файловой системы хоста; и контейнер сможет менять её без каких-либо ограничений). То есть тот, кто может запустить контейнер, может смонтировать
корень хоста и писать в него:
docker run -v /:/host --rm -it alpine chroot /host sh
# теперь это root-шелл в файловой системе хоста
Это не эксплойт и не уязвимость — это задокументированное поведение. Никакой
механизм изоляции здесь не отказывал: команда сделала ровно то, что обещано.
Поэтому группа docker — не «удобство для разработчика», а раздача root, и
относиться к ней надо как к выдаче sudo без пароля.
Проброшенный сокет docker.sock
То же самое, но изнутри контейнера. Если в контейнер пробросили
/var/run/docker.sock (частый приём для CI и «Docker-in-Docker»), процесс
внутри получает канал к демону хоста — а значит, те же возможности, что даёт
группа docker: может запросить новый контейнер с примонтированным /. OWASP выносит это в
правило номер один: Do not expose the Docker daemon socket (even to the
containers)
(Не открывайте сокет демона Docker (даже контейнерам)) — Giving someone access to it is equivalent to giving
unrestricted root access to your host
(Дать кому-то доступ к нему — то же самое, что дать неограниченный root на хосте).
--privileged и лишние capabilities
Флаг --privileged снимает почти все ограничения: отдаёт контейнеру полный
набор capabilities и доступ к устройствам хоста, отключает профили seccomp и
AppArmor. Из такого контейнера до хоста рукой подать. То же относится к отдельным
опасным привилегиям вроде CAP_SYS_ADMIN и к монтированию хостовых путей. OWASP: Do
not run containers with the --privileged flag
(Не запускайте контейнеры с флагом --privileged).
Вывод по классу 1: большинство «побегов из контейнера» в реальных инцидентах относятся именно к нему. Ломать ядро не требуется, потому что доступ к хосту выдали конфигурацией. Хорошая новость: и закрывается он конфигурацией — об этом ниже.
Класс 2: настоящий побег — ошибка в общем рантайме или ядре
Теперь о побеге в строгом смысле: атакующий пересекает границу, которую никто не открывал. Раз ядро и рантайм общие с хостом, ошибка в них даёт мост наружу. Ниже два известных примера — только описание того, что именно отказало, без шагов воспроизведения.
CVE-2019-5736. Дословно: runc through 1.0-rc6 … allows attackers to
overwrite the host runc binary (and consequently obtain host root access)
(runc вплоть до 1.0-rc6 … позволяет злоумышленнику перезаписать исполняемый файл runc на хосте (и тем самым получить на хосте права root)).
Отказал не механизм изоляции ядра, а runc — рантайм, общий для контейнера и
хоста. Перезаписав его, можно было получить root на хосте.
CVE-2024-21626. Дословно: due to an internal file descriptor leak, an
attacker could cause a newly-spawned container process … to have a working
directory in the host filesystem namespace
(из-за внутренней утечки файлового дескриптора злоумышленник мог добиться того, чтобы у только что запущенного процесса контейнера … рабочий каталог оказался в пространстве имён файловой системы хоста). Снова рантайм; исправлено в
runc 1.1.12. Дескриптор, указывающий в файловую систему хоста, оказался
достижим изнутри — потому что ядро одно на всех.
Общее у обоих: отказала не изоляция ядра, а рантайм — программа, которая работает на хосте с правами root и сама готовит процесс контейнера. Ошибка рантайма и уязвимость ядра — разные механизмы, и одной фразой «потому что ядро общее» их не объяснить. Объединяет их другое: у обычного контейнера и рантайм, и ядро хоста входят в доверенную базу, и ошибка в любом из них ведёт к хосту. Поэтому защита от класса 2 — не только патчи, но и выбор границы: какие компоненты окажутся между недоверенным кодом и хостом (последний блок раздела «Как это закрыть»).
Как это закрыть
Защита эшелонирована: сначала не раздавать доступ (класс 1), потом сузить то, что осталось, и наконец — для недоверенного кода — поставить между ним и ядром хоста другую границу (класс 2). Правила ниже — из OWASP Docker Security Cheat Sheet и документации Docker.
Не раздавать управление демоном.
- Группа
docker— только доверенным людям; членство в ней приравнять кsudoи регулярно проверять. - Не пробрасывать
/var/run/docker.sockв контейнеры; не открывать демон по TCP. - Rootless-режим: демон и контейнеры работают от непривилегированного пользователя, и тогда даже побег из контейнера не даёт root на хосте.
Сузить привилегии контейнера.
- Никакого
--privileged. Сбросить всеcapabilitiesи вернуть только нужные. - Работать под непривилегированным пользователем внутри контейнера — OWASP называет это лучшим способом против повышения привилегий.
--security-opt=no-new-privileges— чтобыsetuid-бинарники не поднимали права.--read-onlyдля корневой ФС там, где это возможно.
Оставить включённой защиту ядра.
- Не выключать профиль seccomp по умолчанию; держать AppArmor или SELinux.
- Включить user namespaces: root внутри контейнера перестаёт быть root на хосте.
Против класса 2 — патчи и другая граница.
- Обновлять рантайм: держать
runcна текущем патч-релизе своей ветки и читать бюллетени безопасности проекта. Конкретный номер здесь называть бессмысленно: он меняется с каждым бюллетенем, и вчерашнее «достаточно свежо» сегодня уже уязвимо. - Для недоверенного кода — сменить границу: gVisor (перехват системных вызовов), Kata Containers или микро-VM (Firecracker). Системные вызовы кода обслуживает уже не ядро хоста напрямую: у gVisor — Sentry, ядро в пользовательском пространстве, у Kata и Firecracker — гостевое ядро за гипервизором. Путь, которым шли runc-побеги, упирается тогда в эту промежуточную границу, а не в хост. Но граница не исчезает, она меняется: Sentry сам обращается к ядру хоста через ограниченный набор вызовов, а у микро-VM остаются VMM, виртуальные устройства и компоненты на стороне хоста. Ошибка в них — тоже путь наружу, поэтому их так же обновляют и запускают с минимальными правами, а выбор делают по модели угроз.
То же самое в Kubernetes: три уровня и что каждый запрещает
Всё вышесказанное про флаги docker run в Kubernetes выражается декларативно, и
перечень не надо составлять самому: он стандартизован тремя политиками.
The Pod Security Standards define three different policies to broadly cover the
security spectrum. These policies are cumulative and range from
highly-permissive to highly-restrictive.
Pod Security Standards определяют три разные политики, чтобы покрыть спектр безопасности целиком. Эти политики накопительные и идут от крайне разрешающей к крайне ограничивающей.
Слово «накопительные» здесь рабочее: Restricted включает всё из Baseline.
Privileged — это отсутствие ограничений, и документация этого не смягчает:
Unrestricted policy, providing the widest possible level of permissions. This policy allows for known privilege escalations
(Неограничивающая политика, дающая максимально широкий уровень разрешений. Эта политика допускает известные способы повышения привилегий).
Она предназначена для системных нагрузок, которыми управляют доверенные люди, —
то есть для того самого случая «ключи розданы намеренно» из первого класса выше.
Baseline — Minimally restrictive policy which prevents known privilege escalations
(Минимально ограничивающая политика, которая предотвращает известные способы повышения привилегий).
Пять её пунктов один в один ложатся на раздел «Как это закрыть» — те же запреты,
записанные полями манифеста (всего проверок в политике больше, здесь — только
совпадающие):
| что запрещено | формулировка политики | где в манифесте |
|---|---|---|
| привилегированные Pod | Privileged Pods disable most security mechanisms and must be disallowed. | securityContext.privileged |
| пространства имён хоста | Sharing the host namespaces must be disallowed. | spec.hostNetwork, spec.hostPID, spec.hostIPC |
| лишние capabilities | Adding additional capabilities beyond those listed below must be disallowed. | securityContext.capabilities.add |
| выключение seccomp | Seccomp profile must not be explicitly set to Unconfined. | securityContext.seccompProfile.type |
тома hostPath | HostPath volumes must be forbidden. | spec.volumes[*].hostPath |
Разрешённый Baseline список capabilities — тринадцать имён: AUDIT_WRITE,
CHOWN, DAC_OVERRIDE, FOWNER, FSETID, KILL, MKNOD,
NET_BIND_SERVICE, SETFCAP, SETGID, SETPCAP, SETUID, SYS_CHROOT.
Restricted — Heavily restricted policy, following current Pod hardening best practices
(Сильно ограничивающая политика, следующая текущим практикам ужесточения Pod),
и добавляет она то, что выше названо «сузить привилегии»:
| что добавлено | формулировка политики |
|---|---|
| запрет повышения привилегий | Privilege escalation (such as via set-user-ID or set-group-ID file mode) should not be allowed. |
| работа не под root | Containers must be required to run as non-root users. |
| и не под UID 0 явно | Containers must not set runAsUser to 0 |
| seccomp обязателен | Seccomp profile must be explicitly set to one of the allowed values. Both the Unconfined profile and the absence of a profile are prohibited. |
| все capabilities сброшены | Containers must drop ALL capabilities, and are only permitted to add back the NET_BIND_SERVICE capability. |
Обратите внимание на разницу в строке про seccomp: Baseline запрещает выключить профиль, Restricted требует выставить его явно — отсутствие профиля тоже запрещено. Это разные требования, и второе сильнее.
Семантику полей стоит взять из типов API, а не из пересказов. Два места, где чаще всего ошибаются:
AllowPrivilegeEscalation controls whether a process can gain more privileges than
its parent process. This bool directly controls if the no_new_privs flag will be
set on the container process. AllowPrivilegeEscalation is true always when the
container is: 1) run as Privileged 2) has CAP_SYS_ADMIN
AllowPrivilegeEscalation определяет, может ли процесс получить больше привилегий, чем его родительский процесс. Этот флаг напрямую управляет тем, будет ли для процесса контейнера выставлен no_new_privs. AllowPrivilegeEscalation всегда истинно, когда контейнер: 1) запущен как Privileged, 2) имеет CAP_SYS_ADMIN.
То есть выставить allowPrivilegeEscalation: false и одновременно оставить
привилегированный режим нельзя: второе побеждает молча.
Indicates that the container must run as a non-root user. If true, the Kubelet
will validate the image at runtime to ensure that it does not run as UID 0 (root)
and fail to start the container if it does. If unset or false, no such validation
will be performed.
Указывает, что контейнер должен исполняться под пользователем, отличным от root. Если истинно, kubelet во время запуска проверит образ и убедится, что он не исполняется под UID 0, а если исполняется — не запустит контейнер. Если не задано или ложно, такая проверка не выполняется.
runAsNonRoot: true — это проверка, а не назначение пользователя. Образ,
собранный под root, с ней не запустится; пользователя надо задать в образе или
через runAsUser.
Как это включается. Политики применяются метками пространства имён —
pod-security.kubernetes.io/<режим>: <уровень>, — и режимов три: enforce
(Policy violations will cause the pod to be rejected
(Нарушения политики приведут к отклонению Pod)), audit и warn.
Порядок внедрения отсюда очевиден: сначала warn и audit, а enforce —
когда список нарушений опустеет.
И то, чем меняют границу для второго класса. Поставить недоверенный код за
gVisor или гипервизор в Kubernetes — это не отдельная инфраструктура, а поле в
манифесте: RuntimeClass стабилен с версии 1.20.
You can set a different RuntimeClass between different Pods to provide a balance
of performance versus security. For example, if part of your workload deserves a
high level of information security assurance, you might choose to schedule those
Pods so that they run in a container runtime that uses hardware virtualization.
You'd then benefit from the extra isolation of the alternative runtime, at the
expense of some additional overhead.
Разным Pod можно назначить разный RuntimeClass, чтобы найти баланс между производительностью и безопасностью. Например, если часть нагрузки заслуживает высокого уровня гарантий информационной безопасности, эти Pod можно планировать так, чтобы они исполнялись в рантайме контейнеров, использующем аппаратную виртуализацию. Тогда вы получите дополнительную изоляцию альтернативного рантайма ценой некоторых накладных расходов.
Это ровно то же решение, что и «gVisor или Kata для недоверенного кода» из предыдущего раздела, только принимаемое на уровне отдельного Pod, а не всего узла. И принимается оно по тому же правилу, что и в модели угроз выше: по совпадению назначения, чувствительности и модели угроз.
Что стоит унести
«Побег из контейнера» — это чаще всего не взлом, а раздача прав. Прежде чем
думать про CVE, проверьте конфигурацию: кто в группе docker, где проброшен
сокет, где стоит --privileged. Класс 1 закрывается гигиеной, а не патчами.
Группа docker = root на хосте. Это документированное поведение, а не
уязвимость. Выдавать её — то же, что выдавать sudo без пароля.
Настоящий побег — ошибка в доверенной базе: рантайме или ядре. Патчи
(runc, ядро) закрывают известные дыры, а следующие возможны. Другая граница —
gVisor, Kata, микро-VM — сужает прямой доступ к ядру хоста и рвёт конкретный
путь runc-побегов, но приносит свою доверенную базу (Sentry, VMM), которую тоже
обновляют и ограничивают в правах.
Спрашивайте не «сломают ли контейнер», а «что мы сами отдали и какая граница защищает остальное». У обычного контейнера это разметка на общем ядре; у микро-VM — гостевое ядро и гипервизор с более узким интерфейсом к хосту. Более узкий — не значит пустой: какую поверхность оставить, решает модель угроз.
Это не пересказ и не отдельный текст: всё ниже взято из самой статьи — её выжимка, заголовки разборов, колонка «на самом деле» и таблица версий. Поэтому разойтись со статьёй эти тезисы не могут.
Суть
- Контейнер — это обычный процесс, которому ядро выдало отдельные пространства имён,
cgroupsи урезанный наборcapabilities. Ядро при этом общее с хостом. Отсюда два разных класса «побега», и путать их нельзя. - Класс 1 — розданные ключи. Чаще всего никакого взлома нет: управление хостом отдали сами. Пользователь в группе
docker, проброшенный в контейнер/var/run/docker.sock, флаг--privileged, монтирование/с хоста — всё это по документации даёт полный доступ к хосту. Ломать нечего: дверь открыта. - Класс 2 — ошибка в общем рантайме или ядре. Такое случается реже и даётся труднее, но именно это — «настоящий побег»: ошибки в
runc(CVE-2019-5736, CVE-2024-21626) открывают хост: первая прямо даёт root, вторая — рабочий каталог в файловой системе хоста, а по advisory проекта последствия доходят до полного побега. Это ошибки рантайма — программы, которая работает на хосте с правами root и сама собирает контейнер; ошибка ядра — отдельный механизм с тем же итогом. - Чем закрывается. Класс 1 — гигиеной прав: не раздавать группу
docker, не пробрасывать сокет, убрать--privileged, сбросить лишниеcapabilities, rootless-режим. Класс 2 — обновлениемruncи ядра, а для недоверенного кода — другой границей: gVisor, Kata, микро-VM (Firecracker). Они сужают прямой доступ к ядру хоста и рвут конкретный путь runc-побегов, но не делают побег невозможным: в доверенной базе остаются свои компоненты — Sentry у gVisor, гипервизор и VMM у микро-VM.
На самом деле
- У контейнера ядро общее с хостом; namespaces, cgroups и capabilities — это разметка внутри одного ядра, а не отдельная машина. NIST в SP 800-190 признаёт границу контейнера слабее, чем у гипервизора. У виртуальной машины ядро своё, и граница — гипервизор.
- Чаще всего это вообще не взлом. Доступ к хосту раздают конфигурацией: членство в группе
docker, проброшенный/var/run/docker.sock, флаг--privileged. Всё это по документации даёт полный доступ к хосту — ломать нечего. - Это выдача root на хосте. Демон работает от root, а документация Docker просит: Only trusted users should be allowed to control your Docker daemon. Член группы может запустить контейнер с примонтированным
/и писать в файловую систему хоста — это штатная возможность, а не баг. - Патчи закрывают известные дыры класса 2 (CVE-2019-5736, CVE-2024-21626), но рантайм и ядро остаются в доверенной базе, и следующая дыра возможна. А класс 1 (розданные ключи) патчами не лечится вовсе — только конфигурацией.
- Само по себе — нет: root внутри контейнера ограничен namespaces, cgroups и урезанными capabilities на общем ядре. Опасным это становится, когда права раздали (
--privileged, проброшенный сокет) или когда есть ошибка в общем рантайме. А с user namespaces root внутри контейнера и вовсе перестаёт быть root на хосте.
Что разобрано
- Что такое контейнер и где у него граница
- Что здесь считается доверенным
- Класс 1: побег, которого не было, — розданные ключи
- Класс 2: настоящий побег — ошибка в общем рантайме или ядре
- Как это закрыть
- То же самое в Kubernetes: три уровня и что каждый запрещает
- Что стоит унести
Унести в работу
- Не раздавать группу
dockerи сокет демона: это по документации даёт доступ к хосту, и взлома тут не требуется. - Убрать
--privilegedи работать не от root: большинство «побегов» — это отданные ключи, а не ошибка в ядре. - Обновлять
runcи ядро: рантайм работает на хосте с правами root, и ошибка в нём может дать root на хосте. - Для недоверенного кода брать gVisor или микро-VM вместо обычного контейнера: граница сужает доступ к ядру хоста, но приносит свои компоненты, которые тоже надо обновлять.
Частые заблуждения
«Контейнер изолирует так же, как виртуальная машина».
У контейнера ядро общее с хостом; namespaces, cgroups и capabilities — это разметка внутри одного ядра, а не отдельная машина. NIST в SP 800-190 признаёт границу контейнера слабее, чем у гипервизора. У виртуальной машины ядро своё, и граница — гипервизор.
«Побег из контейнера — это всегда сложный взлом ядра».
Чаще всего это вообще не взлом. Доступ к хосту раздают конфигурацией: членство в группе docker, проброшенный /var/run/docker.sock, флаг --privileged. Всё это по документации даёт полный доступ к хосту — ломать нечего.
«Добавить пользователя в группу docker — это просто удобно».
Это выдача root на хосте. Демон работает от root, а документация Docker просит: Only trusted users should be allowed to control your Docker daemon
(управлять демоном Docker должно быть позволено только доверенным пользователям). Член группы может запустить контейнер с примонтированным / и писать в файловую систему хоста — это штатная возможность, а не баг.
«Обновил runc — и побеги из контейнера закрыты».
Патчи закрывают известные дыры класса 2 (CVE-2019-5736, CVE-2024-21626), но рантайм и ядро остаются в доверенной базе, и следующая дыра возможна. А класс 1 (розданные ключи) патчами не лечится вовсе — только конфигурацией.
«Раз я root внутри контейнера, я почти на хосте».
Само по себе — нет: root внутри контейнера ограничен namespaces, cgroups и урезанными capabilities на общем ядре. Опасным это становится, когда права раздали (--privileged, проброшенный сокет) или когда есть ошибка в общем рантайме. А с user namespaces root внутри контейнера и вовсе перестаёт быть root на хосте.
Проверка знаний
Разработчик просит добавить его в группу docker «чтобы не писать sudo». Что это значит для безопасности хоста?
Источники и что читать дальше
9 ИСТОЧНИКОВ
- Docker Engine security — Docker daemon attack surfaceОфициальная документация. Первоисточник для двух опорных фактов статьи. Дословно: «Only trusted users should be allowed to control your Docker daemon» (Управлять демоном Docker должно быть позволено только доверенным пользователям) — то есть доступ к демону равен 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» (вы можете запустить контейнер, в котором каталог /host — это корень файловой системы хоста; и контейнер сможет менять её без каких-либо ограничений). Это не уязвимость, а задокументированное поведение — потому и опасно.https://docs.docker.com/engine/security/
- OWASP Docker Security Cheat SheetОфициальная документация. Свод защитных правил, на которые опирается раздел «Как это закрыть»: не пробрасывать /var/run/docker.sock, не запускать с --privileged, сбрасывать capabilities и добавлять только нужные, работать под непривилегированным пользователем, --security-opt=no-new-privileges, не выключать профиль seccomp/AppArmor, read-only ФС, rootless-режим.https://cheatsheetseries.owasp.org/cheatsheets/Docker_Security_Cheat_Sheet.html
- CVE-2019-5736 — перезапись бинарника рантаймаИсточник. Пример настоящего побега через общий рантайм. Дословно: «runc through 1.0-rc6 … allows attackers to overwrite the host runc binary (and consequently obtain host root access)» (runc вплоть до 1.0-rc6 … позволяет злоумышленнику перезаписать исполняемый файл runc на хосте (и тем самым получить на хосте права root)). Приведено как разбор класса отказа, без единого шага воспроизведения.https://www.cvedetails.com/cve/CVE-2019-5736/
- CVE-2024-21626 — утёкший дескриптор к каталогу хостаИсточник. Второй пример. Дословно: «due to an internal file descriptor leak, an attacker could cause a newly-spawned container process … to have a working directory in the host filesystem namespace» (из-за внутренней утечки файлового дескриптора злоумышленник мог добиться того, чтобы у только что запущенного процесса контейнера … рабочий каталог оказался в пространстве имён файловой системы хоста). Исправлено в runc 1.1.12. Это описание механизма, а не верхняя граница ущерба: advisory проекта runc (GHSA-xr7r-f8xq-vfvv) описывает последствия вплоть до полного побега на хост. Тоже отказ рантайма; приведено как разбор, не как инструкция.https://www.tenable.com/cve/CVE-2024-21626
- NIST SP 800-190 — Application Container Security GuideОфициальная документация. Отраслевое руководство по безопасности контейнеров: модель угроз, риски образов, рантайма и оркестратора, принцип наименьших привилегий. Опора для тезиса, что изоляция контейнера — не граница безопасности того же класса, что виртуальная машина.https://csrc.nist.gov/pubs/sp/800/190/final
- Firecracker — DesignОфициальная документация. Почему для недоверенного кода берут микро-VM: у неё нет общего с хостом ядра, граница — гипервизор KVM и VMM с узким набором виртуальных устройств. Отсюда вывод статьи: путь, которым шли runc-побеги, у микро-VM упирается в гостевую VM, а не в хост; граница при этом не исчезает, а переносится — на VMM, виртуальные устройства и компоненты на стороне хоста.https://github.com/firecracker-microvm/firecracker/blob/main/docs/design.md
- runc security advisory GHSA-xr7r-f8xq-vfvv (CVE-2024-21626)Официальная документация. Advisory самого проекта к CVE-2024-21626. Опора для оговорки, что рабочий каталог в файловой системе хоста — первый достигнутый эффект, а не предел: advisory разбирает и сценарии, ведущие к полному побегу из контейнера. Приведено как разбор последствий, без шагов воспроизведения.https://github.com/opencontainers/runc/security/advisories/GHSA-xr7r-f8xq-vfvv
- gVisor — Security ModelОфициальная документация. Модель угроз gVisor: системные вызовы недоверенного кода обслуживает Sentry — ядро в пользовательском пространстве, — а сам Sentry обращается к ядру хоста через ограниченный seccomp набор вызовов. Документация рассматривает и случай, когда Sentry скомпрометирован: тогда атакующему остаётся эта узкая поверхность ядра хоста. Опора для тезиса статьи, что gVisor сужает поверхность и добавляет границу, а не делает побег невозможным.https://gvisor.dev/docs/architecture_guide/security/
- Kubernetes — Pod Security Standards, RuntimeClass, core/v1 SecurityContextОфициальная документация. Три политики и то, что каждая запрещает: «These policies are cumulative and range from highly-permissive to highly-restrictive» (Эти политики накопительные и идут от крайне разрешающей к крайне ограничивающей). Разница между Baseline и Restricted по seccomp взята дословно: первая запрещает «Seccomp profile must not be explicitly set to Unconfined» (Профиль seccomp не должен быть явно выставлен в Unconfined), вторая требует «Seccomp profile must be explicitly set to one of the allowed values. Both the Unconfined profile and the absence of a profile are prohibited» (Профиль seccomp должен быть явно выставлен в одно из допустимых значений. Запрещены и профиль Unconfined, и отсутствие профиля). Семантика полей — из типов API: «AllowPrivilegeEscalation is true always when the container is: 1) run as Privileged 2) has CAP_SYS_ADMIN» (AllowPrivilegeEscalation всегда истинно, когда контейнер запущен как Privileged или имеет CAP_SYS_ADMIN) и «runAsNonRoot… the Kubelet will validate the image at runtime» (kubelet во время запуска проверит образ).https://kubernetes.io/docs/concepts/security/pod-security-standards/