Эскалация до root через ваш же скрипт: разбор типовой цепочки
Вы даёте обычному пользователю право запускать один конкретный скрипт через sudo — «это же не полный root, там всего одна операция». А через полгода выясняется, что этот один скрипт можно заставить выполнить произвольную команду от имени root, потому что он читает параметр из окружения без проверки или вызывает tar без полного пути. Ниже — разбор типовой (обобщённой, без привязки к конкретному CVE) цепочки такой эскалации: откуда берётся уязвимость, как её ищут и эксплуатируют, и что сделать, чтобы ваш собственный административный скрипт не стал бесплатным способом получить root на вашем же сервере.
Содержание
Как обычно возникает эта дыра
Классический сценарий: администратор пишет bash- или python-скрипт для рутинной операции — перезапустить сервис, почистить логи, забэкапить каталог, применить конфиг. Скрипт нужен нескольким людям или CI-процессу, которым неудобно каждый раз вводить пароль root. Решение кажется очевидным — добавить строку в sudoers:
deploy ALL=(root) NOPASSWD: /opt/scripts/cleanup.sh
На этом моменте разработчик думает, что ограничил доступ: пользователь deploy может запустить ровно один файл, и всё. Проблема в том, что sudo контролирует, какой бинарник запускается, но не контролирует, что этот бинарник делает после запуска. Если cleanup.sh сам вызывает другие программы, читает переменные окружения или принимает аргументы без проверки — весь периметр защиты держится на качестве кода одного bash-файла, который почти никогда не писался с оглядкой на то, что его будут атаковать.
Дальше скрипт правят на скорую руку, добавляют параметры «для гибкости», переиспользуют куски из других скриптов. Каждое такое изменение — потенциальная новая дыра, а NOPASSWD в sudoers остаётся годами, потому что «работает же».
Три типовых источника уязвимости
Почти вся эскалация через административные скрипты сводится к трём паттернам. Разберём каждый на упрощённом, обобщённом примере — не реальный код, а иллюстрация механизма.
1. Недостаточная проверка входных параметров. Скрипт принимает аргумент и передаёт его дальше без валидации:
#!/bin/bash
# /opt/scripts/backup.sh, запускается через sudo NOPASSWD
tar czf /backups/$1.tar.gz "$2"
Если sudoers разрешает вызывать скрипт с любыми аргументами (ALL=(root) NOPASSWD: /opt/scripts/backup.sh *), пользователь может подставить в $1 не имя файла, а команду через подстановку, либо использовать $2 как путь за пределами ожидаемого каталога. Даже без экзотики: если $2 не проверяется на выход за пределы разрешённой директории, deploy может забэкапить (то есть прочитать через архив) любой файл на сервере, включая /etc/shadow, — уже само по себе утечка привилегий, хоть и не прямой root-шелл.
2. Использование переменных окружения без санитизации. Скрипт, вызванный через sudo, по умолчанию наследует значительную часть окружения вызывающего пользователя (если в конфиге не настроено обратное), и это окружение можно использовать против скрипта:
#!/bin/bash
# фрагмент админ-скрипта
EDITOR=${EDITOR:-nano}
$EDITOR /etc/app/config.yml
Если пользователь перед вызовом sudo выставит EDITOR="vim -c ':!bash'" или просто EDITOR=/bin/bash, скрипт от имени root запустит интерактивный shell вместо редактора. Тот же принцип работает через PATH, LD_PRELOAD, PYTHONPATH, IFS и десяток других переменных — если скрипт полагается на «стандартное» окружение, а sudo его не обнулил, это готовый вектор.
3. Вызов других программ без полного пути. Самая недооценённая ошибка — обращение к утилитам по имени, а не по абсолютному пути:
#!/bin/bash
# скрипт очистки логов, запускается через sudo
find /var/log/app -mtime +30 -exec rm {} \;
gzip /var/log/app/*.log
Если PATH пользователя изменён так, что впереди системных каталогов стоит директория, куда он может писать (а sudo без secure_path в конфиге не всегда это пресекает), он кладёт туда свой файл find или gzip — и когда скрипт вызывает find по имени, выполняется подложенный бинарник от имени root. То же самое относится к скриптам, которые запускают cp, mv, cat, chmod без /usr/bin/ или /bin/ перед именем команды.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверКак выглядит цепочка эксплуатации целиком
Собираем три паттерна в один типовой сценарий. Атакующий (или просто любопытный пользователь) на сервере с обычной непривилегированной учёткой действует по шагам:
- Разведка прав. Первая команда при любом аудите с точки зрения атакующего:
sudo -l
Она честно показывает, что именно текущему пользователю разрешено запускать без пароля. Если в выводе есть путь к самописному скрипту — это и есть точка входа для дальнейшего анализа.
- Изучение скрипта. Раз скрипт прописан в
sudoers, он почти всегда доступен на чтение (ls -la /opt/scripts/,cat). Атакующий читает код и ищет ровно три вещи из раздела выше: неэкранированные аргументы, чтение переменных окружения, вызовы бинарников без полного пути.
- Построение полезной нагрузки. В зависимости от найденного — либо аргумент с инъекцией, либо подмена переменной окружения, либо подложенный бинарник в
PATH.
- Запуск через sudo. Скрипт выполняется от root, полезная нагрузка срабатывает внутри его контекста — и на выходе атакующий получает root-shell или файл с флагом
setuidна/bin/bash, который уже не требует sudo вообще.
Ключевой момент: ни на одном из шагов не нужен пароль root и не нужна эксплуатация ядра или сервисов — только чтение доступного файла и понимание того, как работает sudo и PATH. Это и делает такие цепочки массовыми: они не требуют экзотических инструментов, только внимательного чтения.
Принципы безопасного написания sudo-скриптов
Если скрипт должен запускаться через sudo, стоит относиться к нему как к setuid-бинарнику — то есть исходить из того, что вызывающая сторона недоверенная, даже если это «свой» пользователь.
- Абсолютные пути везде. Каждый вызов внешней программы — через полный путь:
/usr/bin/tar,/bin/rm,/usr/bin/find. Не полагаться наPATHвообще. - Явный
PATHв начале скрипта. Первой строкой после shebang:export PATH=/usr/sbin:/usr/bin:/sbin:/bin. Это не заменяет абсолютные пути, но снижает риск, если какой-то вызов всё же пропущен. - Белый список аргументов, а не чёрный. Не пытайтесь угадать, что запрещено — проверяйте, что входные данные строго соответствуют ожидаемому формату:
[[ "$1" =~ ^[a-zA-Z0-9_-]+$ ]] || exit 1. Любой символ вне алфавита — отказ. - Игнорировать унаследованное окружение. В начале скрипта явно обнулять или переопределять критичные переменные:
unset EDITOR LD_PRELOAD LD_LIBRARY_PATH IFS, либо вообще писать скрипт так, чтобы он не читал переменные окружения для принятия решений. - Минимальный набор операций на файл. Не один универсальный скрипт «на всё», а отдельные узкие скрипты под конкретные действия — так
sudoersразрешает именно то, что нужно, без параметров, дающих слишком много свободы. - Без параметров там, где это возможно. Идеальный
sudoers-скрипт вообще не принимает аргументов — все значения зашиты внутри или читаются из файла конфигурации с правами600, недоступного для записи вызывающему пользователю. - Права на сам файл. Скрипт должен принадлежать root и быть недоступен для записи никому, кроме root (
chown root:root,chmod 750) — иначе пользователь, которому разрешено его *запускать*, сможет его ещё и переписать.
Отдельно стоит правило про sudoers: не давайте ALL=(root) NOPASSWD: /opt/scripts/backup.sh * — звёздочка в конце фактически отменяет всю проверку и разрешает любые аргументы. Если скрипту нужны параметры, перечисляйте допустимые варианты явно, либо (лучше) переносите логику выбора внутрь скрипта, а в sudoers оставляйте вызов без аргументов вовсе.
Аудит sudoers: на что смотреть
sudoers имеет свойство разрастаться незаметно — каждая новая строка добавляется под конкретную задачу и почти никогда не пересматривается потом. Периодический аудит стоит строить вокруг нескольких вопросов.
Есть ли NOPASSWD там, где он не обязателен. Каждая такая строка — кандидат на пересмотр: действительно ли нужен запуск без пароля, или это добавили один раз ради удобства и забыли убрать.
# Быстрый обзор всех правил с NOPASSWD
sudo grep -rn NOPASSWD /etc/sudoers /etc/sudoers.d/
Есть ли широкие маски вместо конкретных путей. Строки вида ALL=(ALL) NOPASSWD: ALL или .../scripts/* (звёздочка вместо конкретного имени файла) — фактически полный root без пароля, только через один лишний шаг.
Кто именно входит в группы с sudo-доступом. getent group sudo и getent group wheel — стоит сверять список с тем, кому доступ реально нужен сейчас, а не полгода назад.
Актуальны ли пути к скриптам. Если скрипт из правила sudoers удалён или перемещён, а правило осталось — это не угроза само по себе, но признак того, что аудит sudoers давно не проводился системно.
Ограничен ли PATH внутри sudo. Проверьте /etc/sudoers на директиву secure_path — она должна быть задана явно, а не наследоваться из окружения пользователя:
Defaults secure_path="/usr/sbin:/usr/bin:/sbin:/bin"
Без неё поведение PATH внутри sudo-сессии сильнее зависит от окружения пользователя, что усиливает риск третьего паттерна из списка выше.
Разумная периодичность — раз в квартал для небольшой инфраструктуры, чаще при активной ротации сотрудников или подрядчиков. Практика, которая экономит время: любое добавление строки в sudoers сопровождается комментарием с датой и обоснованием прямо в файле.
Инструменты для проверки
Ручной аудит легко упускает детали, поэтому есть смысл опираться на инструменты, которые формализуют часть проверок.
Логика в духе GTFOBins. Сам проект GTFOBins — публичный каталог стандартных Unix-бинарников (find, vim, less, tar, python и десятки других) с задокументированными способами получить shell или обойти ограничения, если у бинарника есть sudo-права или setuid-бит. Смысл в том, чтобы перед тем как давать пользователю sudo-доступ к системной утилите — не только к своему скрипту, — свериться со списком: если у less, awk или python3 есть задокументированный трюк для эскалации, правило вида user ALL=(root) NOPASSWD: /usr/bin/less /var/log/app.log небезопасно без дополнительных ограничений, потому что внутри less есть команда запуска shell (!bash).
Для своих же скриптов прямого аналога GTFOBins нет — логика уникальна для каждой инфраструктуры, — но подход переносится: составьте внутренний список «наших» скриптов в sudoers и для каждого распишите, что произойдёт при неожиданном вводе.
sudo -l от имени проверяемого пользователя. Простейший, но обязательный шаг любого аудита — посмотреть, что видит сам пользователь:
sudo -l -U deploy
Вывод — это ровно то, что доступно потенциальному атакующему на первом шаге разведки. Если результат вас удивляет — значит, sudoers разошёлся с реальными задачами.
Lynis. Общий аудитор безопасности Linux-сервера, среди прочего проверяет конфигурацию sudoers, права на файлы и типичные небезопасные настройки. Не заменяет ручной разбор конкретных скриптов, но хорошо ловит системные упущения вроде отсутствующего secure_path или слишком открытых прав на файлы. Мы разбирали установку и типовые проблемы Lynis отдельно — см. установку Lynis на VPS и частые ошибки при работе с ним.
shellcheck для самих скриптов. Статический анализатор bash-кода не заточен под привилегии, но надёжно ловит незаэкранированные переменные, необработанные аргументы и другие паттерны, которые часто и становятся точкой входа для эскалации:
shellcheck /opt/scripts/backup.sh
auditd для постфактум-анализа. Если правило sudoers уже существует и убрать его сразу нельзя, имеет смысл хотя бы логировать каждый его вызов — тогда подозрительный запуск с нетипичными аргументами будет видно в логе, а не останется незамеченным. Настройке auditd для таких задач посвящена отдельная статья — аудит логов сервера через auditd.
Что делать, если полный root всё же не нужен
Чаще всего широкий sudo-доступ раздают не потому что он реально нужен, а потому что это самый быстрый способ снять с себя необходимость думать о деталях. Прежде чем писать очередной sudo-скрипт, стоит спросить: а нужен ли root вообще, или задачу можно решить точечно.
- POSIX capabilities. Если нужна не вся власть root, а конкретная возможность — например, слушать порт ниже 1024, — capabilities позволяют выдать именно её через
setcap, не давая root целиком. Разбор механизма — в статье про capabilities и то, почему root в контейнере не совсем root. - Точечные права через ACL или группы вместо sudo. Если задача — писать в конкретный каталог или перезапускать юнит
systemd,sudoне обязателен:systemctlограничивает перезапуск через polkit-правила, а запись в каталог решается владельцем и группой. - Разделение операции на непривилегированную и привилегированную части. Подготовительную работу (валидация, сборка архива) — от имени обычного пользователя, а финальный привилегированный шаг — предельно узкий, без параметров.
Мы отдельно разбирали, как раздавать доступ к операции, не выдавая root целиком — см. как раздать доступ команде без выдачи root и почему привычка «раз сомневаешься — дай root» опасна — в статье про антипаттерн «всё под root». А инцидент, где безобидный на вид NOPASSWD-скрипт нанёс ущерб без всякой атаки — просто из-за опечатки в пути, — разобран в статье про sudo без пароля и опечатку в пути: урок тот же — passwordless sudo к своему коду стоит выдавать, только если готовы отвечать за каждую строчку как за код, выполняющийся от root.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Если скрипт доступен только автору, риск ли это вообще?
Да — если этот аккаунт когда-либо скомпрометируют через что-то не связанное с sudo (украденный SSH-ключ, фишинг), уязвимый скрипт превращает частичный доступ в полный root.
Достаточно ли chmod 700 на скрипт, чтобы закрыть проблему?
Нет — права на файл ограничивают, кто может прочитать или изменить скрипт, но не то, что происходит внутри него при штатном запуске через sudo.
Можно ли просто убрать NOPASSWD и требовать пароль каждый раз?
Это снижает часть рисков, но не устраняет уязвимости в самом скрипте — если пользователь знает свой пароль, три описанных паттерна эксплуатации работают точно так же.
Нужно ли бояться того же самого в Ansible-плейбуках и CI-пайплайнах?
Да, принцип идентичный: если раннер CI выполняет команды от root на основании входных данных, которые может повлиять обычный пользователь, это тот же класс уязвимости, просто без слова sudo.
Есть ли способ протестировать скрипт на устойчивость до продакшена?
Пройти руками три сценария из раздела про источники уязвимости: неожиданный аргумент, переопределённые переменные окружения, подложенный в PATH тестовый каталог с фиктивными версиями вызываемых утилит. Если хоть один сценарий даёт shell за пределы задуманного — скрипт нужно переписывать.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →