Предысловие
Изначально с Capabilities я лично столкнулся в своем проекте ServeHub-2, когда настраивал контейнеры в docker-compose. Если быть более конкретным, я настраивал WG-easy с использованием AmneziaWG, которому нужны были привилегии, связанные с загрузкой модулей ядра и настройками интерфейсов. До этого я слышал о Capabilities, но не знал что это такое, поэтому решил подробнее в этом разобраться.
Сначала разберу общее понятие, что вообще такое Capabilities (далее буду кратко писать CAP), потом постепенно перейду к примерам из искуственно созданной среды, из своего проекта и также покажу небольшой пример настройки в Kubernetes.
В конце статьи приведу дополнительные материалы, на которые я опирался при написании этой статьи, их также будет полезно почитать/посмотреть, если остались какие-то вопросы или если просто интересно разобрать тему грубже.
Понятие Linux Capabilities и история их появления
Раньше все процессы делились на категории: обычные пользовательские процессы, проходившие проверки прав доступа (owner, group, mode), и процессы с идентификатором пользователя UID = 0 (root). Процессы суперпользователя обходили любые проверки ядра.
Основная проблема - слишком большие полномочия. Ради одной root операции нужно выдавать права и на все остальное, что создает дыру в безопасности. В случае обнаружения уязвимости в таком приложении атакующий получал полный контроль над всей системой.
Начиная с версии ядра 2.2 полномочия root были разделены на отдельные Capabilities. Среди них, например:
CAP_NET_BIND_SERVICE — право слушать привилегированные порты
CAP_CHOWN — право изменять владельца и группу файлов
CAP_NET_ADMIN — право изменять конфигурации сетевых интерфейсов и таблиц маршрутизации
CAP_SYS_MODULE — право загружать и выгружать модули ядра Linux
CAP_DAC_OVERRIDE — право обходить классические проверки прав доступа к файлам
Если что CAP - это именно что механизм ядра Linux. Для каждого процесса CAP существуют отдельно. CAP выдается конкретному процессу/пользователю для конкретной операции.
Эксперимент с двумя контейнерами
Чтобы наглядно увидеть работу CAP, создадим 2 контейнера, запущенных из одного и того же базового образа. В обоих контейнерах процесс выполняется от пользователя root (UID = 0, GID = 0).
На хосте создается файл /file и пробрасывается в оба контейнера, владельцем которого является root, права доступа 000.
При попытке выполнить команду чтения файла cat /file:
В первом контейнере команда отрабатывает успешно и выводит содержимое файла.
Во втором контейнере возвращается ошибка Permission denied.
Образ, UID, файл и права одинаковые, операция одна и та же, но результат отличается.
Чтобы понять, в чем дело зайдем в /proc и посмотри данные о текущей оболочке. Если точнее, нам нужны строчки, начинающиеся с Cap:
cat /proc/$$/status | grep Cap
Вывод содержит пять шестнадцатеричных масок:
CapInh — привилегии, сохраняемые при вызове дочерних процессов
CapPrm — максимальный набор привилегий, которые процесс имеет право себе установить
CapEff — эффективный набор привилегий, проверяемый ядром непосредственно в момент выполнения системного вызова
CapBnd — верхняя граница привилегий, которую процесс может получить
CapAmb — привилегии, сохраняемые при переходе между не-root пользователями.
Сравним значение маски CapEff для первого и второго контейнера:
В первом контейнере CapEff заканчивается на FB.
Во втором контейнере CapEff заканчивается на F9.
Все различие в значениях последних символов. Это значит, что где-то какой-то бит либо есть, либо нет. Чтобы определить, какой именно бит отличается, выполним побитовую операцию исключающего ИЛИ (XOR) над двумя шестнадцатеричными числами в bash:
# ИЛИ (XOR) над масками первого и второго контейнеров. Если проще - то находим разницу, где бит отличается. printf "%x\n" $((16#00000000a80425fb ^ 16#00000000a80425f9)) # Вывод: 2
Шестнадцатеричное значение 2 в двоичном представлении имеет вид 0010. В обеих масках установлен ровно один отличающийся бит. Узнать имя этой capability можно с помощью утилиты capsh:
# # Смотрим, что за бит скрывается за двойкой capsh --decode=2 # Вывод: cap_dac_override
DAC расшифровывается как Discretionary Access Control (избирательное управление доступом). Это права владения файлом для пользователя, группы и остальных. Привилегия CAP_DAC_OVERRIDE позволяет процессу полностью игнорировать любые ограничения DAC и открывать файлы на чтение или запись независимо от выставленных флагов прав доступа.
Первый контейнер был запущен со стандартным набором привилегий Docker, куда по умолчанию входит CAP_DAC_OVERRIDE. Во втором контейнере эта привилегия была сброшена. (я сбросил заранее) При вызове утилиты cat ядро проверило эффективный набор маски CapEff, не обнаружило там бита CAP_DAC_OVERRIDE и сгенерировало ошибку Permission denied.
Как выглядят CAP на не-root пользователе
Просто зайдем по ssh на сервер и выполним команду, которую использовали ранее в контейнерах.
cat /proc/$$/status | grep Cap CapInh: 0000000000000000 CapPrm: 0000000000000000 CapEff: 0000000000000000 CapBnd: 000001ffffffffff CapAmb: 0000000000000000
Разберём, что это значит на практике:
CapInh (0000000000000000) — Все нули. Это значит, что если процесс запустит дочернюю программу, она не получит никаких особых прав по наследству.
CapPrm (0000000000000000) — Это потолок прав, которые процесс вообще имеет право активировать. У обычного пользователя тут полные нули — включить себе хоть какую-то привилегию сам он не сможет при всём желании.
CapEff (0000000000000000) — Так как тут нули, ядро заблокирует любую попытку сделать что-то от root (например, поменять настройки сети или прочитать защищённый файл).
CapBnd (000001ffffffffff) — Единственная маска не с нулями. Здесь включены 41 бит (от 0 до 40). Это значит что в этой системе в принципе существует 41 привилегия, и если этот пользователь запустит бинарник с правами через sudo, система разрешит ему получить любую из них.
CapAmb (0000000000000000) — Тоже нули. Эта маска нужна, чтобы передавать привилегии обычным программам без SUID-битов при запуске дочерних процессов. У обычного пользователя тут ничего не сохраняется.
Настройка Capabilities в Docker Compose
Для управления набором привилегий Docker предоставляет следующие настройки:
cap_add — добавляет указанные capability процессу контейнера
cap_drop — удаляет указанные capability (например, cap_drop: ALL удаляет абсолютно все привилегии)
privileged: true — передает контейнеру полный набор возможностей хоста.
Возвращаясь к моему проекту, для корректной работы сервиса WG-easy и контейнера AmneziaWG требуются привилегии NET_ADMIN (создание сетевых интерфейсов, изменение маршрутов) и SYS_MODULE (загрузка специализированных модулей ядра). Без этих CAP, сервис нормально работать не будет, даже если пользователь будет root:
services: # Сервис управления VPN wg-easy wg-easy: image: ghcr.io/wg-easy/wg-easy:15.4 container_name: wg-easy environment: - LANG=en - WG_HOST=${VPS_PUBLIC_IP} - PORT=51821 - WG_PORT=51820 - WEBROOT_BIND_ADDRESS=127.0.0.1 - WG_DEFAULT_ADDRESS=10.8.0.x - INIT_DNS=10.8.0.1 - WG_PERSISTENT_KEEPALIVE=25 - WG_ALLOWED_IPS=0.0.0.0/0 - EXPERIMENTAL_AWG=true - OVERRIDE_AUTO_AWG=awg - INSECURE=true - WG_DEVICE=eth0 - INIT_ENABLED=true - INIT_USERNAME=${ADMIN_USER} - INIT_PASSWORD=${ADMIN_PASSWORD} - INIT_HOST=${VPS_PUBLIC_IP} - INIT_PORT=51820 volumes: - ./apps-data/wg-easy:/etc/wireguard - /lib/modules:/lib/modules:ro network_mode: "host" restart: unless-stopped # Конфигурация минимально необходимых CAP для управления сетевым стеком cap_add: - NET_ADMIN # Требуется для настройки туннеля WireGuard и правил iptables - SYS_MODULE # Требуется для подгрузки ядром модуля wireguard/amneziawg healthcheck: test: ["CMD", "awg", "show", "wg0"] interval: 30s timeout: 10s retries: 3 start_period: 10s
Архитектура проверки Capabilities на уровне ядра Linux
Если заглянуть в исходники ядра Linux, то права процесса живут в структуре cred (в файле include/linux/cred.h), на которую ссылается task_struct текущего потока. Выглядит это примерно так:
struct cred { kuid_t uid; kgid_t gid; kuid_t euid; kgid_t egid; kernel_cap_t cap_inheritable; kernel_cap_t cap_permitted; kernel_cap_t cap_effective; kernel_cap_t cap_bset; kernel_cap_t cap_ambient; struct user_namespace *user_ns; };
CAP настройки для каждого namespace расчитываются отдельно. То есть в контейнере CAP_SYS_ADMIN не означает, что такой же CAP будет в namespace хоста, и наоборот.
Теперь посмотрим как выглядит чтение файла изнутри (использование cat на файл):
Утилита cat запрашивает открытие файла с помощью системного вызова openat().
Запрос передается в слой виртуальной файловой системы VFS.
Ядро находит иноду файла и сначала выполняет стандартную проверку классических прав DAC.
Поскольку для /file выставлены права 000, проверка DAC завершается с ошибкой. (сначала проверяются “обычные” разрешения, потом CAP)
Ядро считывает маску cap_effective из структуры cred текущего процесса и проверяет наличие бита CAP_DAC_OVERRIDE.
Если бит установлен, доступ разрешается, файл открывается. Если бит отсутствует, системный вызов возвращает ошибку Permission denied.
Также стоит знать, что CAP являются лишь одним из защитных слоев Linux. Наличие CAP не гарантирует выполнение вызова, если операция блокируется модулями безопасности по типу AppArmor.
Конфигурация CAP в Kubernetes
В случае обнаружения RCE-уязвимости атакующий получает возможность выполнять код в контексте существующего процесса контейнера. Создаваемый процесс наследует UID, GID, пространства имен и установленный набор CapEff. Это означает, что при лишнем предоставлении CAP может получиться большая дыра в безопасности.
В оркестраторе Kubernetes управлением CAP занимается манифест securityContext. Имена привилегий указываются без префикса CAP_.
Пример безопасной конфигурации пода:
apiVersion: v1 kind: Pod metadata: name: secure-app-pod spec: containers: - name: application image: nginx:1.25 securityContext: # Управление capabilities capabilities: drop: - ALL # Сбрасываем абсолютно все привилегии процесса add: - NET_BIND_SERVICE # Добавляем только право слушать порты allowPrivilegeEscalation: false # Запрещаем повышение привилегий через SUID-биты runAsNonRoot: true # Требуем запуск процесса исключительно под не-root UID runAsUser: 10001 # Явно указываем UID пользователя readOnlyRootFilesystem: true # Делаем корневую файловую систему доступной только для чтения seccompProfile: type: RuntimeDefault # Включаем стандартный профиль фильтрации системных вызовов
Стоит сначала сбрасывать весь имеющийся набор возможностей с помощью drop: ALL, после чего точечно возвращать только те операции, без которых сервис физически не сможет функционировать. Такой подход гарантирует, что даже при компрометации процесса вред будет минимален.