Дисклеймер: это адаптация моей же статьи, написанной для medium.com. ИИ помог с переводом и редактурой, но идеи, код и технические наблюдения — мои. Если что-то звучит не так, то дайте знать и я подправлю.
На почти каждой Linux-машине в мире живёт один маленький бинарник. Весит несколько мегабайт, несколько десятков тысяч строк кода, поставляется со своим собственным конфигурационным языком, и CVE-шек за ним накопилось столько, что хватит на хорошую таблицу с фильтрами. Большинство из нас запускает его по десятку раз в день, не задумываясь.
Этот бинарник — sudo.
В мире Docker-контейнеров, эфемерных CI-раннеров и Kubernetes-подов, которые живут тридцать секунд и умирают без предупреждения, большая часть того, что умеет sudo, попросту не нужна для той работы, ради которой мы его вообще вызываем.
Вот о чём эта статья: что это за работа, как она обросла такой сложностью, и как маленькая программа на C справляется с ней лучше.
Задача на самом деле простая
Когда вы пишете sudo some-command, вы хотите одного: запустить программу с правами другого пользователя. Всё. Это весь смысл.
Ядро Linux Unix умеет это делать с 70-х годов. Механизм называется setuid-битом, и он по-настоящему элегантен. Прежде чем разбираться с sudo, стоит понять, что вообще происходит под капотом.
Маленький файл, важный бит
У каждого файла в Linux есть биты прав доступа. Стандартные девять вы знаете: чтение, запись, выполнение для владельца, группы, всех остальных. Но над ними есть ещё три бита, и один из них — setuid.
Когда setuid-бит выставлен на исполняемом файле, ядро при запуске делает кое-что интересное: вместо того чтобы использовать UID вызывающего пользователя как effective user ID процесса, оно берёт UID владельца файла. Запущенный процесс становится кем-то другим.
Именно так работает passwd. Когда вы набираете passwd, чтобы поменять пароль, программе нужно писать в /etc/shadow — файл, доступный только руту. Но сам бинарник passwd принадлежит руту и имеет setuid-бит:
-rwsr-xr-x 1 root root 68208 /usr/bin/passwd
Видите s там, где должен стоять x у владельца? Это он. Когда вы запускаете passwd, ядро замечает этот бит, смотрит на владельца файла (root), и выставляет effective UID процесса в 0. UID вашего шелла остаётся вашим. Но процесс passwd работает от рута.
Ядро делает это в момент execve(). Внутри у каждого процесса есть структура с кредами, которая хранит несколько UID-ов: real UID (кто вы есть на самом деле), effective UID (за кого вас принимают при проверке прав) и saved set-user-ID (сохранённая копия effective UID до изменений). Когда execve() запускает setuid-бинарник, ядро копирует UID владельца файла в effective UID нового процесса. Чисто, просто, никаких демонов.
Зачем тогда вообще sudo и su?
Потому что у голого setuid-бита есть ограничение: он переключает только на владельца файла. Если хочется переключиться на произвольного пользователя, нужна программа, которая сама является setuid root, находит нужного юзера и вызывает setuid(), чтобы сменить effective UID на что угодно.
Вот и всё, что делают sudo и su в своей основе. Они — setuid root программы, которые вызывают setuid().
Всё остальное в sudo — это policy enforcement: кому разрешено запускать что, от чьего имени, при каких условиях. Вот где и живёт вся сложность.
Как sudo устроен на самом деле
sudo — setuid root программа. С момента запуска его effective UID равен 0. Дальше он примерно делает следующее:
Читает
/etc/sudoers(и всё, что лежит в/etc/sudoers.d/)Определяет, кто вы (real UID)
Проверяет, разрешено ли вашему юзеру или группе запускать нужную команду от имени нужного таргет-юзера
Опционально спрашивает пароль и проверяет его через PAM или shadow-файл
Вызывает
setuid()иsetgid(), чтобы стать таргет-юзеромВызывает
exec(), чтобы запустить команду
Формат sudoers за годы вырос во что-то, под что стоило бы выдавать сертификаты. Команды можно задавать по пути, по регексу, через алиасы. Можно ограничивать список разрешённых таргет-юзеров. Можно задавать переменные окружения, таймаут пароля, требовать TTY, использовать NOPASSWD. Есть станзы Defaults, влияющие на глобальное поведение. Существует отдельная команда visudo, потому что формат настолько хрупкий, что опечатка может вас из системы выбить.
su чуть попроще, но со своими приколами. Он порождает дочерний процесс (не чистый exec), поднимает новую сессию, опционально дёргает PAM, разбирается с терминалом и вынужден форвардить сигналы от себя к дочернему процессу. Классическая проблема с su в контейнерах видна сразу:
# В Docker-контейнере $ docker run -it --rm alpine su postgres -c 'ps aux' PID USER TIME COMMAND 1 postgres 0:00 ash -c ps aux 12 postgres 0:00 ps aux
Два процесса. su породил шелл (ash -c), шелл породил ps. Ваш init-процесс, или что угодно, следящее за PID 1, видит не то, что нужно. SIGTERM уходит в шелл, а не в вашу реальную программу. Шелл может его переслать, а может и нет.
Проблема сложности в контейнерном мире
Ситуация: у вас Docker-образ. При старте энтрипойнт-скрипт должен сбросить привилегии с рута до менее привилегированного юзера перед запуском основного процесса. Вам нужно одно: запустить программу от имени этого юзера.
Для этого вам не нужны:
Конфигурационный язык
Запросы пароля
Интеграция с PAM
Аудит-лог
Управление сессиями
Работа с TTY
/etc/sudoers
Весь этот обвес существует для многопользовательских таймшеринговых систем, которых в вашем контейнере нет и не было. Ваш контейнер обслуживает один процесс, живёт секунды или часы и ничего не знает о корпоративных политиках доступа. Но sudo тащит за собой 45 лет этих самых предположений.
Помимо накладных расходов по сложности, есть и вопрос security-поверхности. В 2021-м у sudo была CVE-2021-3156: heap-based buffer overflow в парсинге аргументов командной строки, существовавший в каждой версии sudo больше десяти лет, позволявший получить рут-шелл на любой стандартной Linux-инсталляции. В 2025-м добавились ещё две critical CVE: CVE-2025-32462 и CVE-2025-32463. Вторая с рейтингом CVSS 9.3 позволяла обмануть sudo и заставить его загрузить вредоносную шаренную библиотеку через манипуляцию с /etc/nsswitch.conf в chroot-директории.
Больше кода — больше attack surface. Никакой магии.
Альтернативы, которые зашли не до конца
Контейнерная экосистема заметила эту проблему, и несколько проектов попытались её решить.
gosu
gosu написал Tianon Greer специально для Docker-кейсов. Делает правильно: разбирает аргументы, переключается на юзера, вызывает exec(). Никакого промежуточного шелла, никаких дочерних процессов. Запущенная команда полностью заменяет gosu, наследует его файловые дескрипторы, становится PID 1, если так было нужно, и обрабатывает сигналы ровно так, как если бы gosu никогда не существовал.
ENTRYPOINT ["gosu", "www-data", "nginx", "-g", "daemon off;"]
Чисто. Работает. Но gosu написан на Go. Скомпилированный бинарник тянет за собой Go-рантайм и стандартную библиотеку. На Alpine получается около 1.8MB, что не катастрофа сама по себе, но когда у вас сотни микросервисов и каждый байт базового образа на счету — начинает раздражать. Ещё важнее: Go-рантайм запускает планировщик горутин, что означает несколько тредов до вызова exec(). Практически безвредно, но концептуально неприятно для такой простой задачи.
su-exec
Alpine Linux взял su-exec в качестве своей альтернативы. Написан на C, весь код умещается в 200 строк, бинарник весит около 10KB. Вызывает setuid(), setgid(), setgroups(), потом execvp(). Готово.
RUN apk add su-exec ENTRYPOINT ["su-exec", "www-data:www-data", "nginx"]
Это уже гораздо ближе к правильному ответу. Но у su-exec нет никакого access control вообще. Бинарник setuid root, и любой, кто может его запустить, может переключиться на любого юзера. В контейнере это часто нормально, но стоит вам захотеть использовать этот паттерн вне контейнера — в билд-системе, на шаред-хосте, в CI-окружении с несколькими пользователями — и это уже дыра.
tini и dumb-init
Это init-супервизоры, решающие смежную, но другую задачу: PID 1 в контейнерах должен собирать зомби-процессы и правильно форвардить сигналы. Это не инструменты для переключения привилегий. Полезно знать, другая категория.
Чего не хватало: access control без оверхеда
Что если хочется переключать юзеров так же чисто, как su-exec, с тем же прямым exec()-моделью, но с нормальным access control, работающим и за пределами контейнера? Нужно три вещи:
Setuid root бинарник, делающий чистый
exec()в таргет-процессМеханизм контроля доступа к нему
Никаких конфигурационных языков, паролей, PAM, демонов
Linux уже имеет механизм для второго пункта: группы. Подход suex — использовать выделенную Unix-группу как access control list. Состоишь в группе suex — можешь пользоваться инструментом. Не состоишь — нет. Ядро само проверяет членство в группе. Никаких policy-файлов.
Это и есть suex.
suex и sush: прямой путь
suex и его компаньон sush (шелл-лончер на той же основе) доступны по адресу https://github.com/mobydeck/suex. Оба — небольшие C-программы, использующие setuid-механизм напрямую и без лишних абстракций.
Сетап
# Создаём группу access control groupadd --system suex # Выставляем setuid-бит и ограничиваем доступ группой suex chown root:suex suex sush chmod 4750 suex sush # Добавляем нужного пользователя usermod -a -G suex youruser
В chmod 4750 весь смысл. Цифра 4 — setuid-бит. 7 — полные права для владельца (root). 5 — чтение и выполнение для группы suex. 0 — ничего для всех остальных. Пользователь вне группы suex даже не сможет запустить бинарник. Ядро проверяет это до того, как успеет выполниться хоть одна строчка C-кода.
Примеры использования
# От рута: дроп привилегий до непривилегированного юзера suex www-data nginx -g 'daemon off;' # От члена группы suex: запуск чего-то от рута suex /usr/sbin/iptables -L # Переключение на конкретного юзера и группу suex deploy:deploygroup /usr/bin/run-deployment # Запуск в чистом login-окружении suex -l postgres /usr/bin/pg_ctl start # Интерактивный шелл от имени другого юзера sush postgres
Что реально происходит в коде
Основная логика в suex.c прямолинейна и легко проверяема. После того как проверено, что запускающий — root или член группы suex, программа:
Парсит таргет-юзера и группу из аргументов
Ищет юзера в
/etc/passwdчерезgetpwnam()илиgetpwuid()Вызывает
setup_groups(), которая черезgetgrouplist()иsetgroups()настраивает полный список supplementary-групп для таргет-юзераВызывает
setgid()для установки основной группыВызывает
setuid()для установки UIDВызывает
execvp(), заменяя себя на вашу команду
Последний шаг — самый важный. execvp() не форкает. Он полностью заменяет образ текущего процесса. Бинарник suex перестаёт существовать. Ваша команда становится процессом — с тем же PID, теми же файловыми дескрипторами, той же диспозицией сигналов. В дереве процессов suex не было и нет.
Сравните:
# sudo или su: PID 1 --- sudo --- your-command # suex: PID 1 --- your-command
В контейнерах это влияет на поведение PID 1. В пайплайнах — на обработку сигналов. В скриптах — на то, что вы не оборачиваете свой процесс лишними слоями незаметно для себя.
Проблема с группами, которую su-exec игнорирует
Одно место, где su-exec ошибается, а suex делает правильно: supplementary groups. Когда юзер состоит в нескольких группах (скажем, docker, www-data и developers), все эти членства нужно выставить для таргет-процесса, а не только основную группу.
suex вызывает getgrouplist(), а потом setgroups(), гарантируя, что у таргет-процесса будет полный набор групп таргет-юзера. Именно это делают su - и sudo -u. Многие минималистичные альтернативы пропускают этот шаг, и порождённый процесс не может добраться до ресурсов, доступных через группу. Баг тихий и может всплыть очень не вовремя.
sush: когда нужен шелл
sush — компаньон для интерактивной работы. Строится на той же модели, но занимается дополнительной церемонией запуска нормального login-шелла:
sush username # запускает дефолтный шелл юзера sush -s /bin/zsh art # запускает zsh для пользователя art
В sush.c явно строится чистый массив переменных окружения: HOME, SHELL, USER, LOGNAME, PATH, MAIL и TERM. argv[0] шелла формируется с ведущим дефисом:
sprintf(shell_args[0], "-%s", shell_name);
Ведущий дефис — это Unix-соглашение, сигнализирующее шеллу, что он запущен как login shell, и он должен читать .profile, .bash_profile или что там у него настроено. После этого вызывается execve() с явно переданным массивом окружения, а не тем, что унаследовалось от родителя.
PATH настраивается осмысленно: рут получает полный админский путь, остальные — /usr/local/bin:/usr/bin:/bin. Все получают ~/.local/bin в начале, что соответствует современному соглашению для пользовательских тулзов.
Двойная модель поведения
suex работает по-разному в зависимости от того, кто его запускает — и это отлично ложится на реальные кейсы.
Для рута suex работает как su: вы понижаете привилегии. Энтрипойнт контейнера, работающий от рута, использует suex www-data app-server, чтобы сбросить привилегии перед запуском основного процесса. Никакого пароля, никаких проверок политик.
Для не-рутовых пользователей из группы suex — как scoped sudo: можно повысить привилегии до рута или переключиться на конкретного юзера. Это CI-раннер, деплой-аккаунт, билд-системный юзер, которому нужно запустить одну конкретную вещь от другого имени. Access control — членство в группе, которое проверяет файловая система.
Безопасность: что получаем, от чего отказываемся
Модель suex намеренно минималистична.
Что получаем:
Access control через группы: проверяется ядром на уровне файловой системы ещё до запуска любого юзерспейсного кода. Нет policy-файла, который можно некорректно настроить, нет парсера, который можно поломать.
Чистое выполнение: таргет-процесс запускается напрямую, не как дочерний процесс какого-то privilege manager-а. Сигналы идут куда надо. TTY работает правильно. PID именно тот, что ожидается.
Полный контекст юзера: правильные UID, GID и supplementary groups для таргет-юзера, а не обрезанная версия, которая ломает доступ к групповым ресурсам.
Аудируемость: бинарник достаточно маленький, чтобы прочитать за час. Никаких плагинов, никаких шаренных библиотек, никакого сложного парсинга. Attack surface пропорциональна функциональности.
От чего отказываемся:
Гранулярный контроль команд: нельзя ��казать, что юзер Алиса может запускать /usr/bin/systemctl, но не /usr/bin/bash. Если нужна такая детализация — sudoers существует по причине.
Запрос пароля: решение о доступе принимается на уровне группы. Если нужно обязательное подтверждение паролем — это не тот инструмент.
Аудит-лог: никаких записей в syslog на каждую команду. Ваш аудит-трейл — это членство в группе.
Большая часть того, от чего мы отказываемся — это механизмы, которые и не были нужны в окружениях, где suex работает лучше всего.
Паттерн на практике
Dockerfile с энтрипойнтом на suex:
FROM debian:bookworm-slim COPY suex /sbin/suex RUN chown root:root /sbin/suex && chmod 4755 /sbin/suex COPY entrypoint.sh /entrypoint.sh ENTRYPOINT ["/entrypoint.sh"]
#!/bin/sh # entrypoint.sh — стартует от рута, дропает до app-юзера # Инициализация, требующая рута chown -R app:app /data # Заменяем себя на приложение от имени app-юзера exec suex app "$@"
exec в шелл-скрипте заменяет шелл-процесс на suex. suex затем заменяет себя на приложение через execvp(). Итог: PID 1 вашего контейнера — ровно то приложение, о котором вы заботитесь, запущенное ровно от того юзера, которого вы указали, без каких-либо супервизоров или обёрток в дереве процессов.
Билд-система с разделением привилегий:
# CI-агент состоит в группе suex # Запустить деплой-скрипт от деплой-юзера suex deploy /usr/local/bin/deploy-to-staging # Открыть интерактивную сессию для дебага sush deploy
Ядро делает тяжёлую работу
Стоит зафиксировать, чего здесь не происходит. Нет демона. Нет IPC-канала к privilege broker-у. Нет парсинга конфигурационного файла. Нет загрузки шаренных библиотек в момент повышения привилегий.
Ядро Linux выполняет security-критичную работу. Оно проверяет, что suex принадлежит руту и имеет setuid-бит. Оно проверяет, что только члены группы suex могут запустить бинарник. Оно обрабатывает переходы UID/GID в своём собственном коде управления кредами при вызовах setuid() и setgid().
Setuid-механизм существует в Unix с 1970 года. Его изучали, анализировали и тестировали на миллионах систем на протяжении почти полувека. suex не строит абстракцию над этим механизмом. Он использует механизм напрямую, с минимальным расстоянием между возможностью ядра и тем результатом, который вам нужен.
Для кого это всё?
Если запускаете контейнеры и энтрипойнту нужно чисто сбросить привилегии — suex делает это лучше, чем gosu, и безопаснее, чем su-exec.
Если управляете шаред-билд или CI-окружением и хотите контролируемый способ переключения юзеров без раздачи рут-паролей и без поддержки sudoers-файлов — suex хорошо подходит.
Если у вас традиционная многопользовательская Linux-система, где разным юзерам нужны разные команды с разными привилегиями, и нужны аудит, подтверждение паролем и гранулярный вайтлист команд — используйте sudo. Он существует по причине и хорошо делает своё дело.
Суть не в том, что sudo плохой. Это решение другой задачи, и тащить его в мир эфемерных контейнеров и автоматизированных пайплайнов означает тащить 45 лет предположений, которые здесь не применимы.
suex и sush — это Unix-идиома, правильно применённая к тому, где реально находятся вычисления сейчас: маленькая, сфокусированная, построенная на механизмах, которые ядро уже предоставляет, и достаточно простая, чтобы разобраться минут за двадцать.
Это весь джоб. Не больше, не меньше.
suex и sush доступны по адресу https://github.com/mobydeck/suex. Лицензия MIT, написано на C, зависимостей кроме libc нет.