Root в контейнере — это root на хосте? Разбираю особенности прав доступов в контейнерах Docker/Podman

от автора

Контейнеры, в отличие от виртуальных машин, разделяют ядро с хостом. Это порождает важный вопрос: как Unix-права соотносятся между процессами внутри контейнера, соседними контейнерами и хостовой машиной?

Представим, что есть volume — bind mount директории с хоста, внутри которой лежит файл config.yml:

/some-volume/config.yml
  1. Если назначить файлу владельца root на хосте — будет ли это тот же самый root внутри контейнера?

  2. Если на хосте существует пользователь gtosss — можно ли переключиться на него внутри контейнера и получить доступ к файлу?

  3. Если создать пользователя gtosss внутри контейнера и выдать ему права на файл — сможет ли хост обратиться к этому файлу под таким же пользователем?

Если хотя бы один вопрос вызывает сомнение — статья для вас.

Перед прочтением рекомендую ознакомиться со статьей — Права в Linux: chown/chmod, SELinux context, символьная/восьмеричная нотация, DAC/MAC/RBAC/ABAC


Важно помнить

Первое, что нужно держать в голове, — ядро оперирует не именами, а айдишниками UID и GID:

  • UID — User ID

  • GID — Group ID

Сопоставления пользователей и их ID хранятся в файле /etc/passwd.

Данный файл содержит строки такого вида:

gtosss:x:1000:1000:gtosss:/home/gtosss:/bin/bash

Это отдельные значения разделенные символом :

Расшифровка строк из /etc/passwd

Расшифровка строк из /etc/passwd

Подготовим песочницу для экспериментов

Опишем docker-compose.yml

services:  alpine-playground:    image: alpine:3.22    container_name: alpine-playground    volumes:      - ./common:/common    command: ["sleep", "infinity"]    restart: unless-stopped

Создадим директорию с файлом и запишем в него текст.

mkdir common
echo 'hello from host' > docker-common/message.txt

Отвечаем на поставленные вопросы в Ubuntu 22

1. Если назначить файлу владельца root на хосте — будет ли это тот же самый root внутри контейнера?

На хосте я работаю от пользователя gtosss с UID 1000. Соответственно директория и файл принадлежат ему. А теперь я запущу контейнер и зайду внутрь него

docker exec -it alpine-playground sh

Внутри контейнера я root, попробуем посмотреть права на файл из волума:

/ # ls -l commontotal 4-rw-r--r--    1 1000     1000            16 May  9 22:22 message.txt

Вместо названия выводиться сразу UID и GID. Посмотрим чуть подробнее.

/common # stat message.txt  File: message.txt  Size: 16              Blocks: 8          IO Block: 4096   regular fileDevice: 820h/2080d      Inode: 17210       Links: 1Access: (0644/-rw-r--r--)  Uid: ( 1000/ UNKNOWN)   Gid: ( 1000/ UNKNOWN)

Uid: ( 1000/ UNKNOWN) — UID 1000, но такой пользователь, как и группа, внутри контейнера не известны. UNKNOWN (неизвестен) так как в контейнере свой файл /etc/passwd, в который gtosss никогда не добавлялся.

И вроде все логично, для всех, кто не является владельцем, разрешено чтение:

0644/-rw-r--r--   ↑        ↑

Читаем без проблем:

/ # cat common/message.txthello from host

А может дело в том, что ядро linux считает меня root’ом? Что по поводу записи:

echo 'hello from container' >> message.txt

Никаких проблем, мы можем из контейнера писать в файл. Убедимся, прочитав результат с хоста:

/common # cat message.txthello from hosthello from container

Выходит, мы будучи рутом в контейнере, убедили ядро linux в том, что мы являемся рутом относительно файла, который лежит в хостовой файловой системе.

На хосте попробуем назначить владельцем файла root пользователя и только ему дать права.

На хосте меняем владельца файла на рута, убираем все остальные права. Итого получается:

ls -ltotal 4-rw------- 1 root root 60 May 10 18:00 message.txt

Видим, что файл принадлежит руту и никто не имеет к нему доступа:

Зайдя в контейнер, нет никаких преград, чтобы читать или писать в файл, в итоге у нас получилось из контейнера выполнить:

/common # echo 'hello from container 2' >> message.txt

Выходим из контейнера, и с хоста, от имени супер-пользователя читаем:

sudo cat message.txt

Видим содержимое:

hello from hosthello from containerhello from container 2

Делаем вывод, что в Ubuntu, root внутри контейнера, это тот же самый root что и в хостовой системе. Ядро линукс доверяет супер-пользователю внутри контейнера. Для неподготовленного системного администратора, не очень очевидное поведение и довольно рискованное. Можно неосознанно дать доступ к рутовой БД, если она примонтирована к контейнеру.

2. Если на хосте существует пользователь gtosss — можно ли переключиться на него внутри контейнера и получить доступ к файлу?

На хосте устанавливаем владельцем файла пользователя gtosss. Доступы выглядят так:

Access: (0600/-rw-------)  Uid: ( 1001/  gtosss)   Gid: ( 1002/  gtosss)

А теперь запрыгиваем в контейнер. Добавляем пользователя gtosss и смотрим, какой у него UID.

/ $ cat etc/passwd | grep gtosssgtosss:x:1000:1000::/home/gtosss:/bin/sh

Обратите внимание, что UID у пользователя gtosss — 1000. На хосте пользователь с таким именем имеет UID 1001. Пробуем прочитать файл:

/common $ cat message.txtcat: can't open 'message.txt': Permission denied

А теперь установим тот же самый UID что у на хосте. На linux alpine утилита usermod отсутствует поэтому сначала устанавливаем пакет shadow в который она включена:

apk add shadow

Теперь устанавливаем UID 1001

usermod -u 1001 gtosss

И переключаемся на пользователя:

/ $ cat /common/message.txthello from hosthello from containerhello from container 2

Ответ на вопрос получен. Чтобы получить изнутри контейнера доступ к файлу, владельцем которого мы не являемся, нам достаточно создать внутри контейнера юзера с тем же UID. Причем имя не имеет значения, ядро линукс проверяет именно ID пользователя и группы.

3. Если создать пользователя внутри контейнера и выдать ему права на файл — сможет ли хост обратиться к этому файлу под другим пользователем?

Думаю полученных знаний достаточно, чтобы ответить на этот вопрос даже без экспериментов. Решающим фактором получения прав к файлу является UID. Тут такая же ситуация, как во втором вопросе.

Отвечаем на поставленные вопросы в SELinux (Fedora Linux)

На своем сервере я использую Fedora, которая использует модуль ядра SELinux (Security-Enhanced Linux). SELinux реализует множество политик безопасности. Вместо Docker использую Podman — функционал их практически одинаковый, но разный подход к безопасности. Podman отличается тем, что позволяет запускать контейнеры без прав root (режим rootles), и по умолчанию работает именно в таком режиме.


На хосте посмотрим через ls -l какие у файла права

-rwxr--r--. 1 gtosss gtosss 16 May  9 09:48 message.txt

А теперь зайдем внутрь контейнера и посмотрим, что мы увидим изнутри контейнера

gtosss@laptop-dc:~/playground$ podman exec -it alpine-playground sh

В контейнере мы root, попытаемся посмотреть что внутри /common:

/common # ls -lls: can't open '.': Permission denied

На уровне классической системы unix прав проблем нет. На Ubuntu мы имели бы доступ, но тут как раз работает SELinux и особенности Podman. Рассмотрим две особенности, которыми SELinux и Podman отличается от классических Ubuntu и Docker.

Особенность первая — SELinux контекст и политики доступов

Выходим из контейнера и смотрим context SELinux через stat:

gtosss@laptop-dc:~/playground$ stat common   File: common  Size: 22              Blocks: 0          IO Block: 4096   directoryDevice: 0,44    Inode: 3457892     Links: 1Access: (0744/drwxr--r--)  Uid: ( 1000/  gtosss)   Gid: ( 1000/  gtosss)Context: unconfined_u:object_r:user_home_t:s0

Нас интересует эта строка:

Context: unconfined_u:object_r:user_home_t:s0

В статье о правах linux есть детальный разбор этой строки. Если она не понятна, то имеет смысл сначала ознакомиться со статьей. А в статье с обнордингом по SELinux рассказывается все необходимое, что нужно знать о политиках безопасности.

Здесь права ограничены типом user_home_t. Данный тип говорит о том, что файл относится к домашней директории обычного пользователя. Это значит, что доступ имеют сам пользователь, или процессы пользователя (user_t, staff_t) и процессы типа unconfined_t. Поставим специальную метку для волума (bind mount), чтобы Podman разрешал доступ к файлу.

Согласно документации Docker и Podman:

  • z (маленькая) — если volume разделяют несколько контейнеров (наш случай, т.к раннер будет создавать много контейнеров).

  • Z (большая) — если volume использует только один контейнер.

Добавляем маленькую :z:

services:  alpine-playground:    image: alpine:3.22    container_name: alpine-playground    volumes:      - ./common:/common:z    command: ["sleep", "infinity"]    restart: unless-stopped

Теперь, зайдя в контейнер, мы можем получить доступ к файлам.

gtosss@laptop-dc:~/playground$ podman exec -it alpine-playground sh/ # ls common/message.txt/ # cat common/message.txt hello from host

При этом на хосте контекст у директории и файлов из домашней директории автоматически изменяется после запуска контейнера:

gtosss@laptop-dc:~/playground$ ls -Z common/system_u:object_r:container_file_t:s0:c384,c447 message.txt

Тип файла изменился: user_home_tcontainer_file_t

Итого, в SELinux помимо классических unix прав (DAC) реализована более строгая система доступов, работающая на основе SELinux политик. Так что доступы к файлам в bind mount покрыты дополнительным слоем безопасности за счет SELinux контекста файлов.

Особенность вторая — User namespace и UID/GID mapping

Если Docker приравнивает root пользователя в контейнере к root пользователю на хосте, то Podman реализует мапинг пользователей таким образом, что root в контейнере может мапиться на конкретного пользователя в хосте.

Вручную, при запуске контейнера мапинг указывается через флаг --uidmap container_uid:host_uid:amount. Например:

podman run --uidmap 0:10000:5000 alpine

Данная команда говорит, что начиная с UID 0 в контейнере, все нужно сопоставлять с 10000 и так 5000 раз.

Составлю наглядную таблицу сопоставлений, для лучшего понимания:

в контейнере

на хосте

0

10000

1

10001

4999

10499

5000 — не входит, так как счет идет с нуля.

Аналогичный флаг есть для групп: --gidmap.

В docker-compose.yml это указывается так:

services:  alpine-playground:    image: alpine    x-podman:      uidmaps:        - "0:100:100"      gidmaps:        - "0:100:100"

Поведение зависит так же от флага --userns и переменной окружения PODMAN_USERNS. Podman по умолчанию работает в rootles режиме, и значения PODMAN_USERNS равно пустой строке, это значит, что по умолчанию будет мапинг:

  • 0 — root пользователя в контейнере, мапиться на ↓

  • Значение переменной UID (эта переменная хранит UID текущего пользователя).

Иными словами root в контейнере мапиться на пользователя, который поднимал контейнер.

Помимо дефолтного поведения, описанного выше, есть еще несколько режимов (англ. документация). В основе всех режимов, за исключением режима host, лежит главная идея, заключающаяся в том, чтобы не выдать контейнеру какого-то привилегированного пользователя из хоста:

  • keep-id$UID:$GID на хосте = тот же $UID:$GID в контейнере. Это удобно для волумов, когда они принадлежат конкретному пользователю на хосте.

  • auto — Podman сам выбирает уникальный диапазон из специальных конфигурационных файлов /etc/subuid /etc/subgid.

  • host — Нет сопоставлений, и механизм user-namespace не работает, root в хосте = root в контейнере (небезопасно).

Для понимания стоит вспомнить что $UID:$GID в контейнере назначается одним из следующих вариантов:

  • --user — Через CLI например podman run --user 1000:1000 alpine

  • user: "1000:1000" — В docker-compose.yml

  • USER 1000:1000 — В Dockerfile

Если ничего не указано, контейнер запускается от пользователя, прописанного в образе (или root, если не задан USER в Dockerfile образа).

Как работает механизм мапинга пользователей (user-namespace)

Мапинг пользователей основан на механизме ядра linux, который называется namespaces (англ. документация). Это тема довольно объемная, поэтому я проведу максимально короткий онбординг.

namespaces — Механизм ядра линукс. Он позволяет оборачивать системные ресурсы в абстракцию, которая называется namespace (пространство имен). Эта абстракция позволяет выделить часть ресурсов системы для каких-то процессов и реализовать изоляцию друг от друга.

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

Теперь про сами ресурсы которыми можно управлять через namespaces.

Namespace

Флаг clone

Man-страница

Изолирует

Cgroup

CLONE_NEWCGROUP

cgroup_namespaces

Корневой каталог cgroup — для управления ресурсами (RAM, CPU и тд)

IPC

CLONE_NEWIPC

ipc_namespaces

System V IPC, очереди сообщений POSIX (взаимодействие процессов).

Network

CLONE_NEWNET

network_namespaces

Сетевые устройства, стеки, порты, таблицы маршрутизации и т.д.

Mount

CLONE_NEWNS

mount_namespaces

Точки монтирования (файловая система)

PID

CLONE_NEWPID

pid_namespaces

Идентификаторы процессов

Time

CLONE_NEWTIME

time_namespaces

Часы реального времени и монотонные часы

User

CLONE_NEWUSER

user_namespaces

Идентификаторы пользователей и групп

UTS

CLONE_NEWUTS

uts_namespaces

Имя хоста и доменное имя NIS

Из всего этого, механизм маппинга пользователей в Podman основан на user_namespaces (предпоследняя строка в таблице). Кратко это работает следующим образом:

Ядро создает специальные файлы в:

  • /proc/$pid/uid_map — для мапинга пользователей

  • /proc/$pid/gid_map — для мапинга групп

Где $pid — ID процесса.

Понятнее всего будет на практическом примере. Есть запущенный podman контейнер:

gtosss@laptop-dc:~$ podman ps CONTAINER ID  IMAGE                          COMMAND         CREATED      STATUS      PORTS       NAMEScc86938573d3  docker.io/library/alpine:3.22  sleep infinity  10 days ago  Up 10 days              alpine-playground

Чтобы увидеть uid_map нам нужно узнать pid процесса. Тут поможет команда podman top <ID контейнера> — она покажет, какие процессы запущены в контейнере:

gtosss@laptop-dc:~$ podman top cc8USER        PID         PPID        %CPU        ELAPSED             TTY         TIME        COMMANDroot        1           0           0.000       262h2m7.553624745s  ?           0s          sleep infinity 
  • PID — Process ID.

  • PPID — это Parent ID, то есть ID процесса, который является родителем.

Но как мы видим, в отличие от docker, podman показывает PID относительно контейнера, а не хостовой машины — иначе бы мы видели значение на много большее чем 0 или 1. Для просмотра хостовых данных, нужно добавить соответствующий аргумент, начинающийся с буквы h (сокращение от host), в нашем случае hpid:

gtosss@laptop-dc:~$ podman top cc8 hpidHPID404796

Теперь зная PID смотрим файл:

gtosss@laptop-dc:~$ cat /proc/404796/uid_map          0       1000          1         1     524288      65536

Читать следует ровно так же, как это реализовано в Podman --uidmap. Выше я уже составлял таблицу сопоставлений, но давайте еще раз продублируем:

  • Первый столбец — User ID внутри namespace (контейнера)

  • Второй столбец — User ID снаружи namespace (на хосте)

  • Третий столбец — Кол-во сопоставлений (range — диапазон)

Первая строка сопоставляет 0 (root в контейнере) c 1000 (gtosss на хосте) один раз.

Все остальные пользователи, начиная от 1 в контейнере будут сопоставляться с UID 524288 и так 65536 раз.

  • 1 → 524288

  • 2 → 524289

  • 3 → 524290

  • и так 65536 раз.

Почему именно такие значения? Напоминаю, что мы смотрим конфигурация user namespaces которую создал podman, а его поведение мапинга контролируется флагом --userns и переменной окружения PODMAN_USERNS. Если там auto, то конфигурация будет взята из /etc/subuid. Заглянем в конфиг:

gtosss@laptop-dc:~$ sudo cat /etc/subuidgtosss:524288:65536

Видим, что в конфиге как раз эти значения.


Итого

  • Docker по умолчанию не используется namespaces, и root внутри контейнера = root на хосте.

  • Podman по умолчанию опирается на механизм user namespaces, реализованный на уровне ядра Linux, что делает его более безопасным.

При этом Docker умеет работать в режиме Rootles mode — но это требует дополнительных действий.


Об авторе

  • 10 лет в IT как разработчик, вторая половина карьеры FullStack и DevOps.

  • Веду тг-канал в котором пишу все что думаю об IT и нашем будущем, часто поднимаю социально важные темы, поддерживаю активистов и правозащитников.

  • Последние годы пробовал в себя на руководящих должностях.

  • Есть скромный опыт предпринимательской деятельности.

ссылка на оригинал статьи https://habr.com/ru/articles/1040300/