Главная/Основы Containerization/Урок

2.5. Capabilities

Цели

После этого материала вы сможете:

  • объяснить, что такое Linux capabilities и какую задачу они решают;
  • объяснить, почему root внутри container — не полноценный root;
  • перечислить capabilities, которые Docker оставляет по умолчанию, и назвать назначение основных;
  • посмотреть фактический набор capabilities процесса;
  • применять --cap-drop и --cap-add осознанно;
  • определить минимально необходимый набор capabilities для приложения;
  • объяснить, что именно даёт --privileged и почему он почти всегда избыточен.

Предварительные знания

Ключевые термины

ТерминОбъяснение
capabilityОтдельная привилегия, на которые разделены полномочия root
CAP_*Именование capabilities в ядре, например CAP_NET_BIND_SERVICE
permitted setНабор capabilities, которые процесс может задействовать
effective setНабор capabilities, действующих прямо сейчас
bounding setВерхняя граница: capabilities, которые процесс не может получить ни при каких условиях
privileged containerContainer, запущенный с --privileged: почти все ограничения сняты
setuidМеханизм запуска программы с правами владельца файла. Исторический предшественник capabilities

Теория

Задача, которую решают capabilities

Классическая модель Unix знает два состояния: обычный пользователь и root. root может всё — читать любые файлы, менять владельцев, открывать привилегированные порты, загружать модули ядра, перезагружать систему.

Проблема этой модели видна на простом примере. Веб-серверу нужна ровно одна привилегия — привязаться к порту 80 (порты ниже 1024 требуют привилегий). Но для этого его приходилось запускать от root, выдавая заодно право читать /etc/shadow, загружать модули ядра и форматировать диски. Уязвимость в веб-сервере превращалась в полную компрометацию машины.

Capabilities разделяют полномочия root на отдельные права — их около сорока. Процесс получает только те, которые ему нужны.

Веб-серверу достаточно CAP_NET_BIND_SERVICE. Всё остальное можно не выдавать.

Как это связано с containers

Docker по умолчанию не создаёт user namespace (урок 2.3). Значит, root внутри container — это UID 0 и на host.

Тогда почему процесс в container не может отформатировать диск host или загрузить модуль ядра?

Ответ: Docker отбирает большинство capabilities. Процесс имеет UID 0, но набор его полномочий сильно урезан. Это второй уровень защиты после namespaces.

Формулировка, которую стоит запомнить:

root внутри container — это UID 0 с ограниченным набором capabilities, ограниченным набором системных вызовов (seccomp) и изолированным представлением о системе (namespaces). Три независимых механизма.

Что Docker оставляет по умолчанию

Docker сохраняет 14 capabilities и отбирает остальные:

CapabilityЧто разрешаетЗачем оставлена
CHOWNМенять владельца файловУстановка пакетов, создание файлов приложения
DAC_OVERRIDEОбходить проверки прав доступа к файламРабота root с файлами внутри container
FOWNERДействовать как владелец файлаchmod, utime на чужих файлах внутри container
FSETIDСохранять биты setuid/setgid при изменении файлаУстановка пакетов
KILLПосылать сигналы процессам без совпадения UIDУправление дочерними процессами
SETGIDМенять GID процессаПонижение привилегий: запуск от непривилегированного пользователя
SETUIDМенять UID процессаТо же
SETPCAPИзменять набор capabilitiesПонижение привилегий
SETFCAPУстанавливать capabilities на файлыУстановка пакетов
NET_BIND_SERVICEПривязываться к портам ниже 1024Веб-серверы на портах 80 и 443
NET_RAWИспользовать RAW и PACKET сокетыping, traceroute, диагностика сети
SYS_CHROOTВыполнять chroot()Работа некоторых инструментов
MKNODСоздавать файлы устройствСовместимость с приложениями, создающими устройства
AUDIT_WRITEПисать в журнал аудита ядраРабота login и подобных утилит

Отобраны, в частности:

CapabilityЧто давала бы
SYS_ADMINОгромный набор административных операций, включая mount. Самая опасная capability
SYS_MODULEЗагрузка и выгрузка модулей ядра
SYS_TIMEИзменение системных часов (общих с host)
SYS_PTRACEОтладка чужих процессов, чтение их памяти
SYS_BOOTПерезагрузка системы
NET_ADMINНастройка сети: интерфейсы, маршруты, правила фильтрации
SYS_RAWIOПрямой доступ к портам ввода-вывода
MAC_ADMIN, MAC_OVERRIDEИзменение политик SELinux и AppArmor

Отсюда объясняется практическое наблюдение: mount внутри container не работает даже от root — нужна CAP_SYS_ADMIN, которой нет.

Принцип минимальных привилегий

Набор по умолчанию — компромисс между совместимостью и безопасностью. Он рассчитан на то, чтобы работало большинство образов, а не на минимальность.

Для конкретного приложения набор почти всегда избыточен. Типичное Python-приложение, слушающее порт 8000 от непривилегированного пользователя, не нуждается ни в одной capability.

Рекомендуемая практика для production:

bash
docker run --cap-drop=ALL <image>

И далее добавлять только то, что действительно потребуется. Подробно — в разделе 11.

--privileged

Флаг снимает почти все ограничения:

  • выдаются все capabilities;
  • снимается ограничение seccomp;
  • снимается профиль AppArmor;
  • container получает доступ ко всем устройствам host;
  • /sys монтируется в режиме записи.

Практически это означает: --privileged container может смонтировать диск host, загрузить модуль ядра, изменить настройки сети host — то есть выйти за пределы изоляции штатными средствами.

Опасная настройка. --privileged фактически снимает границу между container и host. Container с этим флагом следует считать частью host, а не изолированной средой. Компрометация приложения внутри означает компрометацию машины.

Законные применения существуют — Docker-in-Docker, некоторые системные агенты, — но они редки. В подавляющем большинстве случаев --privileged используется потому, что «без него не заработало», а нужна была одна конкретная capability или одно устройство.

Правильный путь: определить, чего не хватает, и выдать точечно через --cap-add или --device. Методика — в разделе «Практическое упражнение».


Внутренний механизм

Наборы capabilities процесса

У процесса не один набор, а несколько:

НаборНазначение
Permitted (P)Что процесс имеет право задействовать
Effective (E)Что действует прямо сейчас; ядро проверяет именно этот набор
Inheritable (I)Что наследуется при execve() — механизм редко используется
Bounding (B)Верхняя граница: чего процесс не получит никогда
Ambient (A)Сохраняется при execve() для непривилегированных программ

Ключевой для containers — bounding set. Docker формирует его при запуске, и capability, отсутствующая в bounding set, не может быть получена никаким способом изнутри container.

Наборы видны в /proc/<pid>/status:

text
CapInh: 0000000000000000
CapPrm: 00000000a80425fb
CapEff: 00000000a80425fb
CapBnd: 00000000a80425fb
CapAmb: 0000000000000000

Значения — битовые маски в шестнадцатеричном виде. Расшифровать их можно утилитой capsh.

Файловые capabilities

Capability можно назначить не только процессу, но и исполняемому файлу:

bash
sudo setcap cap_net_bind_service=ep /usr/bin/myserver

Тогда программа получает эту привилегию при запуске от любого пользователя. Это современная альтернатива биту setuid: вместо «дать все права root» — «дать одно конкретное право».

Именно этот механизм использовался в уроке 1.6 для публикации привилегированных портов в rootless mode.


Команды и примеры

Capabilities процесса на host

bash
capsh --print | head -5
text
Current: =
Bounding set =cap_chown,cap_dac_override,cap_dac_read_search,cap_fowner,...
Ambient set =
Current IAB: 
Securebits: 00/0x0/1'b0

Для обычного пользователя Current: = означает пустой набор — capabilities не нужны, права определяются обычной моделью UID.

Если capsh не установлен:

bash
sudo apt install -y libcap2-bin

Capabilities внутри container

bash
docker run --rm alpine sh -c 'apk add -q libcap && capsh --print | head -3'

Или без установки пакетов — прочитав маску напрямую:

bash
docker run --rm alpine grep CapBnd /proc/self/status
text
CapBnd:	00000000a80425fb

Расшифруем маску на host:

bash
capsh --decode=00000000a80425fb
text
0x00000000a80425fb=cap_chown,cap_dac_override,cap_fowner,cap_fsetid,cap_kill,
cap_setgid,cap_setuid,cap_setpcap,cap_net_bind_service,cap_net_raw,
cap_sys_chroot,cap_mknod,cap_audit_write,cap_setfcap

Ровно те 14 capabilities, что перечислены в таблице выше.

Сравним с host:

bash
grep CapBnd /proc/self/status
text
CapBnd:	000001ffffffffff
bash
capsh --decode=000001ffffffffff | tr ',' '\n' | wc -l
text
41

41 против 14. Docker отобрал две трети полномочий.

Демонстрация: чего не может root в container

bash
docker run --rm alpine sh -c 'mkdir -p /mnt/test && mount -t tmpfs none /mnt/test'
text
mount: permission denied (are you root?)

Процесс работает от root (проверим: docker run --rm alpine id даёт uid=0), но mount требует CAP_SYS_ADMIN, которой нет.

Добавим её:

bash
docker run --rm --cap-add=SYS_ADMIN alpine \
    sh -c 'mkdir -p /mnt/test && mount -t tmpfs none /mnt/test && echo "смонтировано"'
text
mount: mounting none on /mnt/test failed: Permission denied

Не заработало — и это важнее, чем если бы заработало. Capability выдана, а операция всё равно запрещена: её запретил другой механизм. На Ubuntu это AppArmor, чей профиль docker-default блокирует mount независимо от capabilities.

Уберём и его:

bash
docker run --rm --cap-add=SYS_ADMIN --security-opt apparmor=unconfined alpine \
    sh -c 'mkdir -p /mnt/test && mount -t tmpfs none /mnt/test && echo "смонтировано"'
text
смонтировано

Теперь заработало. Вывод из этих трёх запусков — главный в уроке: ограничения складываются, и снятие одного ничего не гарантирует. Container ограничен как минимум четырьмя независимыми механизмами — capabilities, seccomp, AppArmor (или SELinux) и namespaces, — и операция выполнится, только если её разрешат все.

Отсюда практическое следствие для диагностики: «выдал capability, а не работает» почти всегда означает, что запрещает не capability. Смотреть нужно dmesg на предмет apparmor="DENIED" и вывод docker inspect --format '{{.AppArmorProfile}}'.

CAP_SYS_ADMIN — самая широкая из capabilities: она покрывает десятки операций и часто рассматривается как «новый root». Выдавать её нужно с большой осторожностью и только при отсутствии альтернатив.

Проверка NET_BIND_SERVICE

bash
docker run --rm python:3.13-slim python -c "
import socket
s = socket.socket()
s.bind(('0.0.0.0', 80))
print('порт 80 занят успешно')
"
text
порт 80 занят успешно

Работает, потому что NET_BIND_SERVICE входит в набор по умолчанию.

Уберём её:

bash
docker run --rm --cap-drop=NET_BIND_SERVICE python:3.13-slim python -c "
import socket
s = socket.socket()
s.bind(('0.0.0.0', 80))
print('порт 80 занят успешно')
"
text
порт 80 занят успешно

Порт занялся без capability. Ожидание было противоположным, и это тот случай, когда проверка спасает от заблуждения с последствиями для безопасности.

Причина — в sysctl, который Docker выставляет внутри container'а:

bash
sysctl net.ipv4.ip_unprivileged_port_start                       # на хосте
docker run --rm alpine sysctl net.ipv4.ip_unprivileged_port_start # внутри
text
net.ipv4.ip_unprivileged_port_start = 1024
net.ipv4.ip_unprivileged_port_start = 0

Ядро требует CAP_NET_BIND_SERVICE только для портов ниже значения этого параметра. На хосте граница — 1024, внутри container'а Docker опускает её до нуля, и привилегированных портов там просто не остаётся. Отбирать capability бессмысленно: она ни на что не влияет.

Вернём границу — и capability снова начнёт что-то значить:

bash
docker run --rm --sysctl net.ipv4.ip_unprivileged_port_start=1024 \
    --cap-drop=NET_BIND_SERVICE python:3.13-slim python -c "
import socket
s = socket.socket()
s.bind(('0.0.0.0', 80))
print('порт 80 занят успешно')
"
text
PermissionError: [Errno 13] Permission denied

Теперь связь capability с поведением видна прямо.

Что из этого следует практически. Приложение в container'е может слушать 80-й порт, не имея ни root, ни NET_BIND_SERVICE:

bash
docker run --rm --user 10001:10001 --cap-drop=ALL python:3.13-slim python -c "
import socket
s = socket.socket()
s.bind(('0.0.0.0', 80))
print('порт 80 занят: uid 10001, capabilities отобраны все')
"
text
порт 80 занят: uid 10001, capabilities отобраны все

Это удобно — не нужен ни root, ни лишняя capability, — но и означает, что «слушает привилегированный порт» не является признаком привилегированного процесса. Внутри container'а привилегированных портов нет.

Отбор всех capabilities

bash
docker run --rm --cap-drop=ALL alpine grep CapBnd /proc/self/status
text
CapBnd:	0000000000000000

Пустой набор. Проверим, что обычное приложение при этом работает:

bash
docker run --rm --cap-drop=ALL python:3.13-slim python -c "
import socket, json, sys
s = socket.socket()
s.bind(('0.0.0.0', 8000))
s.listen(1)
print(json.dumps({'ok': True, 'port': 8000}))
"
text
{"ok": true, "port": 8000}

Приложение, слушающее непривилегированный порт, не нуждается ни в одной capability. Это и есть целевое состояние для production.

Точечное добавление

bash
docker run --rm --cap-drop=ALL --cap-add=NET_BIND_SERVICE python:3.13-slim python -c "
import socket
s = socket.socket()
s.bind(('0.0.0.0', 80))
print('порт 80 занят, capabilities: только NET_BIND_SERVICE')
"
text
порт 80 занят, capabilities: только NET_BIND_SERVICE

Паттерн --cap-drop=ALL --cap-add=<нужное> — рекомендуемый подход для production.

Что даёт --privileged

bash
docker run --rm --privileged alpine grep CapBnd /proc/self/status
text
CapBnd:	000001ffffffffff

Та же маска, что у root на host: все 41. Плюс отключены seccomp и AppArmor, плюс доступ к устройствам.

Демонстрация последствий — доступ к дискам host:

bash
docker run --rm --privileged alpine sh -c 'ls /dev/sd* /dev/nvme* 2>/dev/null | head'
text
/dev/nvme0n1
/dev/nvme0n1p1
/dev/nvme0n1p2

Имена зависят от оборудования: на SATA-диске это будут /dev/sda, /dev/sda1. Существенно не имя, а то, что устройства видны.

Container видит блочные устройства host и, имея все capabilities, может их монтировать и изменять. Изоляции фактически нет.

Сравним с обычным запуском:

bash
docker run --rm alpine sh -c 'ls /dev/'
text
console  core  fd  full  mqueue  null  ptmx  pts  random
shm  stderr  stdin  stdout  tty  urandom  zero

Минимальный набор виртуальных устройств. Дисков host нет.

Проверка capabilities в Compose

yaml
services:
  api:
    image: myapp:1.0
    cap_drop:
      - ALL
    cap_add:
      - NET_BIND_SERVICE
    security_opt:
      - no-new-privileges:true

Подробно — в разделе 09 и разделе 11.


Практическое упражнение

Задание. Определите минимально необходимый набор capabilities для приложения методом последовательного сужения.

  1. Возьмите образ python:3.13-slim и приложение, которое слушает порт 80 и записывает файл в /tmp.
  2. Запустите с набором по умолчанию — убедитесь, что работает.
  3. Запустите с --cap-drop=ALL — зафиксируйте, что сломалось и с какой ошибкой.
  4. Добавляйте capabilities по одной, пока не заработает.
  5. Оформите результат: итоговый набор и обоснование каждой capability.

Дополнительно: повторите для случая, когда приложение слушает порт 8000 вместо 80. Объясните разницу.

Подсказки

Подсказка 1

Тестовое приложение можно передать через python -c без создания файлов:

bash
docker run --rm --cap-drop=ALL -p 8080:80 python:3.13-slim python -c "..."
Подсказка 2

Сообщение об ошибке подсказывает нужную capability. PermissionError при bind() на порт ниже 1024 указывает на NET_BIND_SERVICE; Operation not permitted при chown — на CHOWN.

Подсказка 3

Полный список capabilities с описаниями:

bash
man 7 capabilities

Решение

Сначала выполните задание самостоятельно.

Показать решение
bash
#!/usr/bin/env bash
# find-caps.sh — подбор минимального набора capabilities.
set -uo pipefail

APP='
import socket, pathlib, sys
try:
    p = pathlib.Path("/tmp/probe.txt")
    p.write_text("ok")
    s = socket.socket()
    s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    s.bind(("0.0.0.0", PORT))
    print("OK")
except Exception as e:
    print(f"FAIL: {type(e).__name__}: {e}", file=sys.stderr)
    sys.exit(1)
'

try_caps() {
    local port="$1"; shift
    local args=()
    for c in "$@"; do args+=(--cap-add="$c"); done
    docker run --rm --cap-drop=ALL "${args[@]}" python:3.13-slim \
        python -c "${APP//PORT/$port}" 2>&1
}

echo "=== Порт 80 ==="
echo -n "  по умолчанию:              "
docker run --rm python:3.13-slim python -c "${APP//PORT/80}" 2>&1 | tail -1

echo -n "  --cap-drop=ALL:            "
try_caps 80 | tail -1

echo -n "  + NET_BIND_SERVICE:        "
try_caps 80 NET_BIND_SERVICE | tail -1

echo
echo "=== Порт 8000 ==="
echo -n "  --cap-drop=ALL:            "
try_caps 8000 | tail -1

Ожидаемый вывод:

text
=== Порт 80 ===
  по умолчанию:              OK
  --cap-drop=ALL:            FAIL: PermissionError: [Errno 13] Permission denied
  + NET_BIND_SERVICE:        OK

=== Порт 8000 ===
  --cap-drop=ALL:            OK

Результат и обоснование.

СценарийМинимальный наборОбоснование
Порт 80 (или любой ниже 1024)NET_BIND_SERVICEПривязка к привилегированному порту — единственная операция, требующая capability. Запись в /tmp от root работает без capabilities, потому что владелец каталога позволяет
Порт 8000 (или любой от 1024)пустойНи одна операция не требует привилегий

Практический вывод. Если приложение слушает порт от 1024 и работает со своими файлами, оно не нуждается ни в одной capability. Это типичный случай для Python-сервисов за reverse proxy.

Если по каким-то причинам нужен порт 80, есть два пути: выдать NET_BIND_SERVICE или — лучше — слушать порт 8000 внутри container и публиковать наружу как -p 80:8000. Второй вариант вообще не требует capabilities, потому что привязку к порту 80 выполняет Docker на host, а не приложение.

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

Проверка результата

bash
docker run --rm --cap-drop=ALL alpine grep CapBnd /proc/self/status
text
CapBnd:	0000000000000000

Нулевая маска подтверждает, что вы умеете полностью отбирать capabilities. Проверьте, что ваше приложение из проекта 2 при этом работает.

Типичные ошибки

ОшибкаПричинаИсправление
Использование --privileged, чтобы «заработало»Не выяснено, какой именно привилегии не хватаетОпределить конкретную capability и выдать через --cap-add
Мнение, что root в container безопасен «потому что изолирован»Смешаны три разных механизмаИзоляцию даёт namespaces, ограничение привилегий — capabilities и seccomp
Оставление набора по умолчанию в productionОн рассчитан на совместимость, а не на минимальность--cap-drop=ALL и точечное добавление
--cap-add=SYS_ADMIN для одной операцииЭта capability покрывает десятки операцийНайти узкую альтернативу; часто задача решается иначе
Привязка приложения к порту 80 внутри containerТребует capability без необходимостиСлушать порт от 1024, публиковать через -p 80:8000
Ожидание, что --cap-drop защитит от всегоCapabilities — один из уровней, не единственныйДополнять seccomp, non-root user, read-only rootfs
Проверка capabilities по id вместо маскиid показывает UID, а не полномочияСмотреть CapBnd в /proc/self/status

Контрольные вопросы

На понимание:

  1. Какую проблему классической модели root решают capabilities?
  2. Почему root внутри container не может выполнить mount, хотя это UID 0?
  3. Сколько capabilities Docker оставляет по умолчанию и почему именно набор такого размера?
  4. Что такое bounding set и почему он важнее остальных наборов для containers?
  5. Что именно делает --privileged, кроме выдачи всех capabilities?

На применение:

  1. Как посмотреть фактический набор capabilities процесса внутри container?
  2. Как запустить приложение с минимальным набором привилегий?
  3. Как определить, какой capability не хватает приложению?

На диагностику:

  1. Приложение падает с PermissionError при bind() на порт 443. Назовите два решения, одно из которых не требует capability.
  2. В чужом compose.yaml найден privileged: true. Какие вопросы задать автору и что предложить?

Краткое резюме

  1. Capabilities разделяют полномочия root примерно на сорок отдельных прав.
  2. Docker не создаёт user namespace по умолчанию, поэтому root в container — это UID 0 на host.
  3. Ограничение достигается отбором capabilities, профилем seccomp и namespaces — тремя независимыми механизмами.
  4. По умолчанию Docker оставляет 14 capabilities из 41.
  5. mount внутри container не работает, потому что нет CAP_SYS_ADMIN.
  6. Набор по умолчанию рассчитан на совместимость и для конкретного приложения избыточен.
  7. Рекомендуемая практика: --cap-drop=ALL и точечное добавление нужного.
  8. Приложение, слушающее порт от 1024, обычно не нуждается ни в одной capability.
  9. --privileged снимает capabilities, seccomp, AppArmor и открывает устройства host — изоляции практически не остаётся.
  10. Часто привилегия не нужна вовсе, если изменить постановку: слушать порт 8000 и публиковать как 80.

Официальные источники

ИсточникСсылкаЧто подтверждает
Docker Engine security: kernel capabilitieshttps://docs.docker.com/engine/security/#linux-kernel-capabilitiesПодход allowlist, отбор большинства capabilities по умолчанию, невозможность mount и создания устройств
docker run reference: runtime privilege and capabilitieshttps://docs.docker.com/reference/cli/docker/container/run/#privilegedСписок capabilities по умолчанию, поведение --cap-add, --cap-drop, --privileged
capabilities(7) man pagehttps://man7.org/linux/man-pages/man7/capabilities.7.htmlПолный перечень capabilities, наборы permitted/effective/bounding/ambient, файловые capabilities
capsh(1) man pagehttps://man7.org/linux/man-pages/man1/capsh.1.htmlПросмотр и расшифровка масок capabilities
setcap(8) man pagehttps://man7.org/linux/man-pages/man8/setcap.8.htmlНазначение capabilities исполняемым файлам
Compose file: serviceshttps://docs.docker.com/reference/compose-file/services/Атрибуты cap_add, cap_drop, privileged, security_opt

Навигация

← Предыдущий материал
Вернуться к разделу
Следующий материал → Container runtime и OCI
Главное оглавление

Markdown на GitHub ↗