Когда ты впервые запускаешь
Код: Выделить всё
docker run -it ubuntu bashКод: Выделить всё
root@a1b2c3:/#Код: Выделить всё
--privilegedЭтот урок про то, как docker безопасность строится слоями. Контейнер - не виртуалка, у него нет своего ядра. Один общий kernel на хост и на все контейнеры. Значит вся изоляция - это договорённость ядра ограничивать процесс. И договорённость можно усилить: убрать лишние привилегии (docker capabilities), отрезать опасные системные вызовы (seccomp docker), переназначить root на безымянного пользователя (user namespace docker) и вовсе убрать root из-под демона (docker rootless). Разберём каждый слой не как галочку в чеклисте, а как механику: что именно происходит в ядре и от какого класса атак это спасает.

Capabilities: разбираем монолитный root на 40 кусочков
Исторически в Unix была дихотомия: либо ты root (UID 0) и можешь всё, либо обычный пользователь и почти ничего. Linux разбил всемогущество root на отдельные права - capabilities. Их около сорока. CAP_NET_BIND_SERVICE - право слушать порты ниже 1024. CAP_NET_RAW - сырые сокеты (ping, sniffing). CAP_SYS_ADMIN - помойка, куда свалили половину опасных операций (mount, изменение namespaces, и прочее). CAP_SYS_PTRACE - заглядывать в память чужих процессов. CAP_CHOWN, CAP_SETUID, CAP_DAC_OVERRIDE - обход прав на файлы.
Docker по умолчанию НЕ даёт контейнеру все capabilities. Он оставляет урезанный набор примерно из 14 штук: CHOWN, DAC_OVERRIDE, FSETID, FOWNER, MKNOD, NET_RAW, SETGID, SETUID, SETFCAP, SETPCAP, NET_BIND_SERVICE, SYS_CHROOT, KILL, AUDIT_WRITE. Уже неплохо - нет SYS_ADMIN, нет SYS_PTRACE, нет SYS_MODULE. Но для типичного веб-приложения и это избыточно. Зачем nginx право CHOWN или сырые сокеты?
Правильный паттерн - drop ALL, потом add только нужное:
Код: Выделить всё
docker run --cap-drop=ALL --cap-add=NET_BIND_SERVICE nginx
Код: Выделить всё
$ capsh --print
Current: cap_net_bind_service=ep
Bounding set =cap_net_bind_service
Код: Выделить всё
epГрабли. Нельзя dropнуть ALL у образа, который рассчитан на root-операции при старте. Классика - официальный образ, который при запуске делает chown на смонтированном томе под нужным uid. Без CAP_CHOWN он упадёт с Operation not permitted. Лечится либо добавлением точечной capability, либо переходом на образ, который не дёргает root-операции (или ставит права заранее на этапе сборки).
no-new-privileges: запираем дверь для эскалации
Даже урезав capabilities, ты оставляешь лазейку - setuid-бинарники. Файл с битом setuid (например
Код: Выделить всё
/usr/bin/sudoКод: Выделить всё
/bin/pingФлаг no-new-privileges ставит в ядре бит, который запрещает процессу и всем его потомкам получать привилегии, которых не было у родителя. setuid перестаёт работать как лифт наверх:
Код: Выделить всё
docker run --security-opt no-new-privileges:true myapp
Код: Выделить всё
services:
app:
image: myapp:1.0
cap_drop: [ALL]
cap_add: [NET_BIND_SERVICE]
security_opt:
- no-new-privileges:true
read_only: true
tmpfs:
- /tmp
Capabilities ограничивают права, но не сужают саму поверхность ядра. Программа всё ещё может позвать любой из ~400 системных вызовов. А ядро - это код, в коде есть баги, и многие побеги шли через экзотические syscalls вроде
Код: Выделить всё
keyctlКод: Выделить всё
ptraceКод: Выделить всё
userfaultfdDocker применяет дефолтный seccomp-профиль автоматически, без всяких флагов. Он блокирует около 44 опасных вызовов из общего числа: среди них mount, umount2, reboot, swapon, init_module, kexec_load, ptrace (в старых ядрах), и упомянутые keyctl, userfaultfd. Логика whitelist-подобная: разрешено то, что нужно нормальным программам, остальное опасное закрыто.
Посмотреть дефолтный профиль можно в исходниках moby (файл profiles/seccomp/default.json) - это JSON со списком syscalls и действием по умолчанию SCMP_ACT_ERRNO (вернуть EPERM). Свой профиль подключается так:
Код: Выделить всё
docker run --security-opt seccomp=/path/to/profile.json myapp
Код: Выделить всё
docker run --security-opt seccomp=unconfined myapp
Код: Выделить всё
seccomp=unconfinedТонкость. Дефолтный профиль Docker заточен под обычные приложения. Некоторые легитимные программы (Chromium в headless, отдельные JIT-движки, инструменты, дёргающие clone с экзотическими флагами) могут спотыкаться. Симптом - падение с EPERM или Operation not permitted там, где прав вроде хватает. Тогда смотришь strace, находишь зарезанный syscall и добавляешь его в кастомный профиль.
user namespace docker: root внутри = никто снаружи
Главная архитектурная проблема осталась: UID 0 в контейнере по умолчанию равен UID 0 на хосте. User namespace remapping (userns-remap) это чинит. Идея: внутри контейнера процесс думает, что он root (UID 0), а ядро транслирует этот UID в безобидный высокий идентификатор на хосте, скажем 165536. Если процесс сбежит из контейнера, на хосте он окажется пользователем 165536 без всяких прав - не сможет читать чужие файлы, не сможет управлять системой.
Включается на уровне демона в /etc/docker/daemon.json:
Код: Выделить всё
{
"userns-remap": "default"
}
Код: Выделить всё
systemctl restart dockerКод: Выделить всё
# внутри контейнера
$ id -u
0
# на хосте в это же время
$ ps -o user,pid,cmd -C sleep
USER PID CMD
165536 48213 sleep 600
Цена. userns-remap ломает часть привычных паттернов. Тома, к которым контейнер пишет, теперь принадлежат remapped-UID, и привычный bind-mount каталога с хоста может стать недоступным (права не совпадают). Нельзя совмещать с
Код: Выделить всё
--privilegeddocker rootless: убираем root из-под самого демона
userns-remap ремапит контейнеры, но сам демон dockerd по-прежнему работает от root. А демон - это здоровенный кусок кода, слушающий сокет. Скомпрометируешь демон - получишь root на хосте. Docker rootless идёт дальше: весь демон и все контейнеры запускаются от обычного непривилегированного пользователя внутри его собственного user namespace. На хосте нет ни одного процесса Docker от root.
Это и есть ключевое отличие, которое путают. userns-remap: демон под root, контейнеры ремапнуты. Rootless: вообще всё под обычным юзером. Если злоумышленник пробьёт rootless-контейнер и даже сам демон, максимум что он получит - права того непривилегированного пользователя. Это резко снижает риск побега до root.
Ставится и запускается под нужным пользователем (не под root):
Код: Выделить всё
$ dockerd-rootless-setuptool.sh install
$ systemctl --user start docker
$ docker context use rootless
$ docker run hello-world
Ограничения rootless, которые надо знать заранее.
- Порты ниже 1024 по умолчанию недоступны - ты же непривилегированный. Либо публикуешь высокий порт, либо настраиваешь sysctl net.ipv4.ip_unprivileged_port_start.
- Сеть медленнее на дефолтном slirp4netns (пользовательский TCP/IP стек). Для нагруженных сервисов это заметно.
- Часть возможностей (некоторые storage-драйверы, отдельные cgroup-операции) ограничена.
- Rootless - не серебряная пуля. В 2025 Qualys показал способы обойти ограничения на unprivileged user namespaces в Ubuntu, и был ряд CVE 2025-2026, где побег шёл именно через user namespaces. Слой полезный, но не отменяет остальные.
Поверх seccomp и capabilities работают системы мандатного доступа (MAC). Docker на Debian/Ubuntu по умолчанию навешивает профиль AppArmor docker-default, который ограничивает, к каким файлам и операциям процесс контейнера вообще имеет доступ - независимо от его UID и capabilities. На RHEL/Fedora аналогичную роль играет SELinux с типом container_t: даже root внутри контейнера не дотянется до файлов хоста, у которых не тот SELinux-контекст. Свой AppArmor-профиль цепляется через
Код: Выделить всё
--security-opt apparmor=myprofileКод: Выделить всё
:zКод: Выделить всё
:ZЕщё два дешёвых и сильных приёма. read-only корневая ФС - контейнер не может писать в свой образ вообще:
Код: Выделить всё
docker run --read-only --tmpfs /tmp --tmpfs /run myapp
Код: Выделить всё
--read-onlyГлавная дыра: docker.sock и docker privileged
Можно идеально настроить все слои выше и убить всё одним движением. Два смертных греха.
Первый - проброс сокета демона внутрь контейнера:
Код: Выделить всё
# так делать опасно
docker run -v /var/run/docker.sock:/var/run/docker.sock myapp
Код: Выделить всё
--privilegedВторой грех - docker privileged:
Код: Выделить всё
docker run --privileged myapp
Код: Выделить всё
--privilegedКод: Выделить всё
--device=/dev/...Код: Выделить всё
--cap-addКод: Выделить всё
--privilegedПобеги из контейнера: классы CVE
Чтобы слои выше не казались паранойей, посмотри на реальные классы побегов.
- Уязвимости рантайма (runc). Самый свежий пример - связка CVE-2025-31133, CVE-2025-52565 и CVE-2025-52881 (конец 2025). Атака через гонки и символические ссылки на bind-mount-ах /dev/null и /dev/console: runc монтировал подменённую цель read-write до того, как защиты вставали на место, что давало запись в /proc хоста и побег. Починено в runc 1.2.8, 1.3.3 и 1.4.0. Вывод простой - держи runc/containerd свежими, это твоя первая линия.
- Утечка через /proc и /sys при или избыточных capabilities - классический трюк с release_agent у cgroups v1.
Код: Выделить всё
--privileged - Проброшенные ресурсы хоста - тот самый docker.sock, монтирование / хоста, host PID/network namespace.
Мини-лаба: руками собрать укреплённый контейнер
Повтори по шагам и понаблюдай разницу.
- Запусти обычный контейнер и посмотри его права: , внутри
Код: Выделить всё
docker run --rm -it alpine sh. Запомни длину Bounding set.Код: Выделить всё
apk add libcap; capsh --print - Запусти то же с и снова
Код: Выделить всё
--cap-drop=ALL --security-opt no-new-privileges:true. Сравни множества.Код: Выделить всё
capsh --print - Проверь seccomp: внутри обычного контейнера выполни - и сравни поведение при дефолтном профиле и при
Код: Выделить всё
unshare --map-root-user --user echo ok. Увидишь, как фильтр меняет картину.Код: Выделить всё
--security-opt seccomp=unconfined - Сделай контейнер только на чтение: - получишь Read-only file system. Добавь
Код: Выделить всё
docker run --rm --read-only alpine touch /xи убедись, чтоКод: Выделить всё
--tmpfs /tmpпроходит.Код: Выделить всё
touch /tmp/x - Включи userns-remap в daemon.json, перезапусти демон, запусти в контейнере и сверь UID внутри (
Код: Выделить всё
sleep) и снаружи (Код: Выделить всё
id -u).Код: Выделить всё
ps -o user -C sleep
- В чём принципиальная разница между userns-remap и rootless Docker, и от какого именно сценария атаки защищает только rootless?
- Почему без
Код: Выделить всё
--cap-drop=ALLвсё ещё оставляет лазейку, и как она работает?Код: Выделить всё
no-new-privileges - Что конкретно делает seccomp=unconfined и почему этот режим опасен в продакшене?
- Чем проброс /var/run/docker.sock в контейнер эквивалентен по последствиям запуску ?
Код: Выделить всё
--privileged
Безопасность контейнера - это не один флаг, а стек независимых слоёв на общем ядре. capabilities урезают права (drop ALL, add точечно), no-new-privileges глушит setuid-эскалацию, seccomp режет опасные syscalls, AppArmor/SELinux ставят мандатные стены, read-only rootfs и tmpfs лишают атакующего места для закрепления, userns-remap и особенно rootless обезвреживают сам факт побега, переназначая root в безобидного юзера. И всё это не работает, если ты пробросил docker.sock или включил
Код: Выделить всё
--privileged