Rootless Docker и user namespaces: почему root контейнера — это root хоста и как разорвать связь
По умолчанию UID 0 контейнера — это UID 0 хоста, так что побег через bind-mount пишет в ФС хоста от root. User namespaces ремапят root в диапазон из /etc/subuid; rootless Docker гоняет демон без привилегий, платя ~20-30% пропускной способности slirp4netns.
Инцидент начался с однострочного удобства в Compose-файле: разработчик примонтировал хостовый /var/www в контейнер, чтобы правки были видны вживую, и контейнер — как почти любой контейнер — гонял свой процесс от root. Спустя месяцы этот образ подцепил уязвимую зависимость, атакующий получил шелл внутри контейнера, и whoami вернул root. Он сделал chmod 777 на примонтированном каталоге, положил веб-шелл, а затем поднялся по монтированию вверх: каждый файл, что контейнер писал в /var/www, ложился на хост во владение UID 0, потому что UID 0 контейнера и есть UID 0 хоста. Mount namespace изолировал, какие пути процесс видит, а не кем он является. Ничто в дефолтах Docker не говорило: «root этого контейнера — это тот самый root, что владеет вашим сервером». Фикс, который команда выкатила на той неделе, был не пропатченной зависимостью — это был userns-remap, делающий root контейнера бессильным хостовым UID в диапазоне 100000, чтобы тот же побег писал файлы во владение пользователя, который не владеет ничем.
Дефолт-ловушка, о которой не предупреждают: root общий
Спроси себя: если процесс контейнера вырвется из своего вида файловой системы, что помешает ему записывать файлы хоста от root? Ничего — пока ты не поймёшь, какие именно namespaces Docker включает по умолчанию, а какой оставляет выключенным.
Linux-контейнер — это всего лишь процесс с namespaces и cgroups, не VM. По умолчанию user namespace не входит в эти namespaces, а значит трансляции UID нет: root (UID 0) внутри контейнера буквально тот же UID 0, что ядро использует на хосте. Namespaces PID, mount и network изолируют что процесс видит — его дерево процессов, его вид ФС, его интерфейсы — но не меняют, кто он. Поэтому как только процесс контейнера касается чего-то общего с хостом, личность важнее видимости. Классический канал — bind mount: -v /var/www:/data даёт контейнеру вид реального хостового каталога, и любой файл, что root контейнера туда пишет, принадлежит root хоста. Брось уязвимый образ в эту схему — и компрометация контейнера становится записью в ФС хоста с владением root, без всякого эксплойта ядра, просто из-за дефолта, что root контейнера равен root хоста.
Фикс на уровне ядра — user namespace. Он вводит помапную UID/GID-трансляцию, так что UID 0 внутри namespace отображается в некий непривилегированный UID на хосте. Маппинг настраивается из /etc/subuid и /etc/subgid, которые выделяют каждому пользователю непрерывный диапазон — по соглашению 65536 подряд идущих подчинённых UID, начиная с высокой базы вроде 100000. С включённым userns-remap UID 0 контейнера становится хостовым UID 100000, UID 1 контейнера — 100001, и так по всему диапазону шириной 65536. Процесс, сбежавший из контейнера как «root», теперь — хостовый UID 100000, который не владеет ничем и не имеет хостовых привилегий. Радиус поражения полной компрометации контейнера сжимается с «root на хосте» до «аккаунта, что не может даже прочитать файлы других пользователей».
Контейнер гонит процесс от root и примонтировал хостовый каталог. С дефолтными настройками Docker (без userns-remap) атакующий получает шелл и пишет файл в примонтированный каталог. Кому этот файл принадлежит на хосте?
▸Почему это работает
Почему userns-remap выключен по умолчанию, если он так полезен? Потому что сдвиг UID ломает допущения. Файлы в bind mount должны быть читаемы ремапнутым диапазоном, так что существующие хостовые файлы во владении UID 1000 невидимы контейнеру, чей root теперь — хостовый UID 100000: либо re-chown, либо принимаешь трение. Некоторые нагрузки, что реально нуждаются в хостовой UID-личности (отдельные storage-драйверы, host-networked агенты), перестают работать. Docker выбрал разрешающий дефолт, что «просто работает» с монтированиями, над безопасным дефолтом, что удивляет людей, и оставил remap опциональным. Этот компромисс — вся причина существования этого урока: удобный дефолт и есть небезопасный.
Rootless Docker: вообще без привилегированного демона
Когда включаешь userns-remap, ты устраняешь худший сценарий побега через bind-mount — но можешь не заметить, что сам демон по-прежнему работает от root. Именно этот пробел закрывает rootless Docker.
User-namespace remap всё ещё гонит демон Docker от root хоста — он лишь ремапит контейнеры. Rootless Docker идёт дальше: весь демон и все контейнеры работают внутри user namespace одного пользователя, запущенные вообще без root-привилегии. Нет root-овладеемого dockerd, нет root-овладеемого сокета, так что даже компрометация демона приземляется как непривилегированный пользователь. Это закрывает крупнейшую оставшуюся цель — исторически записываемый /var/run/docker.sock — это мгновенный примитив root-на-хосте, ведь любой, кто может говорить с демоном, может запустить контейнер, монтирующий хост, — и rootless делает этот сокет бессильным.
Цена платится в основном в сети. Без root rootless Docker не может создать обычную veth/bridge-обвязку, поэтому маршрутизирует трафик контейнера через userspace TCP/IP-стек — slirp4netns — который копирует пакеты в userspace и обратно и накладывает штраф пропускной способности, обычно измеряемый около 20-30% против rootful-bridge (смягчается slirp4netns с sandbox=false или pasta, но не устраняется). Бинд привилегированных портов ниже 1024 тоже требует доп-настройки, раз демону недостаёт CAP_NET_BIND_SERVICE на хосте. Для CI-раннера или мультиарендного build-хоста платить пятой частью сетевой пропускной способности, чтобы удалить «побег контейнера равен root хоста» из модели угроз, обычно правильный размен; для упирающегося в пропускную способность сетевого аплайанса — может, и нет.
Какова главная рантайм-цена, что команда принимает, переходя с rootful Docker на rootless Docker, и почему?
- 01Объясни, почему контейнер, работающий от root с bind mount, опасен на дефолтном Docker, и что именно меняет userns-remap.
- 02Что rootless Docker добавляет поверх userns-remap, и какова конкретная цена?
Контейнер — это namespace-отделённый, cgroup-ограниченный процесс, не VM, и по умолчанию user namespace не входит в его namespaces — так что ремаппинга UID нет и UID 0 контейнера — это UID 0 хоста. Namespaces PID, mount и network изолируют, что процесс видит, а не кто он, поэтому bind mount превращает компрометацию контейнера в запись в ФС хоста во владении root: через общий путь утекает личность, а не видимость. Фикс ядра — user namespace, настраиваемый из /etc/subuid и /etc/subgid, которые выделяют каждому пользователю непрерывный диапазон, по соглашению шириной 65536 UID от высокой базы вроде 100000; с включённым userns-remap UID 0 контейнера отображается в хостовый UID 100000, и побег приземляется как аккаунт, что не владеет ничем. Rootless Docker идёт дальше и гонит весь демон и его контейнеры внутри одного непривилегированного user namespace, так что нет root-овладеемого dockerd или docker.sock для компрометации — закрывая примитив «записываемый сокет равен root хоста». Цена в основном сетевая: rootless не может построить обычный bridge, так что трафик маршрутизируется через userspace-стек slirp4netns со штрафом примерно 20-30% пропускной способности, а привилегированные порты требуют доп-настройки. Дефолты удобны и небезопасны по замыслу — userns-remap опционален, потому что сдвиг UID ломает допущения bind-mount — так что сеньорский ход: сознательно выбрать безопасную конфигурацию и заплатить её известную, ограниченную цену. Теперь, когда встретишь Compose-файл с bind-mount и без userns-remap, ты будешь точно знать, какую угрозу принимаешь — и какой рычаг её устраняет.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.