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

в 0:01, , рубрики: linux, linux kernel, namespaces, Разработка под Linux

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

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

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

User Namespaces

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

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

User spaces могут быть вложенными! Это означает, что экземпляр пользовательского namespace (родительский) может иметь ноль и больше дочерних пространств имён, и каждое дочернее пространство имён может, в свою очередь, иметь свои собственные дочерние пространства имён и так далее… (до достижения предела в 32 уровня вложенности). Когда создаётся новый namespace C, Linux устанавливает текущий User namespace процесса P, создающего C, как родительский для C и это не может быть изменено впоследствии. В результате все user namespaces имеют ровно одного родителя, образуя древовидную структуру пространств имён. И, как и в случае с деревьями, исключение из этого правила находится наверху, где у нас есть корневой (или начальный, дефолтный) namespace. Это, если вы еще не делаете какую-то контейнерную магию, скорее всего user namespace, к которому принадлежат все ваши процессы, поскольку это единственный user 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? Мы могли бы догадаться, что поскольку C является новым user namespace, процесс может иметь иной вид ID. Поэтому мы, возможно, и не ждали, что он останется iffy, но nobody — это не смешно. С другой стороны, это здорово, потому что мы получили изолирование, которое и хотели. Наш процесс теперь имеет другую (хоть и поломанную) подстановку ID в системе — в настоящее время он видит всех, как nobody и каждую группу как nogroup.

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

Этот маппинг и есть то, что сломано в нашем unshare шелле. Оказывается, что новые user namespaces начинаются с пустого маппинга, и в результате Linux по умолчанию использует ужасного пользователя nobody. Нам нужно исправить это, прежде чем мы сможем сделать какую-либо полезную работу в нашем новом пространстве имён. Например, в настоящее время системные вызовы (например, 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 файл одновременно.

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

Каждая строка имеет формат $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 файлов

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

  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 не налагают этого требования. На самом деле, еще одной их особенностью является то, что они могут владеть другими пространствами имён.

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

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

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

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

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

Групповые ID

Даже если мы оказались root в C, мы до сих пор ассоциированы с ужасной nogroup в качестве нашего ID группы. Нам просто нужно сделать то же самое для соответствующего /proc/$pid/gid_map. Прежде чем мы сможем это сделать, нам нужно отключить системный вызов 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 1n", 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 1n", 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: %mn");
    if (setuid(0) == -1)
      die("Failed to setuid: %mn");
    ...

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

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

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

Автор: A1EF

Источник


* - обязательные к заполнению поля