В этом посте раскрывается эксплойт CVE-2022-27666, уязвимость, позволяющая добиться локального повышения привилегий на последней версии Ubuntu Desktop 21.10. Изначально мы приберегли ее для pwn2own 2022, но она была пропатчена за 2 месяца до начала конкурса. Поэтому было решено обнародовать наш эксплойт и опубликовать PoC в конце поста. Наше первичное исследование показало, что эта уязвимость затрагивает последние версии Ubuntu, Fedora и Debian. Наш эксплойт был создан для атаки на Ubuntu Desktop 21.10 (последняя версия на момент написания эксплойта). Эксплойт достиг около 90% достоверности на свежеустановленной Ubuntu Desktop 21.10 (стандартная настройка VMware: 4G памяти, 2 CPU), нам удалось придумать несколько новых уловок по стабилизации кучи, чтобы осуществить митигацию уровня шума кучи ядра. Что касается самой техники эксплойта, то здесь я впервые занимаюсь кучей феншуй на уровне страницы и переполнением кросс-кэша, а для утечки оффсета KASLR и повышения привилегий я выбрал arb чтение/запись msg_msg. В процессе написания этого эксплойта я узнал очень много нового и надеюсь, что вы получите удовольствие от его прочтения. CVE-2022-27666 — это уязвимость в криптомодуле Linux esp6, она была представлена в 2017 году в коммитах cac2661c53f3 и 03e2a30f6a27. Основная логика этой уязвимости заключается в том, что принимающий буфер пользовательского сообщения в модуле esp6 составляет 8 страниц, но отправитель может отправить сообщение большего размера, что явно приводит к переполнению буфера. esp6_output_head отвечает за аллокацию принимающего буфера, размер allocsize здесь не важен, для  skb_page_frag_refill по умолчанию аллоцируется 8-страничный смежный буфер в строке 9. Для skb_page_frag_refill аллоцировано порядка 3 страниц, что является 8-страничным непрерывным буфером. Поэтому максимальный размер принимающего буфера составляет 8 страниц, а размер входных данных может быть больше 8 страниц, что создает переполнение буфера в null_skcipher_crypt. В этой функции (строка 11) ядро копирует данные размером N страниц в буфер размером 8 страниц, что явно приводит к внеполосной (out-of-band) записи. Теперь давайте посмотрим на патч, устраняющий эту уязвимость. ESP_SKB_FRAG_MAXSIZE это 32768, что равно 8 страницам. Если allocsize больше 8 страниц, то происходит откат к COW; В нашем предварительном эксперименте нам удалось отправить 16 страниц данных, что означает, что мы создаем переполнение на 8 страниц в пространстве ядра, этого вполне достаточно для обычной OOB (out-of-band)-записи. Стоит упомянуть, что esp6 добавляет несколько байт в хвост функции esp_output_fill_trailer. Эти байты рассчитываются исходя из длины входящего сообщения и используемого протокола. Вычисление хвостов показано выше, их значения строго связаны с длиной и протоколом. Мы не можем сделать так, чтобы они были произвольными значениями, и поэтому считаем, что хвостовые байты являются мусорными данными. Эта уязвимость не похожа на другие эксплойты, которые я разрабатывал ранее, она требует кучи феншуй на уровне страницы. Помните, что OOB-запись происходит из 8-страничного смежного буфера, соответственно, переполнение распространяется на соседние страницы. В те дни, когда я начал писать эксплойт, у меня не было опыта работы с аллокатором страниц, поэтому мне пришлось буквально просмотреть весь page_alloc.c и понять основные механизмы, которые помогли разработать этот эксплойт. Страничный аллокатор Linux, он же buddy аллокатор, управляет физическими страницами в ядре Linux. Страничный аллокатор поддерживает управление памятью более низкого уровня за аллокаторами памяти, такими как SLUB, SLAB, SLOB. Один простой пример: когда ядро использует все слэбы kmalloc-4k, аллокаторы памяти запрашивают новый слэб/память у аллокатора страниц, в данном случае kmalloc-4k имеет слэб на 8 страниц (порядок 3), поэтому он запрашивает память на 8 страниц у аллокатора страниц. Аллокатор страниц хранит свободные страницы в структуре данных под названием free_area. Это массив, который содержит различные порядки/размеры страниц. Терм для дифференциации размеров страниц — это order (порядок). Чтобы вычислить размер страниц N-го порядка, используйте PAGE_SIZE << N. В этом случае страница order-0 равна 1 странице (PAGE_SIZE << 0), страница order-1 равна 2 страницам (PAGE_SIZE << 1), ... , страница order-3 равна 8 страницам (PAGE_SIZE << 3). Для каждого порядка в free_area ведется free_list. Страницы выделяются из free_list и высвобождаются обратно в free_list. Различные слэбы ядра запрашивают разные порядки страниц по мере использования free_list. Например, в Ubuntu 21.10, kmalloc-256 запрашивает страницу order-0, kmalloc-512 запрашивает страницу order-1, kmalloc-4k запрашивает страницу order-3. Если в этом free_list нет освобожденных страниц, то free_area низшего порядка заимствует страницы из free_area высшего порядка. В этом случае страницы вышестоящего порядка разделяются на два блока, блок с меньшим адресом отправляется источнику запроса страниц (обычно alloc_pages()), а блок с большим адресом отправляется в free_list нижестоящего порядка. Например, когда free_list order-2 (4 страницы) полностью исчерпан, он запрашивает страницы из order-3 (8 страниц), страницы order-3 разбиваются на два блока по 4 страницы, блок с меньшим адресом 4 страниц назначается объекту, который запрашивает эти страницы, блок с большим адресом 4 страниц отправляется в free_list order-2, так что в следующий раз, если процесс снова запросит страницы order-2, в его free_list будет одна освобожденная страница order-2. Если в списке free_list слишком много освобожденных страниц, page_allocator начинает мерджить две одинаковые по порядку и физически соседние страницы в более высокий порядок. Для пояснения возьмем тот же пример. Если страница order-3 была разделена на две страницы order-2, то одна из них была аллоцирована, а другая осталась в списке free_list order-2. Как только аллоцированные страницы будут освобождены обратно в список order-2, аллокатор страниц проверяет, есть ли у этих вновь освобожденных страниц соседние в том же списке free_list (они называются 'buddy'), если да, то они будут смерджены в order-3. Следующий сниппет показывает, как аллокатор страниц берет блоки страниц из free_area и как заимствовать страницы более высокого порядка. Формирование кучи Теперь поговорим о том, как организовать расположение кучи для OOB-записи. Как известно, аллокатор страниц ведет список free_list для каждого порядка в области free_area. Страницы приходят и уходят из free_list, нет гарантии, что два блока страниц в одном free_list являются смежными, поэтому даже если мы последовательно аллоцируем страницы в одном и том же порядке, они все равно могут быть далеко друг от друга. Чтобы правильно сформировать кучу, необходимо убедиться, что все страницы в одном списке free_list являются смежными. Для этого надо опустошить free_list нужного порядка и заставить его заимствовать блоки страниц у вышестоящего. Как только он позаимствует страницы у более высокого порядка, две последовательные аллокации разделят страницы вышестоящего порядка, и что особенно важно, страницы этого порядка будут представлять собой фрагмент непрерывной памяти. Одна из проблем формирования кучи — как ослабить шум? Мы заметили, что некоторые процессы демонов ядра постоянно аллоцируют и освобождают страницы туда и обратно, страницы низшего порядка могут мерджиться со страницами высшего порядка, страницы высшего порядка разделяются, чтобы удовлетворить потребности страниц низшего порядка, все это создает массу шума при формировании кучи. В нашем случае, мы пытаемся выполнить груминг кучи со страницами order-3, но order-2 всегда запрашивает и разделяет страницы order-3 или иногда страницы order-2 мерджатся обратно в free_list order-3. Чтобы уменьшить этот шум, осуществить его митигацию, я решил сделать следующее: опустошить freelist порядков 0, 1, 2. аллоцировать множество объектов order-2 (предположим, что это N), при этом order-2 будет заимствовать страницы у order-3. освободить каждую половину объектов из шага 2, вторую половину оставить. Это приведет к появлению еще N/2 объектов, которые вернутся во freelist order-2. освободить все объекты с шага 1 На шаге 3 мы не хотим освобождать все объекты, потому что они вернутся в order-3, когда найдут соседние страницы в free_list order-2. Если освободить каждую половину страниц order-2, то они навсегда окажутся в списке free_list order-2. Этот метод создает еще N/2 страниц в free_list order-2 и таким образом предотвращает заимствование/мерджинг страниц order-2 из/в order-3. Эта стратегия защищает наш процесс груминга кучи. Вариант 1: struct msg_msg Первым шагом для эксплойта является утечка KASLR-оффсета. Одна простая мысль, которая пришла мне в голову, заключается в использовании struct msg_msg для создания возможности произвольного чтения. Основная идея этого подхода заключается в перезаписи поля m_ts в struct msg_msg, которое изменяет длину сообщения (struct msg_msgseg). m_ts управляет длиной сообщения, на которое показывает указатель next . Перезаписывая m_ts, мы можем читать без ограничений по указателю next. Во время тестирования я понял, что байты мусора, которые добавляются в хвост, полностью разрушают этот подход. Когда мы перезаписываем поле m_ts, мусорные байты также перезаписывают несколько байт в указателе next. Поскольку next поврежден, внеполосное чтение не имеет смысла (OOB-чтение с адреса, к которому привязан указатель next. Вариант 2: struct user_key_payload На этом этапе мне пришлось застрять на некоторое время, нужно было найти другие структуры, у которых нет критического поля сразу за тем, которое я планирую перезаписать, иначе мусорные данные исказят его. Чтобы получить некоторую информацию, мною было прочитано несколько статей, и в итоге я нашел структуру user_key_payload, подходящую под все мои требования. Указатель rcu можно смело установить в NULL, datalen — это поле длины, которое мы планируем перезаписать, мусорные данные повреждают несколько байт в data[], что вполне нормально, поскольку это просто чистые данные. Одно реальное ограничение этой структуры заключается в том, что обычные пользователи на Ubuntu имеют жесткий лимит на количество ключей и общее количество байт, которые вы можете аллоцировать. Как показано на рисунке ниже, Ubuntu позволяет обычному пользователю иметь максимум 200 ключей и 20000 байт полезной нагрузки в целом. Это ограничивающее условие делает эксплойт немного сложным. Давайте еще раз посмотрим на предварительную схему кучи. Уязвимый буфер при переполнении распространяется на соседнюю память (Victim SLAB на рисунке). Для того чтобы аллоцировать валидный слэб на этой памяти-жертве, его размер должен составлять 8 страниц. В Ubuntu только kmalloc-2k, kmalloc-4k и kmalloc-8k имеют слэбы order-3. Я решил заполнить SLAB жертвы объектами kmalloc-4k, поэтому минимальный размер user_key_payload составляет 2049 байт (округлите до 4k). Для kmalloc-4k каждый слэб имеет 8 объектов. Чтобы заполнить весь слэб user_key_payload, мы должны использовать 2049*8=16392 байт. Помните, что у нас всего 20000 байт, и, таким образом, остается только один user_key_payload ((20000-16392)/2049=1). В заключение, у нас есть только два слэба для кучи фэншуй, что создает нам очень слабую отказоустойчивость, любой шум может разрушить наш фэншуй. Чтобы смягчить эту проблему, я выбрал подход "один user_key_payload на слэб". Каждый раз я аллоцирую только 1 user_key_payload в каждом слэбе и заполняю остальную его часть другими объектами. Позиция user_key_payload может быть произвольной в слэбе из-за рандомизации freelist. Для нашего случая это нормально, так как внеполосная запись может перезаписать весь слэб (8 страниц), который в конечном итоге покроет объект user_key_payload. При таком подходе я могу создать 9 слэбов вместо 2 для кучи фэншуй, и это удается до тех пор, пока один слэб находится в нужном месте. Теперь у нас есть user_key_payload — давайте начнем думать о том, какой объект ядра мы планируем подвергнуть опасности. Самый простой способ — поместить объект с указателем функции рядом с user_key_payload, затем осуществить утечку KASLR-оффсета, вычисляя разницу между указателем функции и ее адресом в файле символов. Но на самом деле мне хочется продемонстрировать технику чтения и записи arb из этого замечательного поста, и именно поэтому я решил слить корректный next указатель struct msg_msg. Если у меня есть корректный указатель next , то позже я смогу перезаписать m_ts и заменить его next, не беспокоясь о повреждении чего-либо (указатель security повреждается при перезаписи next, но в Ubuntu указатель security не используется, и он всегда равен нулю). Схема кучи показана на рисунке ниже. Черная стрелка обозначает OOB-запись, оранжевая — OOB-чтение. Сначала мы используем начальную OOB-запись для перезаписи datalen struct user_key_payload, а затем выполним OOB-чтение, прочитав пейлоад поврежденной struct user_key_payload, наша цель — указатель  next в struct msg_msg. Поскольку позже мы будем использовать этот next указатель для KASLR-утечки, нам необходимо подготовить массу struct seq_operations вместе со struct msg_msgseg. Обе структуры должны попасть в kmalloc-32, что накладывает ограничение на размер msg: 4056 байт - 4072 байт. Это связано с тем, что только 4096-48(заголовок msg_msg) байт будут помещены в основное тело msg, а остальное пойдет в связный список, поддерживаемый указателем next. Чтобы убедиться, что указатель next направляет именно на kmalloc-32, данные в struct msg_msgseg не должны превышать 32-8(заголовок msg_msgseg) байт и не могут быть меньше 16-8 байт (иначе они перейдут к kmalloc-16). Если вам все еще непонятно, посмотрите эту статью. Чтобы инкрементировать процент успеха и уменьшить шум, мы создаем 9 пар такого расположения кучи (9 — это наибольшее количество user_key_payload, которое я могу выделить в пределах kmalloc-4k), до тех пор, пока одна пара будет успешна, мы получим корректный указатель next struct msg_msg. Когда у нас есть корректный указатель next, переходим ко второй фазе. Создаем другой макет кучи для утечки KASLR-оффсета. struct seq_operations — подходящий кандидат. Используем начальную OOB-запись для перезаписи m_ts (длина сообщения) и указатель next (адрес сообщения); в результате мы прочитаем указатель функции из struct seq_operations. Итак, мой путь к утечке таков: Фаза 1 Аллоцируем тонны 8-страничного буфера, чтобы опустошить free_list order-3. Затем order-3 заимствует страницы у вышестоящего порядка, что делает их смежными. Аллоцируйте три смежных 8-страничных объекта-пустышки. Освободите второй фиктивный объект, аллоцируйте 8-страничный слэб, содержащий 1 struct user_key_payload и 7 других объектов. Освободите третий фиктивный объект, аллоцируйте 8-страничный слэб, заполненный struct msg_msg. Размер сообщения должен быть в диапазоне от 4056 до 4072, чтобы struct msg_msgseg попал в kmalloc-32. Аллоцируйте тонны struct seq_operations. Эти структуры будут находиться в одном слэбе со struct msg_msgseg, который мы аллоцировали в шаге 4. Освобождаем первый фиктивный объект, аллоцируем буфер уязвимости и начинаем внеполосную запись — мы планируем модифицировать поле datalen в struct user_key_payload. Если шаг 6 завершится успешно, получение пэйлоад из этой user_key_payload будет приводить к внеполосному чтению. Это OOB-чтение сообщит нам содержимое struct msg_msg, включая его указатель next. Если шаг 7 завершится успешно, у нас теперь есть корректный указатель next на объект struct msg_msg. Фаза 2 Аллоцируйте два смежных объекта-пустышки на 8 страниц. Освободите второй фиктивный объект, аллоцируйте слэб, заполненный struct msg_msg. Освободите первый фиктивный объект, аллоцируйте уязвимый буфер, перезапишите поле m_ts на большее значение, а также перезапишите указатель next на тот, который мы получили на шаге 7 фазы 1. Если шаг 3 фазы 2 прошел успешно, у нас должно получиться OOB-чтение памяти kmalloc-32. Вполне вероятно, что мы прочитаем указатель функции из struct seq_operations, который был аллоцирован на шаге 5 фазы 1. Затем мы вычислим KASLR-оффсет. Как только мы узнали в результате утечки KASLR-оффсет, произвольная запись msg_msg становится валидной опцией. Идея этой техники заключается в том, чтобы заблокировать первый copy_from_user от копирования пользовательских данных (строка 7), а затем изменить указатель next (строка 11 показывает, что seg поступает из msg->next), возобновить процесс, следующий copy_from_user будет произвольной записью (строка 17). Если вы знакомы с userfaultfd, вам может быть известна техника, использующая userfaultfd для зависания процесса. Я писал об этом в блоге около двух лет назад. К сожалению, после ядра версии 5.11 обычному пользователю требуется специальная возможность для использования userfaultfd. Зато теперь у нас есть другая техника, позволяющая сделать то же самое. Спасибо Jann за то, что поделился идеей использования FUSE. FUSE — это файловая система для пользовательского пространства; мы можем создать свою собственную файловую систему и распределить на нее память, все операции чтения и записи через эту память будут обрабатываться нашей собственной файловой системой. Таким образом, мы можем просто подвесить процесс при чтении (copy_from_user читает данные из пользовательского пространства) и высвободить его, когда указатель next будет изменен. Используя произвольную запись, мы перезаписываем путь modprobe. modprobe — это программа пользовательского пространства, которая загружает модули ядра. Каждый раз, когда ядро планирует загрузить модуль, оно выполняет вызов в пользовательское пространство и запускает modprobe на root-правах для загрузки целевого модуля. Чтобы знать, где находится modprobe, ядро компилирует путь в жестком коде; этот путь modprobe хранит в глобальной переменной modprobe_path. В моем эксплойте modprobe_path изменяется  на мою собственную программу /tmp/get_rooot, которая запускает chmod u+s /bin/bash. Когда ядро запускает /tmp/get_rooot на root-правах, оно изменяет разрешение /bin/bash; любой, кто запустит bash, будет запускать его на root-правах. Мои шаги для того чтобы получить root shell: Выделить два смежных 8-страничных фиктивных объекта. Маппировать содержимое сообщения с FUSE и освободить второй фиктивный объект, аллоцировать 8-страничный слэб, содержащий struct msg_msg. На этом шаге потоки будут зависать. Освободите первый фиктивный объект, аллоцируйте уязвимый объект, замените указатель next смежного struct msg_msg на адрес modprobe_path. Освободите потоки, которые зависли на шаге 2, скопируйте строку "/tmp/get_rooot" в modprobe_path. Запустите modprobe, запустив файл неизвестного формата Открываем /bin/bash, теперь мы root  Доступ к эксплойту на моем Github Приглашаем всех заинтересованных на открытое занятие «Основные принципы информационной безопасности стека приложений и инфраструктуры». На этом занятии мы рассмотрим и разберем основные принципы обеспечения информационной безопасности стека приложений и инфраструктуры. Поговорим о следующих подходах: Zero Trust Network Access, Security As A Service Edge, Defense In Depth. Регистрация по ссылке. Переводчик, редактор