Побег из контейнера: как выходят на хост и как это закрыть
Контейнер — это процесс в отдельных пространствах имён поверх общего с хостом ядра. Большинство «побегов» — вовсе не взлом изоляции, а розданные ключи: доступ к демону, проброшенный сокет, флаг --privileged. Настоящие побеги через ядро реже, но опаснее. Разберём оба класса и то, чем каждый закрывается.
Полное техническое изложение
TL;DR
- Контейнер — это обычная программа на том же ядре, что и хост; ядро просто показывает ей урезанный мир. Виртуальной машины с отдельным ядром тут нет.
- Поэтому большинство «побегов» — вообще не взлом. Хосту отдали ключи: пользователь в группе
docker, проброшенный сокет демона, флаг--privileged. Это по документации даёт доступ к хосту. - Настоящий побег — реже: ошибка в общем рантайме (
runc) даёт root на хосте, потому что ядро одно на всех. - Защита: не раздавать группу
dockerи сокет, убрать--privileged, работать без root, обновлятьrunc. Для недоверенного кода — брать не контейнер, а микро-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 (CVE-2019-5736 и CVE-2024-21626): оба давали root на
хосте — потому что ядро одно на всех. Оба давно исправлены; важно вовремя
обновляться.
Картинка ниже показывает две модели рядом — контейнер с общим ядром и микро-VM с гипервизором — и какую границу пробивает каждый класс.
Как закрыть
- Не раздавать ключи. Группа
docker— только доверенным; не пробрасыватьdocker.sock; убрать--privileged. - Сузить права. Работать под непривилегированным пользователем, сбросить лишние привилегии, включить rootless-режим — тогда даже побег не даёт root на хосте.
- Обновлять
runcи не выключать защиту ядра (seccomp, AppArmor/SELinux). - Для недоверенного кода — убрать общее ядро. gVisor, Kata или микро-VM (Firecracker): у них своё ядро и граница-гипервизор, и класс runc-побегов до хоста уже не доходит.
TL;DR
Контейнер — это обычный процесс, которому ядро выдало отдельные пространства
имён, cgroups и урезанный набор capabilities. Ядро при этом общее с
хостом. Отсюда два разных класса «побега», и путать их нельзя.
Класс 1 — розданные ключи. Чаще всего никакого взлома нет: хосту отдали
управление сами. Пользователь в группе docker, проброшенный в контейнер
/var/run/docker.sock, флаг --privileged, монтирование / с хоста — всё это
по документации даёт полный доступ к хосту. Ломать нечего: дверь открыта.
Класс 2 — ошибка в общем рантайме или ядре. Реже и труднее, но именно это
«настоящий побег»: runc (CVE-2019-5736, CVE-2024-21626) даёт root на хосте не
потому, что изоляция слаба, а потому, что рантайм и ядро — общие.
Чем закрывается. Класс 1 — гигиеной прав: не раздавать группу docker,
не пробрасывать сокет, убрать --privileged, сбросить лишние capabilities,
rootless-режим. Класс 2 — обновлять runc и, для недоверенного кода, убирать
общее ядро вовсе: gVisor, Kata, микро-VM (Firecracker). Нет общего ядра — нет и
класса 2.
Что такое контейнер и где у него граница
Контейнер — не виртуальная машина. Это процесс на том же ядре, что и хост,
которому ядро показывает урезанную картину мира: свои идентификаторы процессов,
свою сеть, свою файловую систему — это пространства имён (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. Дескриптор, указывающий в файловую систему хоста, оказался
достижим изнутри — потому что ядро одно на всех.
Общее у обоих: ядро у контейнера и хоста одно, и отказ рантайма даёт к нему мост. Это и делает класс 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). У них своё гостевое ядро, граница с хостом — гипервизор с узким интерфейсом, и класс runc-побегов до хоста уже не доходит: пробивать нечего, общего ядра нет.
То же самое в 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, когда список нарушений пуст.
И то, чем закрывается второй класс. Убрать общее ядро в 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.
Спрашивайте не «сломают ли контейнер», а «что мы сами отдали и какая граница защищает остальное». У обычного контейнера это разметка на общем ядре; у микро-VM — гипервизор, и поверхность у него на порядок меньше.
Это не пересказ и не отдельный текст: всё ниже взято из самой статьи — её выжимка, заголовки разборов, колонка «на самом деле» и таблица версий. Поэтому разойтись со статьёй эти тезисы не могут.
Суть
- Контейнер — это обычный процесс, которому ядро выдало отдельные пространства имён,
cgroupsи урезанный наборcapabilities. Ядро при этом общее с хостом. Отсюда два разных класса «побега», и путать их нельзя. - Класс 1 — розданные ключи. Чаще всего никакого взлома нет: хосту отдали управление сами. Пользователь в группе
docker, проброшенный в контейнер/var/run/docker.sock, флаг--privileged, монтирование/с хоста — всё это по документации даёт полный доступ к хосту. Ломать нечего: дверь открыта. - Класс 2 — ошибка в общем рантайме или ядре. Реже и труднее, но именно это «настоящий побег»:
runc(CVE-2019-5736, CVE-2024-21626) даёт root на хосте не потому, что изоляция слаба, а потому, что рантайм и ядро — общие. - Чем закрывается. Класс 1 — гигиеной прав: не раздавать группу
docker, не пробрасывать сокет, убрать--privileged, сбросить лишниеcapabilities, rootless-режим. Класс 2 — обновлятьruncи, для недоверенного кода, убирать общее ядро вовсе: gVisor, Kata, микро-VM (Firecracker). Нет общего ядра — нет и класса 2.
На самом деле
- У контейнера ядро общее с хостом; 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: три уровня и что каждый запрещает
- Что стоит унести
Частые заблуждения
«Контейнер изолирует так же, как виртуальная машина».
У контейнера ядро общее с хостом; 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». Что это значит для безопасности хоста?
Источники и что читать дальше
7 ИСТОЧНИКОВ
- 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. Тоже отказ рантайма вокруг общего ядра; приведено как разбор, не как инструкция.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 с узким интерфейсом. Отсюда вывод статьи: класс runc-побегов на микро-VM (и на gVisor/Kata) до хоста не доходит, потому что общего ядра нет.https://github.com/firecracker-microvm/firecracker/blob/main/docs/design.md
- 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/