Хабрахабр

[Перевод] Глубокое погружение в Linux namespaces, часть 2

В этом посте мы осветим User namespace. В предыдущей части мы только окунули пальцы ног в воды namespace и при этом увидели, как это было просто — запустить процесс в изолированном UTS namespace.

В этом посте мы сосредоточимся исключительно на ресурсах user и group ID (UID и GID соответственно), поскольку они играют фундаментальную роль в проведении проверок разрешений и других действий во всей системе, связанных с безопасностью. Среди прочих ресурсов, связанных с безопасностью, User namespaces изолирует идентификаторы пользователей и групп в системе.

И каждому процессу назначаются какие-то из них, чтобы задать к каким операциями/ресурсам этот процесс может и не может получить доступ. В Linux эти ID — просто целые числа, которые идентифицируют пользователей и группы в системе. Способность процесса нанести ущерб зависит от разрешений, связанных с назначенными ID.

User Namespaces

Точно такие же действия применимы к групповым ID, к которым мы обратимся далее в этому посте. Мы проиллюстрируем возможности user namespaces, используя только пользовательские ID.

Затем изолирование позволяет связать процесс с другим набором ID — в зависимости от user namespace, которому он принадлежит в данный момент. User namespace имеет собственную копию пользовательского и группового идентификаторов. Например, процесс $pid может выполняться от root (UID 0) в user namespace P и внезапно продолжает выполняться от proxy (UID 13) после переключения в другой user namespace Q.

Это означает, что экземпляр пользовательского namespace (родительский) может иметь ноль и больше дочерних пространств имён, и каждое дочернее пространство имён может, в свою очередь, иметь свои собственные дочерние пространства имён и так далее… (до достижения предела в 32 уровня вложенности). User spaces могут быть вложенными! В результате все user namespaces имеют ровно одного родителя, образуя древовидную структуру пространств имён. Когда создаётся новый namespace C, Linux устанавливает текущий User namespace процесса P, создающего C, как родительский для C и это не может быть изменено впоследствии. Это, если вы еще не делаете какую-то контейнерную магию, скорее всего user namespace, к которому принадлежат все ваши процессы, поскольку это единственный user namespace с момента запуска системы. И, как и в случае с деревьями, исключение из этого правила находится наверху, где у нас есть корневой (или начальный, дефолтный) namespace.

В этом посте мы будем использовать приглашения командной строки P$ и C$ для обозначения шела, который в настоящее время работает в родительском P и дочернем C user namespace соответственно.

Маппинги User ID

Давайте посмотрим, как это может выглядеть: User namespace, по сути, содержит набор идентификаторов и некоторую информацию, связывающую эти ID с набором ID других user namespace — этот дуэт определяет полное представление о ID процессов, доступных в системе.

P$ whoami
iffy
P$ id
uid=1000(iffy) gid=1000(iffy)

В другом окне терминала давайте запустим шелл с помощью unshare (флаг -U создаёт процесс в новом user namespace):

P$ whoami
iffy
P$ unshare -U bash
# Входим в новый шелл, который запускается во вложенном user namespace
C$ whoami
nobody
C$ id
uid=65534(nobody) gid=65534(nogroup) C$ ls -l my_file
-rw-r--r-- 1 nobody nogroup 0 May 18 16:00 my_file

Теперь, когда мы находимся во вложенном шелле в C, текущий пользователь становится nobody? Погодите, кто? Поэтому мы, возможно, и не ждали, что он останется iffy, но nobody — это не смешно. Мы могли бы догадаться, что поскольку C является новым user namespace, процесс может иметь иной вид ID. Наш процесс теперь имеет другую (хоть и поломанную) подстановку ID в системе — в настоящее время он видит всех, как nobody и каждую группу как nogroup. С другой стороны, это здорово, потому что мы получили изолирование, которое и хотели.

Он представляет из себя таблицы поиска соответствия ID в текущем user namespace для ID в других namespace и каждый user namespace связан ровно одним маппингом UID (в дополнение еще к одному маппингу GID для group ID). Информация, связывающая UID из одного user namespace с другим, называется маппингом user ID.

Оказывается, что новые user namespaces начинаются с пустого маппинга, и в результате Linux по умолчанию использует ужасного пользователя nobody. Этот маппинг и есть то, что сломано в нашем unshare шелле. Например, в настоящее время системные вызовы (например, setuid), которые пытаются работать с UID, потерпят неудачу. Нам нужно исправить это, прежде чем мы сможем сделать какую-либо полезную работу в нашем новом пространстве имён. Верный традиции всё-есть-файл, Linux представляет этот маппинг с помощью файловой системы /proc в /proc/$pid/uid_map/proc/$pid/gid_map для GID), где $pid — ID процесса. Но не бойтесь! Мы будем называть эти два файла map-файлами

Map-файлы

Чем особенные? Map-файлы — особенные файлы в системе. Например, map-файл /proc/$pid/uid_maps возвращает маппинг от UID'ов из user namespace, которому принадлежит процесс $pid, UID'ам в user namespace читающего процесса. Ну, тем, что возвращают разное содержимое всякий раз, когда вы читаете из них, в зависимости от того, какой ваш процесс читает. И, как следствие, содержимое, возвращаемое в процесс X, может отличаться от того, что вернулось в процесс Y, даже если они читают один и тот же map файл одновременно.

Каждая строка отображает непрерывный диапазон UID'ов в user namespace C процесса $pid, соответствующий диапазону UID в другом namespace. В частности, процесс X, считывающий UID map-файл /proc/$pid/uid_map, получает набор строк.

Каждая строка имеет формат $fromID $toID $length, где:

  • $fromID является стартовым UID диапазона для user namespace процесса $pid
  • $lenght — это длина диапазона.
  • Трансляция $toID зависит от читающего процесса X. Если X принадлежит другому user namespace U, то $toID — это стартовый UID диапазона в U, который мапится с $fromID. В противном случае $toID — это стартовый UID диапазона в P — родительского user namespace процесса C.

Например, если процесс читает файл /proc/1409/uid_map и среди полученных строк видно 15 22 5, то UID'ы с 15 по 19 в user namespace процесса 1409 маппятся в UID'ы 22-26 отдельного user namespace читающего процесса.

С другой стороны, если процесс читает из файла /proc/$$/uid_map (или map-файла любого процесса, принадлежащего тому же user namespace, что и читающий процесс) и получает 15 22 5, то UID'ы c 15 по 19 в user namespace C маппятся в UID'ы c 22 по 26 родительского для C user namespace.

Давайте это попробуем:

P$ echo $$
1442
# В новом user namespace...
C$ echo $$
1409
# C не имеет маппингов со своим родителем, так как он новый
C$ cat /proc/1409/uid_map
# Пусто
# Пока корневой namespace P имеет фиктивные маппинги для всех
# UIDs в те же UID в несуществующем родителе
P$ cat /proc/1442/uid_map 0 0 4294967295
# UIDs с 0 до 4294967294 в P маппятся
# в 4294967295 - специальный ID no user - в C.
C$ cat /proc/1409/uid_map 0 4294967295 4294967295

Хорошо, это было не очень захватывающе, так как это были два крайних случая, но это говорит там о нескольких вещах:

  1. Вновь созданный user namespace будет фактически иметь пустые map-файлы.
  2. UID 4294967295 не маппится и непригоден для использования даже в root user namespace. Linux использует этот UID специально, чтобы показать отсутствие user ID.

Написание UID Map файлов

Запись в этот файл говорит Linux две вещи: Чтобы исправить наш вновь созданный user namespace C, нам просто нужно предоставить наши нужные маппинги, записав их содержимое в map-файлы для любого процесса, который принадлежит C (мы не можем обновить этот файл после записи в него).

  1. Какие UID'ы доступны для процессов, которые относятся к целевому user namespace C.
  2. Какие UID's в текущем user namespace соответствуют UID'ам в C.

Например, если мы из родительского user namespace P запишем следующее в map-файл для дочернего пространства имён C:

0 1000 1
3 0 1

мы по существу говорим Linux, что:

  1. Что касается процессов в C, единственным UID'ами, которые существуют в системе, являются UID'ы 0 и 3. Например, системный вызов setuid(9) всегда будет завершаться чем-то вроде недопустимого id пользователя.
  2. UID'ы 1000 и 0 в P соответствуют UID'ам 0 и 3 в C. Например, если процесс, работающий с UID 1000 в P, переключится в C, он обнаружит, что после переключения его UID стал root 0.

Владелец пространств имён и привилегии

User namespaces не налагают этого требования. В предыдущем посте мы упомянули, что при создании новых пространств имён требуется доступ с уровнем суперпользователя. На самом деле, еще одной их особенностью является то, что они могут владеть другими пространствами имён.

Если P создан наряду с другими пространствами имён в одном и том же системном вызове clone, Linux гарантирует, что P будет создан первым и назначен владельцем других пространств имён. Всякий раз, когда создаётся не user namespace N, Linux назначает текущий user namespace P процесса, создающего N, владельцем namespace N.

Например, скажем, что P является родительским user namespace дочернего C, а P и C владеют собственными network namespace M и N соответственно. Владелец пространств имён важен потому, что процесс, запрашивающий выполнения привилегированного действия над ресурсом, задействованным не user namespace, будет иметь свои UID привилегии, проверенные в отношении владельца этого user namespace, а не корневого user namespace. Процесс может не иметь привилегий для создания сетевых устройств, включенных в M, но может быть в состоянии это делать для N.

Например, unshare -u bash потребует sudo, но unshare -Uu bash — уже нет: Следствием наличия владельца пространств имён для нас является то, что мы можем отбросить требование sudo при выполнении команд с помощью unshare или isolate, если если мы запрашиваем также создание и user namespace.

# UID 1000 -- это непривилегированный пользователь в корневом user namespace P.
P$ id
uid=1000(iffy) gid=1000(iffy)
# И в результате не удаётся создать сетевое устройство в корневом
# network namespace.
P$ ip link add type veth
RTNETLINK answers: Operation not permitted
# Давайте ещё раз попытаем счастья, на этот раз с
# другими user и network namespace
P$ unshare -nU bash # ЗАМЕТКА: без sudo
C$ ip link add type veth
RTNETLINK answers: Operation not permitted
# Хм, пока безуспешно. Логично, только
# UID 0 (root) разрешено создавать сетевые устройства, а
# в настоящее время мы nobody. Давайте это исправим.
C$ echo $$
13294
# Вернувшись в P, мы маппим UID 1000 в P с UID 0 в C
P$ echo "0 1000 1" > /proc/13294/uid_map
# Кто мы теперь?
C$ id
uid=0(root) gid=65534(nogroup)
C$ ip link add type veth
# Успех!

Но мы обязательно отбросим привилегии командного процесса, чтобы убедиться, что команда не имеет ненужных разрешений. К сожалению, мы повторно применим требование прав суперпользователя в следующем посте, так как isolate нуждается в привилегиях root в корневом user namespace, чтобы корректно настроить Mount и Network namespace.

Как разрешаются ID

Не волнуйтесь, никакой эскалации привилегий не было. Мы только что увидели процесс, запущенный от обычного пользователя 1000 внезапно переключился на root. Так что в то время, когда пространства имён, принадлежащие его новому user namespace (подобно network namespace в C), признают его права в качестве root, другие (как например, network namespace в P) — нет. Помните, что это просто маппинг ID: пока наш процесс думает, что он является пользователем root в системе, Linux знает, что root — в его случае — означает обычный UID 1000 (благодаря нашему маппингу). Поэтому процесс не может делать ничего, что пользователь 1000 не смог бы.

В обратном направлении происходит движение, например, когда он читает ID пользователей, как мы это делаем с помощью ls -l my_file. Всякий раз, когда процесс во вложенном user namespace выполняет операцию, требующую проверки разрешений — например, создание файла — его UID в этом user namespace сравнивается с эквивалентным ID пользователя в корневом user namespace путём обхода маппингов в дереве пространств имён до корня. UID владельца my_file маппится из корневого user namespace до текущего и окончательный соответствующий ID (или nobody, если маппинг отсутствовал где-либо вдоль всего дерева) отдаётся читающему процессу.

Групповые ID

Нам просто нужно сделать то же самое для соответствующего /proc/$pid/gid_map. Даже если мы оказались root в C, мы до сих пор ассоциированы с ужасной nogroup в качестве нашего ID группы. Прежде чем мы сможем это сделать, нам нужно отключить системный вызов setgroups (в этом нет необходимости, если у нашего пользователя уже есть CAP_SETGID capability в P, но мы не будем предполагать этого, поскольку это обычно идёт вместе с привилегиями суперпользователя), написав "deny" в файл proc/$pid/setgroups:

# Где 13294 -- pid для unshared процесса
C$ id
uid=0(root) gid=65534(nogroup)
P$ echo deny > /proc/13294/setgroups
P$ echo "0 1000 1" > /proc/13294/gid_map
# Наш group ID маппинг отображается
C$ id
uid=0(root) gid=0(root)

Реализация

Исходный код к этому посту можно найти здесь.

Всё, что нам нужно сделать, это написать кучу строк в файл — муторно было узнать, что и где писать. Как вы можете видеть, есть много сложностей, связанных с управлением user namespaces, но реализация довольно проста. Без дальнейших церемоний, вот наши цели:

  1. Клонировать командного процесса в его собственном user namespace.
  2. Написать в UID и GID map-файлы командного процесса.
  3. Сбросить все привилегии суперпользователя перед выполнением команды.

1 достигается простым добавлением флага CLONE_NEWUSER в наш системный вызов clone.

int clone_flags = SIGCHLD | CLONE_NEWUTS | CLONE_NEWUSER;

Для 2 мы добавляем функцию prepare_user_ns, которая осторожно представляет одного обычного пользователя 1000 в качестве root.

static void prepare_userns(int pid)
{ char path[100]; char line[100]; int uid = 1000; sprintf(path, "/proc/%d/uid_map", pid); sprintf(line, "0 %d 1\n", uid); write_file(path, line); sprintf(path, "/proc/%d/setgroups", pid); sprintf(line, "deny"); write_file(path, line); sprintf(path, "/proc/%d/gid_map", pid); sprintf(line, "0 %d 1\n", uid); write_file(path, line);
}

И вызовем его из основного процесса в родительском user namespace прямо перед тем, как мы подадим сигнал командному процессу.

... // Получить доступный к записи конец пайпа. int pipe = params.fd[1]; // Тут будут размещаться некоторые настройки namespace ... prepare_userns(cmd_pid); // Сигнал командному процессу, что мы закончили с настройкой. ...

Для шага 3 мы обновляем функцию cmd_exec, чтобы убедиться, что команда выполняется от обычного непривилегированного пользователя 1000, которого мы предоставили в маппинге (помните, что root пользователь 0 в user namespace командного процесса — это пользователь 1000):

... // Ожидание сигнала 'настройка завершена' от основного процесса. await_setup(params->fd[0]); if (setgid(0) == -1) die("Failed to setgid: %m\n"); if (setuid(0) == -1) die("Failed to setuid: %m\n"); ...

isolate теперь запускает процесс в изолированном user namespace. И это всё!

$ ./isolate sh
===========sh============
$ id
uid=0(root) gid=0(root)

В следующем посте мы рассмотрим возможность запуска команды в своём собственном Mount namespace с помощью isolate (раскрывая тайну, стоящую за инструкцией FROM из Dockerfile). В этом посте было довольно много подробностей о том, как работают User namespaces, но в конце концов настройка экземпляра была относительно безболезненной. Там нам потребуется немного больше помочь Linux, чтобы правильно настроить инстанс.

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть