open atlas
↑ К треку
Безопасность облака и инфраструктуры CLOUD · 02 · 02

Container runtime security

Контейнер — это процесс, а не VM: он делит ядро хоста. Сбрось Linux-капабилити, добавь seccomp-профиль и запусти read-only, чтобы баг одного процесса остался багом процесса, а не стал компрометацией хоста.

CLOUD Middle ◷ 15 min
Уровень
ОсновыJuniorMiddleSenior

У Node-сервиса есть SSRF-баг. Неприятно, но локализовано — пока ты не замечаешь, что контейнер запущен как --privileged, потому что год назад кому-то это понадобилось, чтобы примонтировать FUSE-файловую систему для разовой задачи, и откатить забыли. Атакующий разворачивает SSRF в исполнение кода внутри контейнера — и стены больше нет: --privileged отдаёт процессу все Linux-капабилити, снимает seccomp-фильтр и открывает все устройства хоста. Через mknod он создаёт блочное устройство для корневого диска хоста, монтирует его и пишет cron-задание прямо на хост. Один веб-баг — и узел захвачен, а из узла в общем кластере ты в одном «scrape kubectl-токена» от всего кластера. Ядро всегда было общим; единственным, что стояло между багом процесса и хостом, была песочница, которую ты выключил.

К концу урока ты поймёшь, почему контейнер — это процесс, делящий ядро, а не маленькая VM, и как три контроля — сброшенные капабилити, seccomp-профиль и read-only корневая ФС — превращают локализованный баг в локализованный баг вместо захвата хоста.

Контейнер — это процесс, а не VM

Самый важный факт о безопасности контейнеров: контейнер — это не маленькая виртуальная машина. Это обычный Linux-процесс на хосте, обёрнутый в namespace’ы (которые меняют то, что он видит — своё дерево PID, монтирования, сеть) и cgroup’ы (которые ограничивают то, что он использует — CPU, память). Чего у него принципиально нет, так это собственного ядра. Каждый контейнер на узле вызывает то же самое ядро хоста, что и всё остальное. У VM между нагрузкой и железом стоят гипервизор и гостевое ядро; у контейнера — одно общее ядро и немного учётной механики.

Это полностью меняет модель угроз. В VM выйти на хост означает победить гипервизор — твёрдую, хорошо изученную границу. В контейнере «побег» означает заставить общее ядро сделать что-то от твоего имени, что пересекает забор namespace’а: баг ядра, чрезмерно широкую капабилити, доступный на запись путь хоста, syscall, который никогда не фильтровался. Container escape (побег из контейнера) — это событие, когда код внутри контейнера получает исполнение кода или доступ к файлам на хосте. Это не экзотика: CVE-2019-5736 в runc позволял вредоносному образу перезаписать бинарь runc на хосте и выполниться от root на хосте; CVE-2022-0492 злоупотребляла путём release-agent в cgroups v1, чтобы сбежать из контейнера вообще без экзотических привилегий. Граница реальна, но тонка, и твоя задача в рантайме — сделать её толще дефолта.

Capabilities: перестань быть root в 40 степенях

В классическом UNIX всё бинарно: ты либо root (uid 0, может всё), либо нет. Linux capabilities дробят всемогущество root на ~40 отдельных привилегий, которые можно выдавать или сбрасывать по одной: CAP_NET_BIND_SERVICE (привязка к портам ниже 1024), CAP_NET_RAW (raw-пакеты, ping), CAP_SYS_ADMIN (свалка настолько широкая, что её зовут «новым root»), CAP_SYS_PTRACE, CAP_DAC_OVERRIDE (обход проверки прав на файлы) и так далее.

Вот ловушка: контейнер, работающий от root, — это не то же самое, что контейнер от всемогущего root хоста, но по умолчанию он всё равно получает щедрый дефолтный набор капабилити. Рефлекс зрелого инженера — сбросить ALL, потом добавить обратно только то, что нагрузке доказуемо нужно, — почти всегда ничего, потому что типичный веб-сервис привязывается к высокому порту и читает свои же файлы. Капабилити, которые делают побег возможным — CAP_SYS_ADMIN (mount, BPF, множество путей побега), CAP_SYS_MODULE (загрузка модуля ядра = мгновенный конец игры), CAP_DAC_READ_SEARCH (чтение файлов CVE-2014-9357 / Docker «Shocker»), CAP_NET_ADMIN — это ровно те, которые нормальное приложение никогда не использует. Сбросить их ничего не стоит, а целые классы побега исчезают. Антипаттерн обратный: --privileged, который не просто выдаёт все капабилити — он ещё и отключает seccomp/AppArmor и открывает устройства хоста, из-за чего и фигурирует почти в каждом реальном разборе побега из контейнера.

Seccomp: сократи поверхность атаки через syscall’ы

Капабилити гейтят привилегированные операции; seccomp (secure computing mode) гейтит сами syscall’ы. Ядро Linux экспонирует ~300–400 системных вызовов, и эта поверхность и есть поверхность атаки — любой эксплойт ядра в контейнере в конечном счёте это «вызови такой syscall с такими аргументами». seccomp-профиль — это allowlist (или denylist) syscall’ов, применённый к процессу; заблокированный syscall возвращает EPERM или убивает процесс вместо входа в путь ядра.

Практические числа важны. Дефолтный seccomp-профиль Docker уже блокирует ~44 самых опасных syscall’а — mount, ptrace чужих процессов, kexec_load (загрузить новое ядро), bpf, keyctl, namespace-трюки unshare/setns, — и один этот дефолт нейтрализовал несколько реальных CVE-побегов, зависевших от теперь заблокированного syscall’а. Зрелый сбой, который надо распознать: в Kubernetes seccomp не включён по умолчанию для старых кластеров. Под без заданного seccompProfile исторически работал unconfined — вся поверхность syscall’ов открыта, — поэтому современный рефлекс это задать seccompProfile: { type: RuntimeDefault } на весь кластер (а новые версии Kubernetes ставят это по умолчанию). Цена реальна, но мала: слишком тугой кастомный профиль может сломать нагрузку, которой легитимно нужен необычный syscall (некоторые JIT, приложения с активным io_uring), поэтому ход такой — RuntimeDefault как базовая линия, а кастомный профиль только когда ты измерил, какие syscall’ы приложение реально делает.

Read-only корневая ФС и остальной набор харденинга

Третий столп — read-only корневая файловая система (readOnlyRootFilesystem: true). Если файловая система контейнера неизменяема, атакующий, получивший исполнение кода, не может подбросить веб-шелл, перезаписать бинарь на $PATH, изменить конфиг ради закрепления или подготовить инструменты для следующего шага. Большинство приложений вообще не пишут в свой образ — они пишут в базу, в лог-сток или во временную папку, — поэтому ты монтируешь маленький emptyDir на /tmp для по-настоящему эфемерных записей и замораживаешь всё остальное. Это превращает «у меня есть шелл» в «у меня есть шелл, который ничего не может сохранить», что ломает закрепление и большую часть подготовки инструментов.

Эти контроли взаимно усиливают друг друга, и таблица делает соответствие конкретным: каждый рвёт своё звено цепочки побега, в чём и весь смысл эшелонированной защиты — единичный обход не должен отдавать хост.

КонтрольЧто ограничиваетКакое звено побега рвётPod spec / флаг
Сброс capabilitiesПривилегированные операции ядра (mount, BPF, модули)Убирает пути побега через CAP_SYS_ADMIN/MODULEcapabilities.drop: ALL
seccomp-профильКакие из ~350 syscall’ов процесс может вызыватьБлокирует опасный syscall, нужный эксплойтуseccompProfile.type: RuntimeDefault
Read-only корневая ФСЗапись в собственную ФС контейнераОстанавливает подброс веб-шелла / перезапись бинаря / закреплениеreadOnlyRootFilesystem: true
Запуск не от rootuid 0 внутри контейнера (ограничивает ущерб при побеге)Сужает радиус поражения частичного побегаrunAsNonRoot: true
Запрет эскалации привилегийsetuid-бинари, вновь получающие привилегиюЗакрывает путь повторной эскалации через setuidallowPrivilegeEscalation: false
Почему это работает

Почему runAsNonRoot в одиночку недостаточно? Потому что не-root внутри контейнера всё равно делит ядро хоста, а багу ядра нет дела до твоего uid — CVE в пути syscall’а может отдать исполнение кода на хосте из непривилегированного пользователя контейнера. Не-root — это необходимое сокращение радиуса поражения (оно останавливает тривиальное злоупотребление привилегией внутри контейнера и требуется большинством Pod Security Standards), но это слой, а не стена. Стена строится из capabilities + seccomp + read-only + non-root вместе, потому что каждый закрывает путь, который другие оставляют открытым. Именно поэтому --privileged так опасен: он не ослабляет один слой, он снимает сразу несколько.

Когда флагов харденинга мало: более сильная изоляция

Иногда нагрузка по-настоящему недоверенная — ты запускаешь чужой код (CI-задачи, мультитенантную платформу функций, сгенерированный ИИ код в песочнице). Флаги харденинга сокращают поверхность атаки общего ядра, но не устраняют её; ядро всё ещё общее, поэтому достаточно хороший 0-day в ядре всё равно сбегает. Для этого уровня ответ — более сильный рантайм изоляции: gVisor (ядро в user-space, перехватывающее syscall’ы, так что контейнер никогда не касается реального ядра напрямую) или Kata Containers / Firecracker microVM (реальное лёгкое гостевое ядро на каждую нагрузку, изоляция уровня гипервизора со стартом почти как у контейнера). Они стоят некоторой производительности и операционной сложности, поэтому ты не тянешься к ним ради своих first-party сервисов — но для по-настоящему враждебного кода «закалять общее ядро» — неверная высота, а «не делить ядро» — верная.

Выбери лучший вариант

У first-party веб-сервиса был RCE. Он работает как `--privileged`, потому что обслуживающей задаче когда-то понадобился расширенный доступ. Выбери изменение, которое сильнее всего снижает шанс того, что RCE внутри контейнера станет компрометацией хоста.

Викторина

Почему побег из контейнера угрожает хосту так, как побег из VM не угрожает, — и что делает `--privileged` уникально опасным?

Викторина

Твои Kubernetes-поды задают `runAsNonRoot: true`, но больше ничего. Почему этого недостаточно и какова зрелая базовая линия?

Расставь шаги по порядку

Упорядочь цепочку побега из контейнера, которую призван разорвать рантайм-харденинг, от начального бага до полной компрометации кластера:

  1. 1 Баг приложения даёт исполнение кода внутри контейнера
  2. 2 Широкий/privileged набор капабилити разрешает опасную операцию
  3. 3 Нефильтрованный syscall (нет seccomp) достигает уязвимого пути ядра
  4. 4 Записываемый путь хоста позволяет атакующему писать на узел
  5. 5 Исполнение кода на хосте → scrape токена service-account → пивот в кластер
Вспомните перед уходом
  1. 01
    Объясни, почему контейнер — иная граница безопасности, чем VM, и что конкретно означает «побег из контейнера».
  2. 02
    Пройди по трём ключевым рантайм-контролям — capabilities, seccomp, read-only корень — и что каждый останавливает.
Итог

Контейнер — не маленькая VM, а процесс хоста, изолированный namespace’ами и cgroup’ами и делящий одно ядро хоста со всем остальным на узле. Это общее ядро и есть вся причина существования рантайм-безопасности контейнеров: баг процесса, чрезмерно широкая капабилити, нефильтрованный syscall или записываемый путь хоста могут пересечь забор namespace’а и стать исполнением кода на хосте — побегом из контейнера, — а из узла в общем кластере вытянутый токен service-account превращает один веб-баг в компрометацию кластера. Защита эшелонирована, и каждый слой рвёт своё звено этой цепочки: сбрось ALL Linux-капабилити и добавь обратно только доказуемо нужное нагрузке (убирая пути CAP_SYS_ADMIN/CAP_SYS_MODULE, которые любят побеги); примени seccomp-профиль RuntimeDefault, чтобы поверхность из ~350 syscall’ов сжалась до того, что приложение реально вызывает (и никогда не запускай поды seccomp-unconfined); монтируй корневую ФС read-only, чтобы атакующий не закрепился и не подготовил инструменты; запускай не от root с allowPrivilegeEscalation: false, чтобы ограничить радиус поражения. Кардинальный грех — --privileged, который рушит несколько слоёв разом и фигурирует почти в каждом реальном разборе побега. Когда нагрузка по-настоящему недоверенная, перестань закалять общее ядро и перестань делить его — тянись к gVisor или Kata/Firecracker. Теперь, читая pod spec, твой первый вопрос: сбрасывает ли этот securityContext капабилити, задаёт ли seccomp-профиль и замораживает ли ФС — или он доверяет тому, что баг процесса никогда не случится?

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено
Связанные уроки
углубляется в

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.