Веб-сервер хочет слушать восьмидесятый порт. Беда в том, что порты ниже тысячи в Linux считаются привилегированными, и привязаться к ним по традиции может только процесс с правами суперпользователя. Самое лобовое решение, запустить весь сервер от имени суперпользователя, расплачивается чудовищным риском: если в коде сервера найдётся дыра, через неё атакующий получит полную власть над машиной. Получается перекос, при котором ради единственной мелкой привилегии процессу отдают вообще все права в системе.
Linux capabilities разбивают эту монолитную всемогущность суперпользователя на отдельные мелкие полномочия, каждое из которых можно выдать процессу по отдельности. Право привязаться к привилегированному порту, право менять сетевые настройки, право работать с сырыми сокетами и десятки других больше не приходят пакетом. Это и есть воплощение принципа наименьших привилегий: процесс получает ровно то полномочие, что ему нужно, и ни капли больше. Библиотека для работы с этим механизмом называется libcap.
Как единое всемогущество суперпользователя разбили на отдельные полномочия
Классическая модель Unix знала лишь два состояния: ты либо суперпользователь и можешь всё, либо обычный пользователь и многого не можешь. Между этими полюсами не было ничего. Capabilities заполнили этот провал, нарезав права суперпользователя на десятки независимых полномочий. Каждое отвечает за свой узкий класс действий. Одно разрешает привязку к привилегированным портам, другое позволяет менять владельца файлов, третье даёт работать с сырыми сетевыми пакетами, и так далее по всему спектру системных операций.
Самое ходовое из этих полномочий для серверов это право привязки к привилегированному сетевому сервису. Оно позволяет процессу слушать порты ниже тысячи, не имея никаких иных привилегий суперпользователя. Сервер с одним лишь этим полномочием может занять восьмидесятый порт, но при компрометации не даст атакующему ничего сверх этого узкого права, потому что больше у процесса ничего и нет. Разница с запуском от суперпользователя колоссальна: там брешь означала захват всей машины, тут она ограничена единственным безобидным полномочием.
Чтобы понять, как полномочия применяются, нужно знать про наборы. Каждый процесс несёт несколько множеств полномочий, и проверка прав смотрит на так называемое действующее множество: если нужное полномочие в нём присутствует, действие разрешается, иначе запрещается. Разрешённое множество это резерв, из которого процесс вправе переносить полномочия в действующее по мере надобности. Наследуемое множество определяет, что унаследуют запускаемые программы. А ограничивающее множество ставит жёсткий потолок, выше которого процесс не может поднять свои права ни при каких условиях, служа дополнительным рубежом обороны.
Разделение на разрешённое и действующее множества поначалу кажется избыточным, но в нём заложена тонкая защита. Полномочие можно держать в разрешённом множестве как бы в спящем виде, не активируя его в действующем, и поднимать в действующее лишь на те доли секунды, пока выполняется привилегированная операция, после чего снова гасить. Пока полномочие спит в разрешённом множестве, оно не действует, и даже если в этот момент в коде сработает уязвимость, воспользоваться спящим правом она не сможет. Так окно реальной привилегированности сжимается до минимума, а остальное время процесс живёт с формально имеющимся, но выключенным правом. Эта игра на включение и выключение через действующее множество и есть тонкий инструмент, которого начисто лишена грубая модель всё или ничего.
Чем установка полномочия на файл отличается от выдачи внутри программы
Выдать полномочие можно двумя путями, и они дополняют друг друга. Первый путь это пометить сам исполняемый файл. Утилита установки полномочий записывает прямо в метаданные файла, какие права получит процесс при его запуске. После этого любой пользователь, запустивший такой файл, поднимет процесс уже с нужным полномочием, без всякого суперпользователя. Команда выглядит лаконично:
# разрешить файлу привязку к привилегированным портам
sudo setcap 'cap_net_bind_service=+ep' /usr/local/bin/myserver
# проверить, что полномочие проставилось
getcap /usr/local/bin/myserver
Сочетание букв после знака равенства задаёт, в какие наборы попадёт полномочие: одна отвечает за разрешённое множество, другая за действующее. Можно проставить и несколько полномочий разом, перечислив их через пробел. После этого сервер, запущенный обычным пользователем, спокойно займёт восьмидесятый порт, тогда как без полномочия тот же код упёрся бы в отказ в доступе при попытке привязки.
Полезно понимать, что полномочия на файле и полномочия процесса связаны правилами преобразования при запуске. Когда обычный пользователь запускает помеченный файл, ядро смотрит на проставленные полномочия и формирует из них наборы нового процесса по чётким формулам. Полномочие, помеченное как разрешённое на файле, ложится в разрешённое множество процесса, а помеченное как действующее тут же активируется. Это и отличает файловый способ от ручного: при файловом полномочие появляется автоматически с первого мгновения жизни процесса, ещё до того как выполнится первая строчка его собственного кода, тогда как при ручном управлении через библиотеку процесс должен сам позаботиться о поднятии нужного права. Файловый путь проще для готовых программ, которые нельзя пересобрать, ручной гибче для своего кода, способного управлять правами осознанно.
Второй путь это управление полномочиями изнутри самой программы через libcap. Процесс, стартовавший с некоторым запасом прав, может программно сложить с себя всё лишнее, оставив минимум. Библиотека даёт для этого набор функций: получить текущие полномочия процесса, изменить флаги нужных полномочий в наборах, применить изменения обратно к процессу и освободить рабочую структуру. Типичный приём это выполнить привилегированное действие в самом начале, а затем сбросить ставшее ненужным полномочие, чтобы дальше работать с урезанными правами:
#include <sys/capability.h>
cap_t caps = cap_get_proc(); /* читаем текущие полномочия */
cap_value_t cap_list[1] = { CAP_NET_BIND_SERVICE };
cap_set_flag(caps, CAP_EFFECTIVE, 1, cap_list, CAP_CLEAR); /* убираем из действующих */
cap_set_proc(caps); /* применяем к процессу */
cap_free(caps); /* освобождаем структуру */
Эта техника, поднять привилегию ровно на миг привилегированной операции и тут же её отпустить, резко сужает окно, в течение которого процесс уязвим. Сервер привязался к порту, сбросил право привязки и дальше живёт уже совсем безоружным в смысле системных привилегий, что и требуется.
Зачем ограничивающее множество ставит непробиваемый потолок прав
Среди наборов полномочий особняком стоит ограничивающее множество, и его роль в безопасности трудно переоценить. Это жёсткий потолок: процесс не может получить полномочие, которого нет в его ограничивающем множестве, никаким способом, даже через запуск файла с проставленным полномочием или иные механизмы повышения прав. Полномочие, выброшенное из ограничивающего множества, для процесса и всех его потомков становится недостижимым навсегда.
Ядро позволяет процессу сбрасывать полномочия из своего ограничивающего множества специальной операцией управления процессом. Раз выброшенное полномочие назад уже не вернуть в пределах жизни процесса, и это именно то, что нужно для надёжного огораживания. Демон, который точно знает, что ему никогда не понадобится, скажем, создание файлов устройств или загрузка модулей ядра, может выбросить эти полномочия из ограничивающего множества при старте. Даже если потом его код будет скомпрометирован и попытается раздобыть эти права, ядро откажет, потому что потолок уже опущен и поднять его обратно нельзя.
Есть и историческая тонкость, объясняющая, почему механизм устроен с оглядкой. В ранних реализациях ограничивающее множество было общесистемным, а не индивидуальным для процесса, и менять его без перекомпиляции ядра было нельзя. Современная схема с отдельным ограничивающим множеством для каждого процесса появилась позже и дала гибкость, которой раньше не было. Знать про эту эволюцию полезно, разбирая поведение на разных версиях ядра.
Какие ловушки подстерегают при работе с полномочиями на практике
Первая и самая частая засада связана с обновлениями пакетов. Полномочия, проставленные на исполняемый файл утилитой установки, хранятся в метаданных именно этого файла. Когда менеджер пакетов обновляет программу, он заменяет файл новым, и проставленное полномочие исчезает вместе со старым файлом. Сервер, прекрасно работавший, после рутинного обновления вдруг теряет право на привилегированный порт и падает. Лекарство в том, чтобы либо заново проставлять полномочие после каждого обновления, либо описать выдачу полномочия через менеджер служб, который применит её при запуске независимо от состояния файла.
Вторая тонкость это амбиентное множество, относительно свежее добавление. Оно решает давнюю боль: унаследовать полномочие в обычную, не помеченную специально программу раньше было неудобно. Амбиентное множество позволяет передавать полномочие запускаемым программам по наследству. Но у него строгое условие: чтобы полномочие попало в амбиентное множество, оно обязано присутствовать одновременно и в разрешённом, и в наследуемом множествах. Нарушение этого правила тихо ломает наследование, и разобраться без знания условия трудно.
Для диагностики незаменимы штатные инструменты осмотра. Можно посмотреть полномочия любого процесса через его запись в системе, можно расшифровать шестнадцатеричную маску полномочий в человекочитаемые имена, можно вывести текущие полномочия оболочки. Эти средства превращают невидимые битовые маски в понятную картину, без которой отладка полномочий сводится к гаданию.
Третья ловушка касается взаимодействия полномочий со сменой пользователя. Классический приём демона, стартовать с правами суперпользователя, сделать привилегированное дело и сбросить себя до непривилегированного пользователя, по умолчанию обнуляет и полномочия: при смене идентификатора пользователя ядро очищает наборы прав, считая, что раз процесс отказался от суперпользователя, то и от полномочий тоже. Если же полномочие нужно сохранить через смену пользователя, процессу заранее выставляют особый флаг сохранения полномочий специальной операцией управления процессом, и тогда права переживут понижение. Без знания этого нюанса разработчик долго недоумевает, почему аккуратно поднятое полномочие испаряется ровно в момент перехода на безопасного пользователя.
Наконец, полезно держать в уме, что не всякую системную операцию вообще можно отвязать от полного суперпользователя через отдельное полномочие. Часть действий покрыта одним широким полномочием системного администрирования, которое настолько всеобъемлюще, что выдача его процессу мало чем отличается по риску от выдачи прав суперпользователя целиком. Поэтому опираться на это всеохватное полномочие как на средство ограничения наивно, и в сценариях, где нужна именно тонкая нарезка прав, ищут более узкие специализированные полномочия, а не хватаются за универсальное.
Сведём всё воедино. Capabilities разбивают всемогущество суперпользователя на отдельные полномочия и позволяют выдать процессу ровно одно нужное право вместо всех сразу, кардинально сужая последствия возможного взлома. Полномочие проставляют либо на файл утилитой установки, либо выдают и сбрасывают изнутри программы через libcap, поднимая привилегию на миг операции и тут же отпуская. Ограничивающее множество ставит непробиваемый потолок, ниже которого права уже не поднять. А помнить нужно про исчезновение полномочий при обновлении пакетов, про строгое условие амбиентного множества и про инструменты осмотра. Тогда веб-сервер занимает восьмидесятый порт, оставаясь при этом практически безоружным для атакующего, и принцип наименьших привилегий из лозунга превращается в рабочую практику.