Rootful-режим контейнеров устроен так, что компрометация контейнера даёт атакующему права почти как у пользователя, запустившего движок. Rootless-режим меняет это, но за него приходится платить устройством user namespaceизолированное пространство идентификаторов пользователей, в котором процесс видит собственный набор UID и GID , subuidдиапазон дополнительных UID, выделенный пользователю в файле /etc/subuid / subgidдиапазон дополнительных GID, выделенный пользователю в файле /etc/subgid и отдельным жизненным циклом процессов. Разберём механику: как движок понимает, что он rootless, как строятся ID-маппинги, зачем нужен pause-процессвспомогательный процесс, удерживающий user namespace пользователя, пока в нём нет запущенных контейнеров и почему файлы в volume принадлежат не тому пользователю, которого вы ждёте.
Почему rootful — это риск
Демон Docker в стандартной установке работает под пользователем root и предоставляет всю функциональность через API. Тот, кто получает доступ к этому API через сокет, может выполнять код с правами root. Наглядно это выглядит так: обычный пользователь не может прочитать каталог /etc/sudoers.d — команда ls /etc/sudoers.d/ завершается ошибкой Permission denied. Но тот же пользователь может попросить Docker смонтировать весь корень хоста в контейнер и прочитать этот каталог оттуда: docker run -v /:/host alpine:latest ls /host/etc/sudoers.d/ успешно выводит содержимое. Контейнер, запущенный через rootful-демон, даёт доступ к файлам хоста с правами root — то есть почти такими же, как у пользователя, запустившего движок.
Как Podman определяет, что запущен в rootless-режиме
Функция IsRootless из пакета unshare вычисляет булево значение один раз, через sync.Once, и делает это в три шага.
Сначала isRootless устанавливается в true, если UID rootless-пользователя не равен нулю или задана переменная окружения UsernsEnvName — служебная переменная, которой Podman помечает окружение процесса, уже перешедшего в user namespace; её наличие означает, что процесс работает в rootless-режиме. Если после этого значение всё ещё false, вызывается HasCapSysAdmin(), и при отсутствии ошибки и отсутствии CAP_SYS_ADMINэффективная capability ядра, дающая широкие административные права — монтирование, настройку namespace и другие привилегированные операции у процесса isRootless становится true. То есть отсутствие CAP_SYS_ADMIN у процесса с UID 0 трактуется как признак того, что процесс работает в user namespace без полных привилегий, то есть в rootless-режиме.
HasCapSysAdmin проверяет, обладает ли текущий процесс эффективной возможностью CAP_SYS_ADMIN: создаёт объект capability.NewPid2(0), загружает текущие capabilities и возвращает результат проверки эффективной CAP_SYS_ADMIN.
Если и эта проверка не дала результата, вызывается hasFullUsersMappings(), которая читает /proc/self/uid_map и считает маппинги полными, только если в файле встречается строка 4294967295 — весь диапазон ID доступен в user namespace, что характерно для начального user namespace. При отсутствии полных маппингов isRootless также становится true.
В rootless_linux.go HasCapSysAdmin используется при входе процесса в user namespace и mount namespaceизолированное пространство точек монтирования, в котором процесс видит собственную файловую иерархию, отличную от иерархии других процессов rootless-пользователя: если эффективный UID равен 0 и есть CAP_SYS_ADMIN, либо задана переменная _CONTAINERS_USERNS_CONFIGURED, повторный вход в user namespace не выполняется.
Отдельно есть IsRootless в пакете rootless: он вызывает unshare.IsRootless() и дополнительно требует, чтобы unshare.GetRootlessUID() был больше нуля — чтобы вложенные экземпляры podman действовали как root и выбирали пути на хосте, которые обычно используются для root.
Что проверяет runc в rootless-режиме
Проверка rootlessEUIDCheck нужна, чтобы убедиться, что процесс, запускаемый в новом user namespace, действительно работает без привилегий root на хосте: она подтверждает, что эффективный UID процесса не равен нулю, то есть контейнер запускается непривилегированным пользователем, а не от имени root. Такая проверка защищает от ситуации, когда rootless-режим был бы выбран ошибочно и процесс получил бы полные привилегии хоста.
Откуда берутся UID и GID rootless-пользователя
UID и GID rootless-пользователя берутся из переменных окружения _CONTAINERS_ROOTLESS_UID и _CONTAINERS_ROOTLESS_GID, если они непусты, иначе — из os.Getuid() и os.Getgid(). Эти переменные устанавливаются в init() пакета rootless: значения rootlessUIDInit и rootlessGIDInit получаются из C-функций rootless_uid() и rootless_gid(), и если rootlessUIDInit != 0, через os.Setenv записываются _CONTAINERS_USERNS_CONFIGURED=done, _CONTAINERS_ROOTLESS_UID и _CONTAINERS_ROOTLESS_GID.
В окружение дочернего процесса они попадают потому, что RootlessEnv() возвращает копию текущего окружения (включая уже установленные переменные) плюс отметку UsernsEnvName=done. Далее Cmd.Start() запускает процесс с этим окружением, а после старта при CLONE_NEWUSER настраиваются setgroups и ID-маппинги через /proc/<pid>/setgroups и GetHostIDMappings.
Диапазоны subuid и subgid
В rootless-режиме Podman автоматически создаёт для пользователя user namespace, определённое в файлах /etc/subuid и /etc/subgid. Для этого у пользователя должен быть задан диапазон UID/GID, и он должен присутствовать в обоих файлах. Диапазоны добавляются командами usermod --add-subuids и --add-subgids, либо прямой записью строк вида USERNAME:10000:65536 в оба файла. Сами файлы предоставляются пакетом shadow-utils или newuid на разных дистрибутивах, и для изменения записей в них нужны root-права.
Значения для каждого пользователя должны быть уникальны: при перекрытии диапазонов один пользователь может использовать namespace другого и повредить его.
Запись johndoe:100000:65536 в /etc/subuid читается по формату USERNAME:UID:RANGE: первое поле — имя пользователя, как оно указано в /etc/passwd или в выводе getpwent; второе — начальный UID, выделенный пользователю; третье — размер диапазона выделенных UID. Так, для johndoe выделены UID 100000–165535 в дополнение к его обычному UID из /etc/passwd.
Если диапазон subuid/subgid не задан — например, в средах HPC, где пользователи не могут воспользоваться дополнительными UID и GID, — rootless Podman может работать с единственным UID. Для этого в containers-storage.conf(5) задаётся опция ignore_chown_errors, которая при скачивании образа велит Podman игнорировать ошибки chown при попытке изменить файл в образе контейнера под non-root UID. Из-за этого все файлы сохраняются под UID пользователя, что может вызвать проблемы при запуске контейнера.
При изменении /etc/subuid или /etc/subgid нужно остановить все запущенные контейнеры пользователя и завершить pause-процесс, работающий в системе для этого пользователя. Это можно сделать автоматически, выполнив podman system migrate от имени пользователя. Пробельные символы в любой строке этих файлов, включая завершающие пробелы, могут привести к ошибкам «no entry failures».
Как формируются UID- и GID-мапы
Маппинги собираются в буфер целиком, потому что запись в proc-файл должна быть выполнена за один раз. Для GID-мапы по каждому элементу GidMappings в буфер добавляется строка из трёх чисел — ContainerID, HostID и Size. Для UID-мапы — то же самое по UidMappings.
Флаг UseNewgidmap включает использование внешней утилиты newgidmap для установки GID-маппинга вместо прямой записи в proc-файл: при включённом флаге сначала ищется исполняемый файл newgidmap через exec.LookPath, затем запускается команда с pidString и полями уже собранной строки мапы; вывод и ошибки команды перенаправляются в тот же буфер, а сам буфер предварительно очищается. При успешном запуске команды флаг gidmapSet становится true, и прямая запись в /proc/<pid>/gid_map не выполняется.
При ошибке запуска newgidmap логируется предупреждение, проверяется setgid-бит или файловая capability CAP_SETGID, затем буфер очищается и в него записывается единственная строка отката 0 <egid> 1. Если gidmapSet остался false, при UseNewgidmap в /proc/<pid>/setgroups пишется deny, после чего содержимое буфера записывается в /proc/<pid>/gid_map.
Аналогично устроена UID-часть: флаг UseNewuidmap включает использование внешней утилиты newuidmap для установки UID-маппинга вместо прямой записи в proc-файл. При UseNewuidmap сначала ищется newuidmap, и при его отсутствии возвращается ошибка. Затем newuidmap запускается с pidString и полями собранной мапы; буфер сбрасывается и используется как Stdout и Stderr команды. При успехе uidmapSet становится true и запись в /proc/<pid>/uid_map не производится. При ошибке логируется предупреждение, проверяется setuid-бит и CAP_SETUID у найденного пути, после чего буфер сбрасывается и в него пишется единственная строка 0 <euid> 1 — откат идёт к одной записи, а не к полному набору. Далее, поскольку uidmapSet остался false, содержимое буфера записывается в /proc/<pid>/uid_map.
Запись deny в /proc/<pid>/setgroups — это установка политики SETGROUPS_DENYзапрет на изменение списка дополнительных групп процесса через setgroups(2), при которой в целевом процессе запрещается изменение списка дополнительных групп через setgroups(2). В коде это реализовано в update_setgroups: при SETGROUPS_DENY переменной policy присваивается deny, и значение пишется в /proc/<pid>/setgroups. Функция помечена комментарием, что её нужно вызвать до работы с gid_map. В Go-ветке при UseNewgidmap и неудачном запуске newgidmap код открывает /proc/<pid>/setgroups и пишет туда deny перед тем, как открыть и заполнить /proc/<pid>/gid_map. Это делается только тогда, когда маппинг GID не был установлен через newgidmap и приходится писать gid_map напрямую.
Проверка маппингов сначала смотрит, заданы ли оба списка: если len(c.UidMappings) == 0 или len(c.GidMappings) == 0, она читает текущие маппинги родителя через GetHostIDMappings("") и подставляет их в пустые поля, при этом для каждого элемента HostID приравнивается к ContainerID. Это нужно, чтобы у дочернего процесса в новом user namespace были и UID-, и GID-маппинги: далее отдельно обрабатывается GID-часть и отдельно UID-часть, каждая пишется в свой proc-файл одним вызовом. Если newgidmap/newuidmap не срабатывает, код откатывается к единственной записи вида 0 <egid> 1 или 0 <euid> 1, то есть маппинг всё равно должен существовать.
Невозможность rootless-процесса опираться на чужие маппинги видна в copyMappings: она разрешена только root — при не-root возвращает ошибку.
Как читаются маппинги из /proc и из /etc/subuid
getHostIDMappings открывает указанный файл, читает его построчно и для каждой строки разбивает её на поля; если полей не ровно три, возвращает ошибку. Затем первые три поля по порядку преобразуются в числа и из них собирается specs.LinuxIDMapping с полями ContainerID, HostID и Size. GetHostIDMappings подставляет pid self, если pid пустой, и вызывает getHostIDMappings для /proc/<pid>/uid_map и /proc/<pid>/gid_map, возвращая два среза.
GetSubIDMappings не читает /proc напрямую, а вызывает idtools.NewIDMappings(user, group), который читает /etc/subuid и /etc/subgid, после чего перебирает UID- и GID-маппинги и преобразует каждый в specs.LinuxIDMapping.
createIDMap сортирует диапазоны по возрастанию ID и присваивает ContainerID последовательно, начиная с 0, увеличивая его на длину каждого диапазона. parseSubidFile читает /etc/subuid или /etc/subgid, пропускает пустые строки и строки, начинающиеся с #, и требует ровно три части, разделённые двоеточием.
Reexec: как процесс попадает в новый user namespace
Параметр evenForRoot в функции MaybeReexecUsingUserNamespace управляет тем, будет ли процесс перезапущен в новом user namespace даже тогда, когда его эффективный UID равен 0. Условие выбора ветки построения ID-маппингов выглядит так: если uidNum != 0 или evenForRoot истинно, читаются диапазоны subuid/subgid и строятся маппинги для копии себя; иначе (когда uidNum == 0 и evenForRoot ложно) проверяется наличие CAP_SYS_ADMIN, и при его наличии функция просто возвращает управление без создания нового namespace.
Таким образом, при euid == 0 и evenForRoot == true процесс всё равно переходит в новый user namespace, потому что выбирается первая ветка, а не вторая. Это нужно для rootless-сценариев: в первой ветке выполняется чтение разрешённых ID-диапазонов из /etc/subuid и /etc/subgid и построение маппингов. Кроме того, при uidNum != 0 выставляются UseNewuidmap и UseNewgidmap, а флаги namespace задаются как CLONE_NEWUSER | CLONE_NEWNS.
Pause-процесс: зачем он нужен
nsfsфайловая система ядра, через которую можно получить дескрипторы на namespace и потом восстановить их позволяет сохранить handles user namespace и mount namespaceдескрипторы namespace — ссылки, по которым ядро позже восстановит то же пространство имён в файл, чтобы позже восстановить user namespace и mount namespace без постоянно живущего pause-процесса.
При сохранении сначала берётся эксклюзивная блокировка на файле ns_handles.lock, затем повторно проверяется уже существующий файл, и если он ещё не готов — handles получаются и записываются. Запись идёт во временный файл, созданный mkstemp, с атомарным переименованием через rename_noreplace, чтобы читатель никогда не увидел частично записанные данные.
Если переменная окружения PODMAN_NO_PAUSE_PROCESS не установлена или равна 0, файл ns_handles удаляется и возвращается EOPNOTSUPP — система ведёт себя так, будто ядро не поддерживает этот механизм.
При reexecперезапуске процесса в новом user namespace и наличии state_dir сначала предпринимается попытка сохранить handles, и только при ошибках EOPNOTSUPP, EPERM, ENOSYS или ENOENT происходит откат к созданию pause-процесса. Это и есть отказ от pause-процесса на новых ядрах, где nsfs handles доступны.
Сам pause-процесс определяется по файлу /proc/<pid>/environ: если в нём есть запись _PODMAN_PAUSE=1, процесс считается pause-процессом, а при любой ошибке или отсутствии записи — нет. Если процесс по PID из pause.pid не является pause-процессом, выводится предупреждение, что PID мог быть переиспользован, и файл удаляется.
Откат к pause.pid происходит, когда сохранение handles возвращает ошибку с errno EOPNOTSUPP, EPERM, ENOSYS или ENOENT — то есть ядро не поддерживает nsfs-хендлы, они заблокированы (например, seccomp) или каталог состояния ещё не существует; в этом случае создаётся pause-процесс.
Volumes и права: почему файлы принадлежат deploy, а не root
overflow IDзапасной идентификатор, который подставляется, когда целевой ID не удаётся отобразить через маппинг; по умолчанию 65534 используется, когда целевой ID не удаётся отобразить через маппинг. getOverflowUID и getOverflowGID один раз (через sync.Once) инициализируют значение 65534, а затем пытаются прочитать /proc/sys/kernel/overflowuid или /proc/sys/kernel/overflowgid и, если чтение и преобразование в число удались, заменяют значение на прочитанное.
В ToHostOverflow при ошибке RawToHost для UID или GID подставляется getOverflowUID() или getOverflowGID() и пишется отладочное сообщение. RawToHost ищет в idMap диапазон, где contID попадает между m.ContainerID и m.ContainerID + m.Size - 1, и возвращает m.HostID + (contID - m.ContainerID); если ни один диапазон не подошёл, возвращается ошибка. Поэтому при отсутствии маппинга владелец файла на хосте отображается как 65534: владелец 65534 на хосте означает, что ID не замаплен.
podman unshare ls -ldn на родительской директории показывает, в каком случае вы находитесь: владелец 65534 там означает, что ID не замаплен. Это различие важно, потому что внутри user namespace процесс, настраивающий монтирование, — root, но CAP_DAC_OVERRIDEвозможность ядра, позволяющая процессу обходить проверку прав доступа к файлу по его режиму обходит режим файла только тогда, когда и user ID, и group ID файла имеют валидные маппинги в namespace. Директория, принадлежащая незамапленному ID, отображается с overflow ID (65534 по умолчанию), и ничто не переопределяет её режим. Поэтому bind-mount с mode 755, owned by root, не вызывает проблем: режим уже разрешает доступ, и маппинг вообще не вступает в игру. --userns=keep-idрежим, при котором UID пользователя сохраняется внутри контейнера этого не меняет — он меняет то, какой UID у вас внутри контейнера, а не то, какие host ID маппит namespace.
Что из этого следует на практике
- Rootful-демон даёт доступ к API тому, кто может достучаться до сокета, и через этот доступ — права root на хосте. Rootless-режим строится вокруг user namespace, поэтому цена безопасности — работа с subuid/subgid и маппингами.
- Диапазон UID/GID для пользователя должен быть задан в
/etc/subuidи/etc/subgidи быть уникальным: перекрытие позволяет одному пользователю использовать namespace другого и повредить его. - Любое изменение этих файлов требует остановки всех контейнеров пользователя и завершения pause-процесса;
podman system migrateделает это автоматически. Пробелы в строках, включая завершающие, могут привести к ошибкам «no entry failures». - Если диапазон не задан, rootless Podman может работать с единственным UID через
ignore_chown_errors, но тогда все файлы сохраняются под UID пользователя, что может вызвать проблемы при запуске контейнера. - Маппинги собираются целиком и пишутся в proc-файл за один раз. Если
newuidmap/newgidmapне срабатывают, откат идёт к единственной записи0 <euid> 1или0 <egid> 1— маппинг всё равно существует, но не полный. - На новых ядрах pause-процесс не нужен: handles namespace сохраняются в файл через nsfs. Откат к
pause.pidпроисходит только при EOPNOTSUPP, EPERM, ENOSYS или ENOENT — то есть когда ядро не поддерживает механизм, он заблокирован или каталог состояния ещё не создан. - Если файлы в volume принадлежат 65534, это не «случайный» пользователь, а overflow ID: ID не замаплен. Проверить это можно через
podman unshare ls -ldnна родительской директории. Bind-mount с mode 755, owned by root, работает не из-за маппинга, а потому что режим уже разрешает доступ.
Где смотреть в коде
- unshare_linux.go: Fprintf
- unshare_linux.go: hasFullUsersMappings
- rootless_linux.go: init
- unshare_linux.go: Warnf
- unshare_linux.go: getHostIDMappings
- idtools.go: getOverflowGID
- idtools.go: GetRootUIDGID
- idtools.go: Sort