
Технический анализ и код эксплойта для CVE-2021-3156 — переполнение буфера в куче в Sudo, позволяющее локальное повышение привилегий до root без аутентификации.
Qualys Security Advisory
Baron Samedit: Переполнение буфера в куче в Sudo (CVE-2021-3156)
Краткое описание Анализ Эксплуатация Благодарности Временная шкала
Мы обнаружили переполнение буфера в куче в Sudo (https://www.sudo.ws/). Эта уязвимость:
эксплуатируется любым локальным пользователем (обычные пользователи и системные пользователи, sudoers и не-sudoers), без аутентификации (т.е. атакующему не нужно знать пароль пользователя);
была внесена в июле 2011 года (коммит 8255ed69) и затрагивает все устаревшие версии с 1.8.2 по 1.8.31p2 и все стабильные версии с 1.9.0 по 1.9.5p1, в их конфигурации по умолчанию.
Мы разработали три различных эксплойта для этой уязвимости и получили полные привилегии root на Ubuntu 20.04 (Sudo 1.8.31), Debian 10 (Sudo 1.8.27) и Fedora 33 (Sudo 1.9.2). Другие операционные системы и дистрибутивы, вероятно, также уязвимы.
Если Sudo выполняется для запуска команды в «командном» режиме (shell -c command):
либо через опцию -s, которая устанавливает флаг MODE_SHELL в Sudo;
либо через опцию -i, которая устанавливает флаги MODE_SHELL и MODE_LOGIN_SHELL;
то в начале функции main() в Sudo, parse_args() перезаписывает argv (строки 609-617), объединяя все аргументы командной строки (строки 587-595) и экранируя все метасимволы обратными слешами (строки 590-591):
Позже, в sudoers_policy_main(), set_cmnd() объединяет аргументы командной строки в буфер в куче "user_args" (строки 864-871) и снимает экранирование метасимволов (строки 866-867), «для целей сопоставления sudoers и ведения журнала»:
К сожалению, если аргумент командной строки заканчивается одним обратным слешем, то:
в строке 866 "from[0]" является обратным слешем, а "from[1]" — нулевым терминатором аргумента (т.е. не пробелом);
в строке 867 "from" инкрементируется и указывает на нулевой терминатор;
в строке 868 нулевой терминатор копируется в буфер "user_args", а "from" снова инкрементируется и указывает на первый символ после нулевого терминатора (т.е. за пределы аргумента);
цикл "while" в строках 865-869 читает и копирует символы за пределами границ в буфер "user_args".
Другими словами, set_cmnd() уязвим к переполнению буфера в куче, поскольку символы за пределами границ, которые копируются в буфер "user_args", не были включены в его размер (вычисленный в строках 852-853).
Однако теоретически ни один аргумент командной строки не может заканчиваться одним обратным слешем: если установлены MODE_SHELL или MODE_LOGIN_SHELL (строка 858, необходимое условие для достижения уязвимого кода), то MODE_SHELL установлен (строка 571) и parse_args() уже экранировал все метасимволы, включая обратные слеши (т.е. он экранировал каждый обратный слеш вторым обратным слешем).
Однако на практике уязвимый код в set_cmnd() и код экранирования в parse_args() окружены слегка разными условиями:
по сравнению с:
Наш вопрос, следовательно, таков: можем ли мы установить MODE_SHELL и либо MODE_EDIT, либо MODE_CHECK (чтобы достичь уязвимого кода), но не MODE_RUN по умолчанию (чтобы избежать кода экранирования)?
Ответ, по-видимому, отрицательный: если мы устанавливаем MODE_EDIT (опция -e, строка 361) или MODE_CHECK (опция -l, строки 423 и 519), то parse_args() удаляет MODE_SHELL из "valid_flags" (строки 363 и 424) и завершает работу с ошибкой, если мы указываем недопустимый флаг, такой как MODE_SHELL (строки 532-533):
Но мы нашли лазейку: если выполнить Sudo как "sudoedit" вместо "sudo", то parse_args() автоматически устанавливает MODE_EDIT (строка 270), но не сбрасывает "valid_flags", и "valid_flags" включают MODE_SHELL по умолчанию (строки 127 и 249):
Следовательно, если выполнить "sudoedit -s", то мы устанавливаем как MODE_EDIT, так и MODE_SHELL (но не MODE_RUN), избегаем кода экранирования, достигаем уязвимого кода и переполняем буфер "user_args" в куче через аргумент командной строки, заканчивающийся одним обратным слешем:
perl -e 'print "A" x 65536'
malloc(): corrupted top size
Aborted (core dumped)С точки зрения атакующего, это переполнение буфера идеально:
мы контролируем размер переполняемого буфера "user_args" (размер объединённых аргументов командной строки, строки 852-854);
мы независимо контролируем размер и содержимое самого переполнения (наш последний аргумент командной строки удобно расположен сразу перед нашими первыми переменными окружения, которые не учитываются в расчёте размера в строках 852-853);
мы можем даже записывать нулевые байты в переполняемый буфер (каждый аргумент командной строки или переменная окружения, заканчивающиеся одним обратным слешем, записывают нулевой байт в "user_args", строки 866-868).
Например, в amd64 Linux следующая команда выделяет 24-байтовый буфер "user_args" (32-байтовый блок кучи) и перезаписывает поле размера следующего блока на "A=a\0B=b\0" (0x00623d4200613d41), его поле fd на "C=c\0D=d\0" (0x00643d4400633d43) и его поле bk на "E=e\0F=f\0" (0x00663d4600653d45):
--|--------+--------+--------+--------|--------+--------+--------+--------+-- | | |12345678|90123456|789012.A|AA=a.B=b.|C=c.D=d.|E=e.F=f.| --|--------+--------+--------+--------|--------+--------+--------+--------+-- size <---- user_args buffer ----> size fd bk
Поскольку Sudo вызывает функции локализации в самом начале своей функции main():
и передаёт строки перевода (через функцию gettext() и макрос _()) функциям форматных строк, таким как:
мы изначально хотели переиспользовать потрясающую технику halfdog из https://www.halfdog.net/Security/2017/LibcRealpathBufferUnderflow/ и преобразовать переполнение буфера в куче Sudo в эксплойт через форматную строку. Точнее:
в строке 154, в setlocale(), мы malloc()им и free()им несколько переменных окружения LC (LC_CTYPE, LC_MESSAGES, LC_TIME, и т.д.), тем самым создавая маленькие отверстия в самом начале кучи Sudo (свободные быстрые или tcache блоки);
в строке 155, bindtextdomain() malloc()ит структуру binding, которая содержит указатель dirname на имя каталога, содержащего файлы каталогов ".mo" и, следовательно, строки перевода;
в set_cmnd(), мы malloc()им буфер "user_args" в одно из отверстий в начале кучи Sudo и переполняем этот буфер, таким образом перезаписывая указатель dirname структуры binding;
в строке 301 (например), gettext() (через макрос _()) загружает нашу собственную строку перевода из перезаписанного dirname — другими словами, мы контролируем форматную строку, которая передаётся в sudo_printf().
Для реализации этой начальной техники мы написали примитивный брутфорсер, который выполняет Sudo внутри gdb, переполняет буфер "user_args" и случайным образом выбирает следующие параметры:
переменные окружения LC, которые мы передаём Sudo, и их длину (мы используем локаль "C.UTF-8" и добавляем случайный "@modifier");
размер переполняемого буфера "user_args";
размер самого переполнения;
проходим ли мы через код аутентификации Sudo (опция -A или -n) или нет (опция -u #realuid).
К сожалению, эта начальная техника провалилась; наш брутфорсер смог перезаписать указатель dirname структуры binding:
Program received signal SIGSEGV, Segmentation fault.
0x00007f6e0dde1ea9 in __dcigettext (domainname=domainname@entry=0x7f6e0d9cc020 "sudoers", msgid1=msgid1@entry=0x7f6e0d9cc014 "user NOT in sudoers", msgid2=msgid2@entry=0x0, plural=plural@entry=0, n=n@entry=0, category=5) at dcigettext.c:619
=> 0x7f6e0dde1ea9 <__dcigettext+1257>: cmpb $0x2f,(%rax)
но LC_MESSAGES всегда была локалью "C" по умолчанию (не "C.UTF-8"), что отключает перевод строк в gettext() (т.е. gettext() возвращает оригинальную форматную строку, а не нашу собственную).
К счастью, однако, наш брутфорсер создал десятки уникальных крахов Sudo и бэктрейсов gdb; среди них три привлекли наше внимание, и в итоге мы эксплуатировали все три.
Первый крэш, который привлёк наше внимание:
Program received signal SIGSEGV, Segmentation fault.
0x000056291a25d502 in process_hooks_getenv (name=name@entry=0x7f4a6d7dc046 "SYSTEMD_BYPASS_USERDB", value=value@entry=0x7ffc595cc240) at ../../src/hooks.c:108
=> 0x56291a25d502 <process_hooks_getenv+82>: callq *0x8(%rbx)
rbx 0x56291c1df2b0 94734565372592
Невероятно, но функция Sudo process_hooks_getenv() аварийно завершилась (в строке 108), потому что мы напрямую перезаписали указатель функции, getenv_fn (член структуры struct sudo_hook_entry в куче):
Чтобы эксплуатировать эту перезапись struct sudo_hook_entry, отметим:
вызов getenv_fn (в строке 108) совместим с вызовом execve():
. name ("SYSTEMD_BYPASS_USERDB") совместимо с аргументом пути execve(); . &val (указатель на нулевой указатель) совместимо с argv execve(); . hook->closure (нулевой указатель) совместимо с envp execve();
мы можем победить ASLR, частично перезаписывая указатель функции getenv_fn (который указывает на функцию sudoers_hook_getenv() в общей библиотеке sudoers.so); и, к счастью, начало sudoers.so содержит вызов execve() (или execv()):
Следовательно, мы принимаем следующую стратегию:
Мы успешно протестировали этот первый эксплойт на Ubuntu 20.04.
Второй крэш, который привлёк наше внимание:
Program received signal SIGSEGV, Segmentation fault.
0x00007f6bf9c294ee in nss_load_library (ni=ni@entry=0x55cf1a1dd040) at nsswitch.c:344
=> 0x7f6bf9c294ee <nss_load_library+46>: cmpq $0x0,0x8(%rbx)
Функция glibc nss_load_library() аварийно завершилась (в строке 344), потому что мы перезаписали указатель "library", член структуры struct service_user в куче:
Мы можем легко преобразовать эту перезапись struct service_user в выполнение произвольного кода:
мы перезаписываем ni->library нулевым указателем, чтобы войти в блок строк 330-342, избежать крэша в строке 344 и войти в блок строк 344-359;
мы перезаписываем ni->name (массив символов, изначально "systemd") на "X/X";
строки 353-357 конструируют имя общей библиотеки "libnss_X/X.so.2" (вместо "libnss_systemd.so.2");- в строке 359 мы загружаем собственную разделяемую библиотеку "libnss_X/X.so.2" из текущего рабочего каталога и выполняем наш конструктор _init() как root.
Мы успешно протестировали эту вторую эксплуатацию на Ubuntu 20.04, Debian 10 и Fedora 33.
Наша третья эксплуатация основана не на одной из ошибок Sudo, а на случайном наблюдении: во время нашего перебора Sudo создавал десятки новых каталогов в нашем текущем рабочем каталоге (AAAAAA, AAAAAAAAA и т.д.). Каждый из этих каталогов принадлежит root и содержит только один маленький файл, названный в честь нашего собственного пользователя: временной файл Sudo — мы, очевидно, перезаписали def_timestampdir, имя каталога временных меток Sudo.
Если мы перезапишем def_timestampdir именем каталога, который ещё не существует, то мы можем устроить гонку с ts_mkdirs() Sudo, создать символическую ссылку на произвольный файл и:
3а/ либо выполнить chown() для этого произвольного файла на пользователя root и группу root;
3б/ либо открыть (или создать) этот произвольный файл как root и записать в него struct timestamp_entry.
Нам не удалось преобразовать 3а/ в полные привилегии root (например, если мы выполняем chown() для нашего собственного SUID-бинарника на root, то ядро автоматически удаляет SUID-бит нашего бинарника). Если вы, дорогой читатель, найдёте решение этой проблемы, пожалуйста, опубликуйте его в общедоступном списке рассылки oss-security!
В конце концов, нам удалось преобразовать 3б/ в полные привилегии root, но изначально мы столкнулись с двумя проблемами:
timestamp_open() Sudo удаляет нашу произвольную символическую ссылку, если файл, на который она указывает, старше времени загрузки. Нам удалось решить эту первую проблему, создав очень старый временной файл (от начала эпохи Unix), дождавшись, пока timestamp_open() удалит его, и устроив гонку с timestamp_open(), чтобы создать нашу окончательную произвольную символическую ссылку.
Мы не контролируем содержимое struct timestamp_entry, которое записывается в произвольный файл. Насколько нам известно, мы контролируем только три байта (идентификатор процесса или struct timespec), и нам не удалось преобразовать эту запись из трёх байт в полные привилегии root. Если вы, дорогой читатель, найдёте решение этой проблемы, пожалуйста, опубликуйте его в общедоступном списке рассылки oss-security!
Однако нам удалось обойти эту вторую проблему, злоупотребив незначительной ошибкой в timestamp_lock() Sudo. Если мы выиграем две гонки с ts_mkdirs() и timestamp_open(), и если наша произвольная символическая ссылка указывает на /etc/passwd, то этот файл открывается как root, и:
в строке 644 первые 0x38 байт /etc/passwd ("root❌0:0:...") читаются в стековую struct timestamp_entry, entry;
в строке 652 entry.type равно 0x783a (":x"), а не TS_LOCKEXCL;
в строках 657 и 318 entry->size байт из стекового entry записываются в /etc/passwd, но entry->size на самом деле равно 0x746f ("ot"), а не sizeof(struct timestamp_entry).
В результате мы записываем всё содержимое стека Sudo в /etc/passwd (включая наши аргументы командной строки и переменные окружения); мы внедряем произвольного пользователя в /etc/passwd и, таким образом, получаем полные привилегии root. Мы успешно протестировали эту третью эксплуатацию на Ubuntu 20.04.
Примечание: эта незначительная ошибка в timestamp_lock() была исправлена в январе 2020 года коммитом 586b418a, но это исправление не было перенесено в устаревшие версии.
Мы благодарим Тодда К. Миллера за профессионализм, быструю реакцию и скрупулёзное внимание к каждой детали в нашем отчёте. Мы также благодарим участников distros@openwall.
2021-01-13: Уведомление отправлено Todd.Miller@sudo.
2021-01-19: Уведомление и исправления отправлены на distros@openwall.
2021-01-26: Согласованная дата публикации (18:00 UTC).