MAATRIX / Блог / Capabilities: почему root внутри контейнера — уже не совсем root

Capabilities: почему root внутри контейнера — уже не совсем root

MAATRIX

Если вы хоть раз запускали docker exec -it container id и видели uid=0(root), наверняка возникала мысль: ну всё, внутри контейнера точно такой же всемогущий root, как на голом сервере, просто в отдельной коробочке. Это не так, и не так уже больше двадцати лет — с тех пор, как в ядре Linux появился механизм capabilities. Именно он превращает «root внутри контейнера» из абсолютной власти в набор из полутора десятков конкретных, поимённо перечисленных прав. Разберём, как это устроено на уровне ядра, что входит в контейнер по умолчанию и что root физически не может сделать с хостом, даже если процесс внутри полностью скомпрометируют.

Root — это не одна привилегия, а исторически всё сразу

В классической модели Unix права проверяются предельно просто: если у процесса uid == 0, ядро пропускает практически любую привилегированную операцию без дальнейших вопросов. Смена владельца чужого файла, открытие порта ниже 1024, монтирование файловой системы, загрузка модуля ядра, изменение системных часов, трассировка чужого процесса, перезагрузка машины — исторически всё это было завязано на одну и ту же проверку одного и того же UID.

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

Ядро решило эту проблему не для контейнеров — она существовала в чистом Unix задолго до Docker. Начиная с ядра 2.2, появилась модель POSIX-подобных capabilities: право root разбито примерно на четыре десятка независимых битов, каждый со своим именем CAP_*, каждый можно выдавать или отбирать у процесса отдельно от остальных. Контейнеры не изобрели этот механизм — они его унаследовали и сделали его границей по умолчанию.

Как capabilities дробят root на конкретные права

Смысл прост: вместо одной всемогущей проверки uid==0 ядро проверяет конкретный бит на конкретную операцию. Вот самые важные из них — именно эти чаще всего всплывают в контексте контейнеров:

CapabilityЧто разрешаетПример операции
CAP_CHOWNменять владельца и группу любого файлаchown файла, принадлежащего другому пользователю
CAP_DAC_OVERRIDEобходить проверки прав чтения/записи/выполнениячитать файл с правами 600 чужого пользователя
CAP_FOWNERобходить проверки владения при операциях над файламименять права на файл, которым процесс не владеет
CAP_NET_BIND_SERVICEоткрывать TCP/UDP-порты ниже 1024nginx слушает 80 и 443 без полного root
CAP_NET_RAWсоздавать raw- и packet-сокетыping, простые снифферы трафика
CAP_NET_ADMINменять сетевую конфигурациюдобавлять интерфейсы, маршруты, правила iptables
CAP_SYS_TIMEменять системные часыdate -s, вызов settimeofday()
CAP_SYS_MODULEзагружать и выгружать модули ядраinsmod, modprobe
CAP_SYS_ADMINроссыпь административных операциймонтирование ФС, квоты, часть настроек namespaces
CAP_SYS_PTRACEтрассировать чужие процессыstrace -p <pid> для процесса другого пользователя
CAP_SYS_BOOTперезагружать или выключать системуreboot, kexec
CAP_SETUID / CAP_SETGIDменять UID/GID процессаsu, sudo, смена привилегий демоном
CAP_KILLслать сигналы чужим процессамkill -9 процессу другого пользователя
CAP_MKNODсоздавать файлы устройствmknod /dev/whatever
CAP_SYS_CHROOTвызывать chroot()смена корня файловой системы процесса

Полный root — это процесс, у которого выставлены сразу все эти биты. Веб-серверу из этого списка реально нужен один — CAP_NET_BIND_SERVICE, чтобы слушать 80 порт. Всё остальное — избыточная власть, которая ничего не даёт серверу для его работы, зато даёт атакующему, если он найдёт в этом сервере дыру.

Отдельно стоит CAP_SYS_ADMIN — по сути это корзина, куда разработчики ядра годами сваливали административные операции, для которых не нашлось отдельного имени. Она покрывает десятки не связанных друг с другом действий, поэтому выдача CAP_SYS_ADMIN по факту почти равносильна выдаче полного привилегированного доступа для многих практических целей — относитесь к ней с той же осторожностью, что и к самому root.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Permitted, effective, bounding: как ядро действительно проверяет права

У каждого процесса capabilities хранятся не одним флагом, а несколькими наборами:

  • Permitted — максимум, который процесс вообще может когда-либо иметь активным;
  • Effective — то, что реально используется прямо сейчас: именно этот набор ядро сверяет при каждой привилегированной операции;
  • Inheritable — что может быть унаследовано дочерним процессом через execve(), если у исполняемого файла тоже выставлен нужный флаг;
  • Bounding set — жёсткий потолок. Никакой процесс, даже через эксплуатацию локальной уязвимости или запуск setuid-бинарника, не может получить capability, которой нет в bounding set его текущего пространства имён.

Для контейнера критичен именно bounding set. Рантайм — runc, containerd, тот же Docker — выставляет bounding set для PID 1 контейнера ещё до того, как выполнит execve() вашего процесса. Даже если внутри контейнера процесс работает от UID 0, все его capability-наборы пересекаются с этим потолком. Значит, что бы ни произошло дальше внутри контейнера — включая гипотетическую эксплуатацию бага, который в обычной системе позволил бы «нарастить» привилегии, — процесс физически не может выйти за пределы бита, которого в bounding set просто нет. Это не политика, которую можно обойти из userspace, а структура, которую проверяет ядро при каждом системном вызове.

Посмотреть текущие наборы можно напрямую:

grep Cap /proc/self/status
# CapInh: 0000000000000000
# CapPrm: 00000000a80425fb
# CapEff: 00000000a80425fb
# CapBnd: 00000000a80425fb
# CapAmb: 0000000000000000

Расшифровать шестнадцатеричную маску в читаемый список помогает capsh (пакет libcap2-bin на Debian/Ubuntu):

capsh --decode=00000000a80425fb

Дефолтный набор capabilities контейнера

Из примерно четырёх десятков capabilities, которые вообще существуют в ядре, контейнерные рантаймы по умолчанию включают в bounding set гораздо более узкий список. У Docker/Moby и runc это исторически такой набор:

CAP_CHOWN
CAP_DAC_OVERRIDE
CAP_FOWNER
CAP_FSETID
CAP_KILL
CAP_MKNOD
CAP_NET_BIND_SERVICE
CAP_NET_RAW
CAP_SETFCAP
CAP_SETGID
CAP_SETPCAP
CAP_SETUID
CAP_SYS_CHROOT
CAP_AUDIT_WRITE

Это ориентир на конец августа 2026 года — конкретный список задаётся конфигурацией вашего рантайма и может отличаться, если демон настроен нестандартно; сверяйтесь с документацией конкретной версии, если это критично.

Ключевое здесь то, чего в этом списке нет: CAP_SYS_ADMIN, CAP_SYS_MODULE, CAP_SYS_TIME, CAP_SYS_PTRACE, CAP_SYS_BOOT, CAP_NET_ADMIN, CAP_SYS_RAWIO и ещё добрый десяток бит. Все они существуют в ядре, все они часть того, что умеет «настоящий» root — но ни один рантайм не выдаёт их контейнеру автоматически. Их нужно запросить явно.

Что root в контейнере физически не может сделать на хосте

Вот практическая проверка — попробуйте это в обычном контейнере со стандартным набором прав:

docker run --rm alpine date -s "2020-01-01"
# date: can't set date: Operation not permitted

docker run --rm --cap-add=SYS_TIME alpine date -s "2020-01-01"
# сработает — потому что теперь capability явно выдана

Тот же принцип для остальных «серьёзных» операций:

  • изменить системные часы хоста без CAP_SYS_TIME;
  • смонтировать произвольное блочное устройство без CAP_SYS_ADMIN;
  • загрузить или выгрузить модуль ядра без CAP_SYS_MODULE;
  • перезагрузить или выключить хост без CAP_SYS_BOOT;
  • трассировать процесс, принадлежащий другому пользователю, без CAP_SYS_PTRACE;
  • перенастроить сетевые интерфейсы хоста без CAP_NET_ADMIN.

По умолчанию ни одна из этих capabilities не выдана. Значит root внутри контейнера — формально UID 0, id честно покажет root — не может выполнить ни одну из перечисленных операций над хостом, даже если атакующий полностью завладеет процессом внутри контейнера через уязвимость в приложении.

Важная оговорка: защита здесь двойная, и capabilities — только одна её половина. Вторая половина — пространства имён (namespaces). Даже с выданной CAP_SYS_ADMIN процесс в своём mount namespace монтирует что-то только в собственном обзоре файловой системы, если явно не расшарен доступ к монтированиям хоста. А в rootless-режиме (когда контейнер работает через user namespace с ремапом UID) ядро проверяет capability относительно конкретного user namespace — то есть даже теоретически «полный» набор прав внутри такого контейнера не даёт власти над реальными ресурсами хоста, потому что ядро видит эти права привязанными к чужому, изолированному пространству идентификаторов. Подробнее о том, почему это работает именно так, — в материале про то, как процессы в контейнере думают, что они одни.

Из этого не следует, что контейнер — герметичная песочница: у процесса внутри по-прежнему остаётся полный доступ ко всему, что лежит в его собственной файловой системе, ко всей памяти самого контейнера, ко всем данным, которые в него смонтированы. Разбор того, где именно проходит граница изоляции и почему её легко переоценить, — в отдельной статье про миф «контейнер — это песочница».

Как посмотреть и точечно настроить capabilities

Практическая модель — начинать с полного отказа и добавлять только то, что реально нужно:

docker run --rm -it \
  --cap-drop=ALL \
  --cap-add=NET_BIND_SERVICE \
  -p 80:80 \
  nginx:alpine

Тот же принцип в docker-compose.yml:

services:
  web:
    image: nginx:alpine
    cap_drop:
      - ALL
    cap_add:
      - NET_BIND_SERVICE
    ports:
      - "80:80"

В Kubernetes — через securityContext пода или контейнера:

securityContext:
  capabilities:
    drop: ["ALL"]
    add: ["NET_BIND_SERVICE"]

Проверить, что реально применилось к уже запущенному контейнеру:

docker inspect mycontainer --format '{{json .HostConfig.CapAdd}} {{json .HostConfig.CapDrop}}'

Если после --cap-drop=ALL приложение падает с Operation not permitted, не нужно гадать — можно поймать конкретный отказавший syscall через strace и по нему понять, какая именно capability нужна:

strace -f -e trace=%process,%file -o /tmp/trace.log ./your-app
grep -i "EPERM" /tmp/trace.log

Дальше добавляется ровно один недостающий бит, а не всё подряд «на всякий случай». Общие практики по безопасной настройке контейнеров, включая работу с capabilities как частью более широкого чек-листа, собраны в статье про лучшие практики безопасности Docker.

Ловушка --privileged: когда «на всякий случай» возвращает полного root

Флаг --privileged — самый быстрый способ свести на нет всё, что описано выше. Он не добавляет одну-две capability, а разом:

  • включает весь bounding set — все существующие capabilities, включая CAP_SYS_ADMIN, CAP_SYS_MODULE, CAP_SYS_BOOT;
  • отключает фильтрацию системных вызовов через seccomp;
  • снимает конфайнмент AppArmor/SELinux;
  • открывает контейнеру прямой доступ ко всем устройствам хоста в /dev, а не только к тем, что явно проброшены.

По факту это возврат к дореформенной модели root — с той лишь разницей, что процесс формально сидит в отдельных namespaces. Для многих операций (загрузка модуля, работа с raw-устройствами, управление cgroups хоста) namespaces такую власть не сдерживают вовсе.

К --privileged тянутся обычно от нежелания разбираться: контейнеру не даётся какая-то одна операция, проще всего выдать всё сразу. Так рождаются типичные антипаттерны запуска сервисов от root — только на уровне контейнеров, а не пользователей ОС. Практическая альтернатива почти всегда есть:

  • нужен доступ к конкретному устройству — пробросьте его явно через --device, а не весь /dev;
  • нужен Docker-in-Docker — рассмотрите rootless-подход или специализированные инструменты изоляции вместо блока --privileged целиком;
  • нужен мониторинг хостовых метрик из контейнера — смонтируйте нужные пути /proc и /sys в режиме только для чтения и добавьте одну конкретную capability вместо снятия всех ограничений сразу.

Пять минут на strace и точечный --cap-add почти всегда обходятся дешевле, чем инцидент, в котором скомпрометированный контейнер получает буквально те же права над хостом, что и его физический администратор.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Если убрать CAP_SETUID, сломается ли su/sudo внутри контейнера?

Да. Обе команды используют системные вызовы setuid()/setgid(), для которых нужна именно эта capability; без неё они завершатся с Operation not permitted.

Помогают ли capabilities против самой уязвимости в приложении?

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

Чем это отличается от rootless-режима Docker или Podman?

Capabilities урезают набор прав внутри текущего пространства идентификаторов пользователя. Rootless идёт на шаг дальше: весь демон и все процессы контейнера реально работают от непривилегированного пользователя хоста, а UID 0 внутри контейнера транслируется в обычного пользователя снаружи через ремап user namespace — это независимый и более сильный слой защиты поверх capabilities.

Можно ли добавить capability уже работающему контейнеру на лету?

Нет. Bounding set фиксируется в момент создания контейнера. Чтобы поменять набор, контейнер нужно пересоздать с новыми --cap-add/--cap-drop.

Что делать, если непонятно, какая capability нужна процессу, который падает с Operation not permitted?

Запустить его под strace с фильтром по файловым и процессным вызовам, найти вызов, вернувший EPERM, и сопоставить его с таблицей capabilities — обычно это сразу указывает на конкретный недостающий бит.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →