• XSS.stack #1 – первый литературный журнал от юзеров форума

Статья Cледующеe поколение безопасности памяти XNU: kalloc_type

вавилонец

CPU register
Пользователь
Регистрация
17.06.2021
Сообщения
1 116
Реакции
1 265
ОРИГИНАЛЬНАЯ СТАТЬЯ
ПЕРЕВЕДЕНО СПЕЦИАЛЬНО ДЛЯ xss.pro
$600 ---> bc1qhavqpqvfwasuhf53xnaypvqhhvz966upnk8zy7 для поднятия private node's ETHEREUM и тестов

В честь открытия нашего блога исследований в области безопасности мы представляем первый из серии технических постов, посвященных важным улучшениям безопасности памяти в XNU - ядре, лежащем в основе iPhone, iPad и Mac. Поскольку почти все популярные пользовательские устройства сегодня используют код, написанный на языках программирования, таких как C и C++, которые считаются "небезопасными для памяти", то есть не обеспечивают надежных гарантий, предотвращающих определенные классы программных ошибок, повышение безопасности памяти является важной задачей для инженерных команд во всей отрасли. На платформах Apple повышение безопасности памяти - это широкая деятельность, включающая поиск и устранение уязвимостей, разработку на безопасных языках и масштабное развертывание средств защиты. Этот пост посвящен повышению безопасности памяти в XNU: ужесточению распределителя памяти. Впервые мы поставили новый усиленный распределитель, названный kalloc_type, в iOS 15, а в этом году мы расширили его использование во всех наших системах.

Наша стратегия заключается в разработке такого аллокатора, который делает использование большинства уязвимостей повреждения памяти ненадежным. Это ограничивает влияние многих ошибок безопасности памяти еще до того, как мы узнаем о них, что повышает безопасность для всех пользователей. Мы также ожидаем, что эта работа сделает методы эксплуатации более индивидуальными и менее пригодными для повторного использования, что значительно увеличит усилия злоумышленников, когда мы устраним уязвимость, которую они использовали. В результате мы считаем, что определенные классы уязвимостей программного обеспечения на устройствах iPhone, iPad и Mac теперь гораздо сложнее эксплуатировать злоумышленникам.

Этот пост посвящен проблемам, связанным с временной безопасностью - одному из распространенных классов ошибок в памяти - и построена следующим образом:

Введение в проблемное пространство, включая наши цели, обоснование дизайна и проблемы, которые нам пришлось преодолеть.
Техническое описание безопасного аллокатора kalloc_type с упором на практическую реализацию
Анализ безопасности и оценка нашей работы, включая сильные и слабые стороны.

Проблема

Давайте сначала опишем проблемное пространство безопасности памяти, аллокатор XNU и наши цели в отношении временной безопасности.
Безопасность памяти

Безопасность памяти - это относительно хорошо изученная проблемная область. Остальная часть этой заметки предполагает знакомство с таксономией безопасности памяти:

  • Временная безопасность означает, что все обращения к памяти объекта происходят в течение времени жизни его выделения, между моментом выделения памяти объекта и моментом ее освобождения. Доступ к объекту вне этого окна является небезопасным и называется Use-After-Free (UAF); нарушения double-free являются особым вариантом UAF.
  • Пространственная безопасность подразумевает, что распределение памяти имеет определенный размер, и некорректно обращаться к любой памяти за пределами предполагаемых границ распределения. Нарушения этого свойства называются доступом вне границ (OOB).
  • Безопасность типа означает, что когда распределение памяти представляет определенный объект с конкретными правилами использования этого объекта, эти правила не могут неожиданно измениться - другими словами, распределение является типизированным. Нарушения этого свойства называются путаницей типов.
  • Определенная инициализация означает, что программа несет ответственность за правильную инициализацию вновь выделенной памяти перед ее использованием, поскольку в противном случае выделение может содержать неожиданные данные. Нарушение этого свойства часто приводит к проблемам, называемым раскрытием информации, но иногда может привести к более серьезным проблемам безопасности памяти, таким как перепутанные типы или UAFs.
  • Безопасность потоков связана с тем, как современное программное обеспечение осуществляет одновременный доступ к памяти. Если одновременный доступ к распределению не синхронизирован должным образом, то объекты, содержащиеся в распределении, могут достичь некорректного состояния и нарушить свои инварианты. Нарушения этого свойства обычно называют гонками данных.

Большинство современных языков программирования, таких как Swift, Go и Rust, как правило, обеспечивают первые четыре вышеупомянутых свойства безопасности памяти и с разным успехом гарантируют безопасность потоков. Однако ядро каждой широко используемой современной операционной системы реализовано на таких языках, как C или C++, которые считаются "небезопасными для памяти" - они не предотвращают нарушения безопасности памяти и предоставляют программисту очень мало поддержки, чтобы избежать непреднамеренного и неосознанного нарушения правил безопасности памяти в своем коде. Документировано, что нарушения безопасности памяти являются наиболее широко используемым классом уязвимостей программного обеспечения. И хотя языки, обеспечивающие безопасность памяти, могут предотвратить повреждение памяти в новом коде, переписать большое количество существующего кода за одну ночь не представляется возможным, поэтому нам необходимо разработать новые решения, которые помогут устранить этот пробел.

Устранение последствий и алгоритмы эксплойтов

Большинство эксплойтов, связанных с повреждением памяти ядра, проходят аналогичный путь:

уязвимость → ограниченное повреждение памяти → сильное повреждение памяти → чтение/запись памяти → обход целостности потока управления → выполнение произвольного кода

Идея заключается в том, что атакующий начинает с начальной уязвимости и наращивает все более сильные примитивы, пока, наконец, не достигнет своей цели: возможности читать и записывать память ядра или выполнять произвольный код в ядре. Лучше всего смягчать эти атаки как можно раньше в цепочке по двум причинам.

Во-первых, ранние части цепочки эксплойтов более специфичны к особенностям эксплуатируемой уязвимости, чем поздние, а все, что происходит после этапа чтения/записи памяти, является полностью общим. Ограничения, которые накладываются смягчениями на ранних этапах цепочки, сочетаются с ограничениями конкретной эксплуатируемой ошибки. Злоумышленнику приходится продевать иголку между обоими наборами ограничений одновременно, и поэтому он не может рассматривать обход смягчения как полностью независимый компонент, который работает с любой уязвимостью. С другой стороны, обходы для смягчений, которые накладывают ограничения на более поздних этапах цепочки, особенно после того, как атакующий добился права чтения/записи, обычно совместимы с остальной частью цепочки эксплойтов.
Во-вторых, ограничение злоумышленника на ранних этапах цепочки более эффективно, поскольку у него меньше контроля и, следовательно, меньше инструментов для обхода средств защиты. В отличие от этого, средства защиты от эксплойтов на поздних этапах цепочки обычно накладывают более слабые ограничения на более мощного злоумышленника и с большей вероятностью могут быть обойдены. Средства защиты, изолирующие привилегированную и непривилегированную память или предотвращающие повреждение памяти в первую очередь, имеют огромное преимущество. По этим причинам мы были намерены смягчить последствия как можно раньше в цепи, где у нас есть наилучшие возможности для создания быстрых и поддерживаемых смягчений, обеспечивающих высокий уровень безопасности. Даже обманчиво простые методы может быть трудно преодолеть, если они сдерживают атакующего до того, как эксплойт получит сильные возможности.

Изоляция типов

Без специализированной аппаратной помощи, такой как Arm Memory Tagging Extension (MTE), практические современные средства смягчения проблем временной безопасности вращаются вокруг изоляции типов или секвестрирования. Основной принцип изоляции типов заключается в том, что после использования какого-либо конкретного адреса для объекта данного "типа", только объекты этого типа могут существовать по этому адресу в течение всего времени работы программы. Это активная область исследований, и она также нашла свое применение в производственном программном обеспечении, таком как IsoHeap и GigaCage в WebKit, распределитель памяти iBoot, PartitionAlloc в Chrome и другие. Насколько нам известно, ни одно из основных ядер не использовало ни одну из этих техник, когда мы отправились в это путешествие, хотя AUTOSLAB от grsecurity был разработан независимо в те же сроки. Чтобы понять, почему изоляция типов эффективна, давайте рассмотрим конечную цель эксплойта, связанного с нарушением безопасности памяти. Проблема UAF или OOB сама по себе редко может быть использована напрямую как стабильный произвольный примитив чтения/записи. Сначала необходимо проделать определенную работу, чтобы превратить его в более мощный и надежный примитив. Почти все атаки в какой-то момент зависят от создания путаницы типов: принуждение системы к тому, чтобы один и тот же фрагмент памяти интерпретировался двумя различными и противоречивыми способами. Рассмотрим этот простой пример с использованием двух стандартных типов POSIX: struct iovec и struct timespec. Если злоумышленник может обмануть систему, чтобы интерпретировать один и тот же участок памяти как iovec и timespec на разных кодовых путях, то это потенциально может дать злоумышленнику возможность интерпретировать первую область размера указателя этого участка памяти как указатель (iovec.iov_base) и как поле данных (timespec.tv_sec) в различных контекстах.

Код:
struct iovec {
    char  *iov_base;
    size_t iov_len;
};

Код:
struct timespec {
    time_t tv_sec;
    long   tv_nsec;
};

Скорее всего, система предоставляет злоумышленнику легитимные API для взаимодействия с каждым из этих типов по отдельности. Вооружившись интерфейсами, которые изменяют значения timespec, злоумышленник может использовать этот API на памяти с перепутанным типом для чтения и перенаправления поля iov_base, когда эта же память рассматривается как iovec. А используя интерфейсы, взаимодействующие со структурой iovec, злоумышленник может читать из буфера, на который указывает iov_base, или писать в него. Попеременно используя эту память как timespec и iovec, злоумышленник получает возможность доступа к любой области памяти в адресном пространстве ядра.

Этот пример надуманный, но он дает нам понимание того, почему изоляция типов может быть полезной. Если распределитель гарантирует, что после того, как данный адрес памяти был использован как iovec, он может быть только iovec, то построение путаницы типов из iovec UAF, как в примере выше, становится невозможным. В этом месте памяти можно найти только действительный iovec, освобожденный iovec или немаркированную память. Места, соответствующие возможным полям iovec.iov_base, всегда будут интерпретироваться как указатели, что лишает злоумышленников возможности влиять на значение этих мест памяти с точностью, необходимой для построения произвольного примитива чтения/записи. Доступ к освобожденному iovec все еще может позволить злоумышленнику разыменовывать висячие указатели, но это можно смягчить с помощью политики zero-on-free в аллокаторе. К сожалению, изоляция типов сама по себе не поможет справиться с ошибками OOB. Рассмотрим тип со встроенным буфером, например struct sockaddr. Если доступ к sa_data выходит за границы, то он может быть использован для повреждения всего, что расположено сразу после него, независимо от изоляции типов. В оставшейся части статьи мы в основном игнорируем нарушения пространственной безопасности, поскольку они рассматриваются с помощью другого набора методов.

Код:
struct sockaddr {
     u_char sa_len;
     u_char sa_family;
     char   sa_data[14];
};

Описание указателей и данных

Память в целом можно разделить на "управляющую" и "данные". Управление - это то, что позволяет программе структурировать и организовывать данные. Сюда входят такие вещи, как указатели, счетчики ссылок, длины и информация о вводе, например, теги объединения. Почти все остальное - это данные.

Компьютеры манипулируют данными и полагаются на управляющие структуры как на необходимую сложность, чтобы понять смысл задачи. Это, как правило, определяет системные интерфейсы и, в свою очередь, то, как эти интерфейсы манипулируют памятью. Как правило, системные интерфейсы позволяют напрямую манипулировать данными на месте, как для чтения, так и для записи. И наоборот, системные интерфейсы часто позволяют только косвенные манипуляции с элементами управления: добавление узлов в связанные списки, увеличение количества ссылок и так далее. Точные числовые значения полей управления обычно имеют смысл только в конкретном адресном пространстве и в конкретный момент времени. Это детали реализации, обычно рассматриваемые как "внутренности", которые не должны раскрываться через хорошо спроектированные интерфейсы. Например, системные вызовы никогда не должны позволять пользователям напрямую читать или записывать указатели ядра, изменять счетчики структур данных ядра или изменять информацию о типизации структур данных ядра. Возвращаясь к надуманному примеру в предыдущем разделе, перекрытие iovec.iov_base и timespec.tv_sec было такой мощной техникой эксплуатации, потому что оно алиасировало управляющие поля и поля данных. И действительно, именно такую фундаментальную путаницу пытаются сформировать злоумышленники в большинстве современных эксплойтов.
Поскольку вычисления так сильно зависят от манипулирования данными, у нас есть два наблюдения из реального опыта:

Выделения данных имеют тенденцию доминировать по размеру и составляют большую часть всех выделений.
Манипулирование данными имеет тенденцию к более жестким ограничениям производительности.

Если собрать эти наблюдения вместе, то становится ясно, что чистые типы данных являются особенными: ими легко манипулировать через легитимные системные интерфейсы, но они по своей сути неинтересны для злоумышленника. Эксплуатация системы - это захват того, как организованы данные, то есть захват контроля. Это приводит к естественному желанию полностью изолировать чистые типы данных от управления, как для безопасности, так и для производительности. Польза для безопасности связана с тем, что путаница между чистыми данными и управлением почти всегда может быть использована. Преимущество в производительности возникает потому, что легче ослабить дорогостоящие правила безопасности вокруг распределений только данных, когда они живут в отдельном мире.

Краткие сведения о распределителях XNU

В XNU есть несколько API распределителей памяти, но все они вливаются в одну из двух подсистем:

зональный аллокатор, который обслуживает аллокации меньшего размера (в основном субстраницы).
Прямые распределения виртуальных машин в vm_map, подобную kernel_map, которая обслуживает распределения с гранулярностью страницы или с особыми потребностями для совместного использования, ремаппинга и т.д.

В этом посте мы сосредоточимся на зональном аллокаторе. Это относительно общий распределитель областей, управляющий коллекцией "кусков" памяти - непрерывных страниц - которые равномерно разделены на элементы одинакового размера. Система может создавать определенные зоны для конкретного случая использования, например, зону "ipc ports", или использовать предварительно созданную коллекцию зон, обслуживаемых API ядра kalloc, по одной на класс размера (kalloc.16, kalloc.32, ...). Команда zprint(1) в macOS может быть использована для получения сводки об использовании зон в живой системе.

Подсистема зон следит за использованием своих блоков, и если блок состоит только из свободных элементов, то память, как физическая, так и виртуальная, может быть восстановлена, когда система испытывает давление памяти. Этот акт восстановления баланса называется событием сборки мусора (GC) зоны.
Технические проблемы

Основными техническими проблемами при добавлении изоляции типов в аллокатор являются использование памяти и исчерпывающее внедрение.

Использование памяти является особенно сложной задачей для XNU, поскольку ядро должно масштабироваться от небольшой энергоэффективной системы, такой как Apple Watch, до производительного Mac Studio с 128 ГБ оперативной памяти. Большинство небольших систем не могут выдержать значительного увеличения использования памяти без риска регрессии пользовательского опыта. Это проблематично, поскольку изоляция типов часто увеличивает фрагментацию памяти из-за ограничений на повторное использование памяти. Еще одна проблема - исчерпаемость. Злоумышленники хотят, чтобы их эксплойты были надежными - в идеале детерминированными. Если изоляция типов применяется лишь частично, то зачисленные типы становятся более безопасными, но ценой большей детерминированности оставшихся распределений. Поэтому очень важно, чтобы изоляция типов была принята полностью, иначе злоумышленники сосредоточатся на распределениях, которые не были зачислены - и, вероятно, в итоге получат более стабильные эксплойты. Еще более сложной задачей является то, что "ядро" на платформах Apple представляет собой конгломерат основного ядра XNU и большого количества расширений ядра для критически важных функций, таких как драйверы устройств, файловые системы, контроллеры питания и т. д. Эти расширения ядра (или kexts) имеют широкий спектр стилей кодирования, которые используют различные стили кодирования.

Наше решение должно свести к минимуму нарушения в этих кодовых базах. Некоторые kexts также имеют специальные отношения и идиосинкратические сроки жизни выделения, которые усложняют изоляцию и типизацию, например, выделение конкретного типа в одном kext, а затем освобождение объекта в другом kext без знания исходного типа или размера выделения.

Распределитель зон также сильно задействован в рабочих нагрузках, чувствительных к производительности, и не может мириться с регрессом в использовании процессора. В предыдущих исследованиях изоляции типов некоторые реализации использовали обратную трассировку распределения в качестве приближения к "типу", но это привело бы к ощутимому и неприемлемому влиянию на производительность для меньших размеров распределения.

В целом, мы начали это путешествие с бюджетом производительности в 0% CPU и 0% влияния на память.

Изоляция типов в XNU

Секвестрирование зон и кучи kalloc

До iOS 14 виртуальная память, используемая для зон, была вырезана во время ранней загрузки и имела размер доли от общей памяти устройства, с ограничением для особо крупных конфигураций. Этот диапазон был обернут в подкарту kernel_map под названием zone_map, и память для зон выделялась из этой карты через подсистему виртуальной памяти XNU.

Как мы уже говорили, зоны управляют смежно распределяемыми областями памяти в "кусках", которые обычно состоят из пары системных страниц. Зона отслеживает свои куски в трех отдельных списках: куски со всеми свободными элементами, куски с некоторыми свободными элементами и куски без свободных элементов. Чтобы уменьшить фрагментацию, зоны предпочитают выделять из частично используемых блоков. Когда в системе заканчивается доступная память, она может инициировать событие Zone GC, чтобы вернуть системе чанки без выделенных элементов.

До iOS 14 эксплойты для уязвимостей ядра Use-After-Free (UAF) обычно проходили по одному и тому же сценарию:

  • Выделить большое количество объектов, для которых существует уязвимость UAF.
  • Запустить уязвимость UAF, чтобы освободить один из этих объектов, пока на него еще есть висящая ссылка.
  • Освободить оставшиеся объекты, в результате чего чанк, содержащий висящий объект, остается полностью пустым и доступным для возврата.
  • Создать давление на память, чтобы зона GC вернула системе виртуальную страницу, содержащую висящий объект.
  • Выделить большое количество объектов другого типа, чтобы вернуть адрес висящего объекта объекту другого типа (часто объекту чистых данных), создавая таким образом путаницу типов через указатель

Поскольку этот поток был настолько надежным, первыми двумя вещами, на которых мы сосредоточились в iOS 14, были предотвращение повторного использования виртуальных адресов в зонах и отделение выделения чистых данных от остальных. Первое предотвращает создание перекрытия зон GC на шаге 4 на шаге 5, а второе уменьшает количество полезных объектов замены на шаге 5.
Мы предотвратили повторное использование виртуальных адресов (VA) в зонах, введя секвестрирование зон. В дополнение к трем спискам чанков, представленным выше, мы добавили четвертый для хранения чанков чистых диапазонов VA без какого-либо физического резервного хранилища:

Код:
struct zone {
...
    /*
     * list of metadata structs, which maintain per-page free element lists
     */
    zone_pva_t z_pageq_empty;  /* populated, completely empty pages   */
    zone_pva_t z_pageq_partial;/* populated, partially filled pages   */
    zone_pva_t z_pageq_full;   /* populated, completely full pages    */
    zone_pva_t z_pageq_va;     /* non-populated VA pages              */
...
};

Когда зоны секвестрируются, зональный GC ведет себя несколько иначе. Вместо того чтобы возвращать в zone_map физическую память и диапазон VA, он возвращает только физическую память и запоминает VA в новом четвертом списке. Сохранение диапазона виртуальных адресов, выделенных для зоны, даже когда этот диапазон освобождается от физических страниц, гарантирует, что VA не может быть повторно использован какой-либо другой зоной. Выделения в зонах одного типа (например, зоны "proc" и "thread") больше не подвержены прямой путанице типов через UAF, поскольку их VA не могут быть повторно использованы для другого типа. А в традиционных зонах kalloc объекты теперь можно перепутать только с другими объектами того же класса размера. GC-атаки через зоны с разными размерными классами больше невозможны.
Мы отделили выделения чистых данных от остальных, введя два понятия: кучи kalloc (kheaps) и субмапы зон. Куча kheap - это основанная на размерах коллекция зон, обслуживающих определенное "пространство имен" выделений; оригинальная "kalloc" стала кучей ядра "по умолчанию" (KHEAP_DEFAULT). Мы также добавили новую кучу, названную "кучей данных" (KHEAP_DATA_BUFFERS), для хранения распределений из чистых данных. Основные примитивы выделения XNU были скорректированы так, что kalloc(...) стал означать kheap_alloc(KHEAP_DEFAULT, ...), а новое семейство вызовов kalloc_data(...) переводится как kheap_alloc(KHEAP_DATA_BUFFERS, ...). Наша первая ручная попытка разделить мир по этой границе произошла в iOS 14.

Примечание: в iOS 14 было два дополнительных kheap: "kext" и "temp". Первая отделяла выделения, сделанные kexts, от кучи "по умолчанию" (ядра), а вторая проверяла, чтобы выделения не оставались за пределами времени жизни создавшего их системного вызова. Куча kext была промежуточным вариантом для более совершенного решения в iOS 15, в то время как куча temp оказалась недостаточно безопасной. Обе кучи были удалены в iOS 15.

Поскольку распределение данных часто вовлекается в критические пути производительности, мы решили не секвестрировать чистые зоны данных. Это ограничило влияние секвестрирования на стабильность ОС и фрагментацию памяти. Конечно, отказ от секвестрирования зон данных влечет за собой компромисс с безопасностью, но мы ожидали, что ошибки UAF в мире чистых данных, как правило, не будут интересны злоумышленникам.

В результате карта зон стала статической вырезкой виртуальной памяти, которая затем подразделяется на подкарты. В iOS 14 их было три: "VM", "общая" и "данные".

Примечание: В iOS 15.2 общая подкарта была разделена на четыре отдельные подкарты. Мы расскажем об этом изменении в одной из следующих статей.

Зонам присваивается идентификатор подмапы, который определяет диапазон, из которого выделяются фрагменты VA. Такая конструкция позволяет очень быстро проверять, является ли память из ожидаемого мира (см. KPIs kalloc_{,non_}data_require()). Зоны в куче данных назначаются на подкучу данных. ВМ использует zalloc для своих внутренних структур данных (VM maps, VM map entries, VM pages и т.д.) и упаковывает указатели некоторых объектов в 32 бита, что ограничивает диапазон, в котором могут жить указанные объекты. Однако зонам также необходимо использовать подсистему VM. Чтобы разрешить эту круговую зависимость, зоны, поддерживающие подсистему VM, получают особое обращение и назначаются на подмап VM. Почти все остальные зоны в системе выделяют свою память из общей подкарты. Сегодня все зоны в подкартах VM и general секвестрируются политикой, хотя до iOS 15.2 были некоторые исключения. Зоны в подкарте данных не секвестрируются.

Мы также решили сделать метаданные, используемые аллокатором, внешними. До iOS 14.5 реализация зоны отслеживала освобожденные элементы во внешнем списке с указателями, хранящимися внутри самих освобожденных распределений. В течение некоторого времени этот список не был распространенной целью для эксплуатации, поскольку он был защищен случайными секретами и резервными указателями. Однако достаточно мощный UAF все еще может быть использован для манипулирования метаданными аллокатора, поэтому мы заменили этот список внешним битовым изображением, хранящим состояние аллокации каждого элемента.

Зональные субмапы и хеапы - это необходимая инфраструктура для создания более мощной изоляции. Однако UAF все еще могут создавать путаницу типов между любыми типами в пределах класса размера.

Kalloc_type

В iOS 15 мы представили kalloc_type, чтобы обеспечить разделение на основе типов для выделений общего назначения в каждом размерном классе. Kalloc_type основывается на секвестрировании на основе зон, предоставляя каждому размерному классу несколько зон kalloc.type* для использования, а не объединяя все выделения в пределах размерного класса в одну зону. Основная идея заключается в том, чтобы использовать компилятор для статической генерации "сигнатуры" каждого типа, который выделяется, а затем назначить различные сигнатуры в различные зоны kalloc.type* во время ранней загрузки. В итоге данный тип может быть перераспределен только другими типами, которые были назначены в ту же зону, что значительно сокращает количество кандидатов на перераспределение UAF для любого данного типа.

Как мы уже говорили, злоумышленники обычно используют UAFs, устанавливая путаницу типов между указателем и контролируемыми злоумышленником данными. Поэтому мы разработали схему подписи kalloc_type, чтобы позволить алгоритму сегрегации уменьшить количество наложений указателя на данные путем кодирования следующих свойств для каждой 8-байтовой гранулы типа:

Код:
__options_decl(kt_granule_t, uint32_t, {
    KT_GRANULE_PADDING = 0, /* Represents padding inside a record type */
    KT_GRANULE_POINTER = 1, /* Represents a pointer type */
    KT_GRANULE_DATA    = 2, /* Represents a scalar type that is not a pointer */
    KT_GRANULE_DUAL    = 4, /* Currently unused */
    KT_GRANULE_PAC     = 8  /* Represents a pointer which is subject to PAC */
});

Исходный код может получить доступ к строковому представлению сигнатуры для данного типа с помощью __builtin_xnu_type_signature(). Например, struct iovec из начала этой заметки будет иметь сигнатуру "12", что означает, что первые 8 байт содержат указатель, а вторые 8 байт - значение данных.

Код:
(lldb) showstructpacking iovec
0000,[  16] (struct iovec)) {
    0000,[   8] (void *) iov_base /* pointer -> 1 */
    0008,[   8] (size_t) iov_len  /* data    -> 2 */
}

__builtin_xnu_type_signature(struct iovec) = "12"

Каждый объект, выделяющий или освобождающий тип (kalloc_type() и kfree_type()), должен знать, из какой конкретной зоны kalloc.type* в соответствующем размерном классе выделять или освобождать. Вычисление зоны на основе строки сигнатуры во время каждого вызова было бы непомерно дорогим. Вместо этого мы используем структуры kalloc_type_view для предварительного вычисления этого назначения при загрузке, а затем кэшируем результат для каждого участка выделения:

Код:
/* View for fixed size kalloc_type allocations */
struct kalloc_type_view {
    /* Zone view that is chosen by the segregation algorithm */
    struct zone_view        kt_zv;
    /* Signature produced by __builtin_xnu_type_signature */
    const char             *kt_signature __unsafe_indexable;
    kalloc_type_flags_t     kt_flags;
    uint32_t                kt_size;
    void                   *unused1;
    void                   *unused2;
};

Чтобы создать структуру kalloc_type_view для каждого объекта распределения, мы определяем kalloc_type() как макрос, который создает соответствующий kalloc_type_view в секции __DATA_CONST.__kalloc_type ядра:

Код:
#define kalloc_type_2(type, flags) ({                                      \
    static KALLOC_TYPE_DEFINE(kt_view_var, type, KT_SHARED_ACCT);          \
    __unsafe_forge_single(type *, kalloc_type_impl(kt_view_var, flags));   \
})

#define _KALLOC_TYPE_DEFINE(var, type, flags)                       \
    __kalloc_no_kasan                                               \
    __PLACE_IN_SECTION(KALLOC_TYPE_SEGMENT ", __kalloc_type")       \
    struct kalloc_type_view var[1] = { {                            \
        .kt_zv.zv_name = "site." #type,                             \
        .kt_flags = KALLOC_TYPE_ADJUST_FLAGS(flags, type),          \
        .kt_size = sizeof(type),                                    \
        .kt_signature = KALLOC_TYPE_EMIT_SIG(type),                 \
    } };                                                            \
    KALLOC_TYPE_SIZE_CHECK(sizeof(type));

Размещение представлений kalloc_type_views в специальном разделе позволяет нам обрабатывать их во время ранней загрузки, чтобы назначить каждый участок выделения и освобождения определенной зоне kalloc.type*. Алгоритм сегрегации, который выполняется во время инициализации распределителя зон, сортирует список представлений kalloc_type_views по сигнатуре в пределах каждого класса размеров. Это гарантирует, что представления типов с одинаковой сигнатурой в определенном размерном классе всегда назначаются одной и той же зоне. Затем мы разбиваем соседние сигнатуры, где первая является префиксом второй, как часть одной и той же "группы уникальных сигнатур". Например, если ни один вид типа не имеет сигнатуры "122111", то "12211" и "122112" рассматриваются как одна и та же группа сигнатур, поскольку первая является префиксом второй.

В идеале каждый тип должен иметь свою собственную зону для достижения идеальной изоляции, но поскольку фрагментация начинает резко возрастать после определенного момента, это не укладывалось в наш бюджет памяти. Вместо этого мы остановились на бюджете в 200 зон, которые мы разделили между классами размеров на основе количества уникальных групп сигнатур для каждого. Затем во время ранней загрузки мы равномерно и случайно распределяем уникальные группы сигнатур между зонами kalloc.type* для каждого класса размеров. Наконец, мы обновляем поле kt_zv.zv_zone каждого представления типа, чтобы оно указывало на назначенную зону. Это позволяет kalloc_type_impl() находить нужную зону для данного типа во время выполнения с помощью одной загрузки. В итоге kalloc_type реализует рандомизированную, блочную изоляцию типов для распределений общего назначения с размером зоны в XNU с разумными затратами памяти - за которые приходится платить различными оптимизациями - и почти нулевыми затратами процессора.
Дополнительные проблемы

Помимо основной идеи, при разработке и реализации kalloc_type мы столкнулись с некоторыми интересными дополнительными проблемами.

Нам нужно было сгруппировать один и тот же тип в разных единицах компиляции, даже если типы были скопированы или даже слегка подправлены или переименованы в разных областях. Нам нужно было, чтобы разные определения для одного и того же функционального типа стали едиными, чтобы избежать распространения этого типа по нескольким зонам или попыток освобождения в неправильную зону. Отчасти поэтому мы решили использовать очень простую, нерекурсивную схему подписи. Другим препятствием было то, что, хотя мы разработали схему подписи для минимизации перекрытия указателей с данными, в коде часто встречаются случаи хранения указателей в целочисленных типах, таких как uintptr_t и vm_address_t. Поскольку схема подписи пытается сгруппировать типы с одинаковой сигнатурой вместе, наличие указателя, спрятанного в поле с типом данных, даст детерминированное перекрытие указателя с данными и станет привлекательной целью для эксплуатации UAF.
Для решения проблемы указателей, скрытых в данных, мы ввели атрибут xnu_usage_semantics, чтобы вручную переопределить информацию компилятора о гранулах. XNU аннотирует определенные типы или поля как указатели или данные с помощью удобных макросов __kernel_ptr_semantics и __kernel_data_semantics:

Код:
#define __kernel_ptr_semantics __attribute__((xnu_usage_semantics("pointer")))
#define __kernel_data_semantics __attribute__((xnu_usage_semantics("data")))

Затем мы использовали проверку исходного текста и автоматизированные инструменты для поиска мест, где указатели ядра хранились в полях, типизированных для данных, и аннотировали эти случаи соответствующим образом. Также было несколько случаев хранения указателей пользовательского пространства в полях, типизированных для указателей, которые мы аннотировали с помощью __kernel_data_semantics, поскольку ядро считает указатели пользовательского пространства данными.

Код:
typedef uint64_t mach_vm_offset_t __kernel_ptr_semantics;

struct shared_file_mapping_slide_np {
    /* address at which to create mapping */
    mach_vm_address_t       sms_address __kernel_data_semantics;
...
    /* offset into file to be mapped */
    mach_vm_offset_t        sms_file_offset __kernel_data_semantics;
...
};

Нам также необходимо было обеспечить эргономичность работы клиентов при выделении типов, содержащих только данные, или больших типов, превышающих максимальный размер, поддерживаемый распределителем зон. Как говорилось во введении, как для производительности, так и для безопасности полезно изолировать типы, содержащие только данные, от типов, содержащих управление, но мы хотим предоставить клиентам согласованный API распределения, чтобы изменение определения типа не требовало от них изменения функции распределителя, которую они используют. Кроме того, некоторые kexts определяют очень большие типы, для которых нам нужно использовать VM allocator вместо zone allocator для обслуживания запроса. Эти типы размером с VM будут иметь огромные строки сигнатур типов, которые фактически не используются.

Чтобы убедиться, что мы выделяем из правильного пространства, мы отслеживаем, является ли этот тип типом только для данных или VM-размером, в поле kt_flags каждого представления kalloc_type_view. Это позволяет kalloc_type_impl() быстро определить, к какой базовой реализации аллокатора следует обращаться.

Использование kt_flags решает проблему согласованности API, но мы также хотим исключить неиспользуемые и потенциально очень большие строки сигнатур из двоичного файла для выделения только данных и VM. Это требует, чтобы мы определяли, будет ли тип только для данных или только для виртуальных машин, во время компиляции. Мы можем легко проверить, является ли тип VM-размером во время компиляции, используя sizeof(), но нет способа проверить, является ли тип только для данных, даже если у нас есть строка подписи. Чтобы сделать проверку только данных возможной во время компиляции, мы ввели еще одну встроенную функцию Clang, __builtin_xnu_type_summary(), которая возвращает битовую или битовую информацию о гранулах для каждой гранулы в типе.

Но, пожалуй, самой сложной задачей было решить, что делать с выделениями переменной длины. Действительно, эта проблема была настолько сложной, что мы решили не решать ее в первом выпуске kalloc_type.

Выделения переменной длины

Хотя любой тип на уровне языка будет иметь фиксированный размер, очень часто в коде создаются выделения переменной длины, которые, тем не менее, хорошо типизированы. Наиболее очевидным случаем является массив типов фиксированного размера, но также часто встречается заголовок фиксированного размера, за которым непосредственно следует переменное количество элементов другого типа. Эти шаблоны настолько распространены, что мы должны были поддержать их, если хотели перенести все выделения ядра из кучи "по умолчанию" (KHEAP_DEFAULT), где эти выделения жили, когда мы впервые выпустили kalloc_type для типов фиксированного размера.

Хотя все выделения типа фиксированного размера помещаются в одну зону, типы переменного размера этого не делают. Естественным продолжением нашей предыдущей работы было бы создание нескольких kheaps исключительно для этих распределений переменного размера и случайное распределение типов переменного размера между ними на основе их сигнатур. В принципе, мы так и поступили, за одним исключением для массивов указателей.

Чтобы ограничить форму выделений переменного размера до чего-то удобоваримого, мы разрешаем выделения переменного размера только в следующих формах:

  • Массив элементов одного типа
  • Заголовок фиксированного размера, за которым следует массив элементов одного типа.

Мы явно запрещаем выделения, состоящие из заголовка, не содержащего данных, за которым следует повторяющийся тип, содержащий только данные. Если часть распределения, содержащая только данные, имеет произвольный размер и контролируется злоумышленником, то это будет общий кандидат на перераспределение для эксплуатации UAF практически в любом типе переменного размера, содержащем указатели. Поскольку весь смысл этого усиления заключается в том, чтобы заставить злоумышленников создавать индивидуальные стратегии эксплуатации для каждой ошибки, мы посчитали, что стоит заставить существующий код разделить распределение так, чтобы данные переменной длины попадали в кучу данных.

Мы решили дополнительно смягчить общие методы эксплуатации с помощью еще одного особого случая: мы изолируем массивы указателей в их собственную кучу данных. Мы неоднократно наблюдали, как эксплойты используют массивы указателей - например, массивы внестрочных портов Mach - в качестве общего объекта перераспределения. Мы чувствовали, что без изоляции массивов указателей такие техники эксплуатации будут продолжать быть полезными для злоумышленников.

Естественно, мы ввели новый тип kalloc_type_var_view для отслеживания дополнительной информации, необходимой для кода инициализации ранней загрузочной зоны, чтобы правильно назначить типы переменного размера для kheaps. Эти представления живут в секциях __DATA_CONST.__kalloc_var ядра Mach-O:

Код:
/* View for variable size kalloc_type allocations */
struct kalloc_type_var_view {
    kalloc_type_version_t   kt_version;
    uint16_t                kt_size_hdr;
    uint32_t                kt_size_type;
    zone_stats_t            kt_stats;
    const char             *__unsafe_indexable kt_name;
    zone_view_t             kt_next;
    /* Kheap start that is chosen by the segreagtion algorithm */
    zone_id_t               kt_heap_start;
    uint8_t                 kt_zones[KHEAP_NUM_ZONES];
    /*
     * Signature produced by __builtin_xnu_type_signature for
     * header and repeating type
     */
    const char             *__unsafe_indexable kt_sig_hdr;
    const char             *__unsafe_indexable kt_sig_type;
    kalloc_type_flags_t     kt_flags;
};

В отличие от типов фиксированного размера, выделения переменного размера не принадлежат определенной зоне. Поэтому мы кэшируем идентификатор зоны начала выбранной kheap для быстрого доступа во время выполнения. Kalloc определяет индекс, основанный на размере выделения, который добавляется к началу kheap для получения идентификатора зоны для выделения.

Теперь, когда мы описали, как работает kalloc_type, давайте обсудим, как мы внедрили его в ядро.

Стратегия внедрения

Интерфейс kalloc_type() требует ручного внедрения, поскольку программист должен явно предоставлять информацию о типе того, что он выделяет. И для достижения нашей цели безопасности нам нужно было, чтобы ядро и все kexts полностью и правильно приняли этот интерфейс. Однако в сложном коде программисты неизбежно допускают ошибки. Так что если нам нужно, чтобы принятие было идеальным, и мы знаем, что люди совершают ошибки, почему мы намеренно выбрали дизайн API, требующий ручного принятия?

Если целью является повсеместная изоляция типов, то мы видим только три основных варианта реализации:

  • Время выполнения может определять тип без изменений клиента, например, используя обратные следы распределения.
  • Компилятор может переписать все сайты распределения, чтобы добавить более богатую информацию о типе.
  • Мы могли бы создать ручной интерфейс, который пришлось бы внедрять повсеместно.

Мы отказались от вывода типа во время выполнения, потому что хэширование и вычисление обратных следов будут доминировать по стоимости для небольших распределений, которые находятся на критическом пути производительности. Мы также исключили вариант 2, передачу компилятору, потому что компилятору вообще трудно безошибочно определить типы из места вызова выделения, и вывод плохо работает для "оберток выделения", о чем говорилось в нескольких работах. Мы также хотели применить семантику "строгого освобождения", когда свободный сайт также проверяет информацию о типе. Для этого нужно, чтобы все сайты вызова, выделяющие и освобождающие один и тот же "тип", получали одинаковую информацию, что было бы еще труднее обеспечить в варианте 2. Поэтому ручной интерфейс из варианта 3 был единственным способом получить желаемый набор функций и производительность, несмотря на риск ошибки программиста при ручном внедрении. Чтобы помочь инженерам ядра выполнять последовательные правильные внедрения, мы обучили внутренний Clang компании Apple работе с поверхностью API распределения XNU, чтобы он мог отмечать ошибки при неправильном использовании API. Разработчики XNU и расширений ядра должны следовать жесткому набору правил при использовании этих API, и эти правила также были закодированы в компиляторе. Компилятор использует естественную доступность типов, когда они используются либо для приведения результата вызова выделения, либо для вычисления размера выделения с помощью выражения sizeof(). Это изменение компилятора способствовало быстрому внедрению. С выходом iOS 16 было преобразовано около 95% кодовой базы пространства ядра для мобильных платформ. Поддержка компилятора также дает нам больше уверенности в том, что при изменении кода не возникнут регрессии. Вот примеры фактической диагностики, показанной при нарушении правил или использовании устаревших интерфейсов выделения:

Код:
error: allocation of mixed-content type 'struct ipc_port' using a data allocator API [-Werror,-Wxnu-typed-allocators]
        port_array = kalloc_data(sizeof(struct ipc_port) * len, Z_WAITOK);
                     ^
                        
error: allocation of array of type 'int' should use 'IONew' [-Werror,-Wxnu-typed-allocators]
        _swapCurrentStates = (int*)IOMalloc(newCurStatesSize);

Анализ безопасности

Теперь, когда мы объяснили мотивацию и дизайн kalloc_type, давайте рассмотрим его свойства временной безопасности - в частности, насколько хорошо он достигает своей цели изоляции типов, а также слабые места, о которых мы знаем. Мы начнем со сравнения kalloc_type с двумя другими механизмами изоляции типов, IsoHeap и PartitionAlloc.

Сравнение с IsoHeap и PartitionAlloc

IsoHeap - это API аллокатора, используемый WebKit для обеспечения строгой изоляции между участвующими типами C++ в браузере Safari. Основная идея заключается в том, что типы C++ выбирают макрос MAKE_BISO_MALLOCED_IMPL(), который переопределяет операторы new и delete для выделения из выделенной IsoHeap. Каждая выделенная страница IsoHeap хранит метаданные о состоянии выделения каждой "ячейки" (объекта) на странице в начале самой страницы. Однако существует свободный список, проходящий через свободные ячейки на странице. Гарантия изоляции типов заключается в том, что после того, как данный виртуальный адрес будет назначен определенному типу, этот виртуальный адрес никогда не будет повторно использован для любого другого типа.

Примечание: IsoHeap включает умную оптимизацию для уменьшения фрагментации: первые несколько распределений типа обслуживаются из общего распределителя, хотя слоты остаются закрепленными за этим типом навсегда. После того, как было сделано достаточно выделений, IsoHeap переключается на использование выделенных страниц для типа, что повышает производительность. Это помогает уменьшить объем памяти редко выделяемых или одиночных типов.

PartitionAlloc в Google Chrome - это еще один распределитель, который использует изоляцию для уменьшения влияния UAFs. PartitionAlloc называет каждую отдельную кучу "разделом", а каждый раздел содержит несколько "блоков" (классов размеров). Каждый блок, в свою очередь, состоит из нескольких "слотов", или областей непрерывной памяти, предназначенных для хранения распределений ("слотов") из этого блока. Новая виртуальная память фиксируется в разделе как "суперстраницы" размером 2 Мбайт, выровненные по 2 Мбайт, содержащие защитные страницы, метаданные и пространство для использования блоками. Хотя большинство метаданных перемещается в выделенный регион, как и в случае с IsoHeap, обычно все еще существует свободный список, проходящий через сами свободные слоты. Гарантия изоляции типа заключается в том, что после назначения данного виртуального адреса определенному блоку в определенном разделе, этот виртуальный адрес остается связанным с этим блоком навсегда.

Итак, как IsoHeap, PartitionAlloc и kalloc_type сравниваются с точки зрения изоляции типов, защиты метаданных и внедрения?

Что касается изоляции типов, мы считаем IsoHeap самой сильной, за ней следует kalloc_type, а затем PartitionAlloc. IsoHeap обеспечивает истинную изоляцию типов, когда данный тип не может быть перераспределен на любой другой тип: просто не существует пути кода при нормальной работе, который позволяет виртуальному адресу, используемому объектом типа A, быть повторно использованным для выделения объекта типа B. Между тем, kalloc_type обеспечивает рандомизированную изоляцию типов: любой данный тип A будет иметь несколько других типов B, которые могут его перераспределить, но набор всех типов, которые могут работать (класс размеров), относительно мал, а набор, который будет работать на данной загрузке, еще меньше, состоящий только из тех, которые попадают в ту же зону. PartitionAlloc в принципе можно было бы использовать для достижения более сильной изоляции типов, но движок рендеринга Blink, используемый в Google Chrome, в настоящее время определяет только четыре раздела: LayoutObject, Buffer, ArrayBuffer и FastMalloc. UAF между разделами и между классами размеров блокируются, что устраняет многие методы эксплуатации, но висящий объект может быть перераспределен на любой другой объект из того же раздела и класса размеров независимо от типа. Что касается защиты метаданных, мы считаем, что kalloc_type является самым сильным, за ним следует PartitionAlloc и затем IsoHeap. Метаданные kalloc_type полностью экстернализированы: нет даже списка освобожденных элементов. Это означает, что UAF вообще не может нацелиться на метаданные аллокатора. Единственная возможность - это получить слот, перераспределенный на какой-то новый объект, и попытаться манипулировать им. Между тем, и IsoHeap, и PartitionAlloc используют freelist внутри самих элементов, поэтому некоторые UAF смогут модифицировать внутреннее устройство аллокатора. Оба также используют различные формы защиты freelist для предотвращения вмешательства в указатели freelist, и различные UAF смогут манипулировать каждой схемой. Однако PartitionAlloc окружает свой основной блок метаданных защитными страницами, что предотвращает его перезапись при линейных переполнениях. Распределители имеют разные сильные и слабые стороны. IsoHeap требует явного ручного внедрения, но внедрение происходит относительно легко. С другой стороны, он поддерживает только C++, поэтому код на C не может участвовать в нем вообще. PartitionAlloc требует ручного принятия для обеспечения сильной изоляции типов, но благодаря усилиям PartitionAlloc-Everywhere, Chromium смог направить почти все непринятые выделения в раздел Malloc. Несмотря на это, автоматическое внедрение не обеспечивает такого же уровня изоляции типов в пределах класса размера, как IsoHeap и kalloc_type. Наконец, переход на kalloc_type осуществляется вручную, но с помощью инструментов. Его внедрение требует больших усилий, чем внедрение IsoHeap, поскольку необходимо изменить каждый сайт выделения, а иногда требуется разбить выделения для соответствия правилам аллокатора. Однако он поддерживает как C, так и C++, и автоматизация была очень надежной.

Преимущества рандомизированного распределения

В ядре так много типов языкового уровня, что назначение каждому из них выделенной зоны, как в IsoHeap, было бы непомерно сложным. С самого начала было ясно, что потребуется какая-то форма разделения на группы. Использование сигнатур вместо типов на уровне языка решило ряд проблем, связанных с "идентичностью типов", таких как присвоение одному и тому же типу разных имен в разных областях кода. Это также позволило легко объединить типы, которые, как ожидается, будут иметь схожие эксплуатационные свойства. Например, в кернелкэше iOS 16 beta 1 было 3574 непеременных типа kalloc_type, но только 1822 уникальных сигнатуры, использующих схему, описанную выше. Кластеризация сигнатур в уникальные группы сигнатур по общему префиксу в пределах класса размера позволяет сократить это число до 1482 групп сигнатур.
Однако 1482 группы сигнатур все еще в 7 раз превышают 200 зон, которые мы выделили для изоляции непеременных типов. Мы рассмотрели несколько вариантов выделения групп сигнатур для решения этой проблемы:

  • Мы могли бы использовать что-то вроде randstruct для принуждения существующих типов к одинаковой сигнатуре.
  • Мы можем вычислить наилучшее разбиение сигнатур на группы сигнатур, которое минимизирует количество перекрытий указателей/данных.
  • Мы могли бы группировать сигнатуры случайным образом.

В конечном итоге мы решили, что случайная группировка подписей в группы дает наилучший компромисс. Другие варианты увеличили бы количество типов C/C++, которые всегда выделяются вместе, что делает их идеальными кандидатами для использования UAF. Теоретически, таких кандидатов будет труднее использовать, поскольку количество перекрытий указателей/данных уменьшится. Однако мы ожидали, что злоумышленники, ищущие надежные эксплойты, вместо этого начнут использовать интересные перекрытия указатель/указатель, превращая UAF в косвенную путаницу типов через поле указателя. В целом, это будет сложнее осуществить, поскольку ограничения путаницы типов верхнего уровня (от UAF) будут сочетаться с ограничениями путаницы типов следующего уровня (от перекрывающихся указателей разных типов), но пространство поиска хороших перекрытий будет небольшим, и один эксплойт будет работать одинаково на всех устройствах. С другой стороны, случайное распределение типов по группам меняет то, какие типы группируются вместе от загрузки к загрузке, уменьшая количество детерминированно стабильных пар. Это вредит изоляции, поскольку более интересные типы группируются вместе, но мы ожидаем, что компромисс стоит того. Поскольку меньше типов всегда распределяются вместе, существует меньше способов создать универсальный эксплойт, и злоумышленники будут чаще вынуждены перераспределять типы, которые могут жить в другом блоке. Такой эксплойт просто не сработает, если во время конкретной загрузки два типа окажутся в разных блоках. Еще одно преимущество рандомизированного распределения по блокам заключается в том, что оно максимизирует ценность строго свободной семантики kalloc_type. Строгое освобождение означает, что вызов kfree_type() ограничивается освобождением только тех адресов, которые принадлежат правильной зоне, что затрудняет передачу UAF за пределы блока, содержащего уязвимый тип. За счет рандомизации набора типов, принадлежащих данной зоне, пространство недопустимых освобождений неправильного типа, которые не будут пойманы при обычном использовании, становится намного меньше. Средства защиты, основанные на рандомизации, иногда могут быть хрупкими. Например, ASLR часто критикуют за то, что утечка одного значения указателя дает злоумышленнику расположение большого числа интересных объектов, даже не связанных с кодом, содержащим утечку. А многие предложения по усиленному распределителю, которые полагаются на рандомизацию, могут быть побеждены простым распылением распределений, чтобы преодолеть рандомизацию и снова прийти к стабильной модели кучи. Рандомизация здесь другая. Даже если атакующий узнает назначение блока для каждого типа в системе, это покажет только, какие типы замены для данного UAF будут работать; это не позволит добиться успеха для конкретного типа замены, который атакующий выбрал априори, при разработке эксплойта. Атакующий ничего не может сделать во время выполнения, чтобы сделать предпочтительный тип замены выполнимым, если система назначила его в блок, отличное от типа UAF. А поскольку распределение по корзинам происходит случайно для каждой загрузки, типы замены, которые будут работать для данного UAF, не являются постоянными даже на одном устройстве с течением времени, не говоря уже о всей популяции устройств. Мы надеемся, что злоумышленники, желающие создать надежный эксплойт для одного UAF, будут вынуждены приходить в систему с несколькими стратегиями эксплойта, нацеленными на несколько различных типов замены, и что они смогут решать, какую из них использовать, только во время выполнения. Такой выбор стратегии также внесет сложность и нестабильность, если он будет опираться на утечку информации. Мы ожидаем, что создание такого эксплойта потребует значительно больше усилий, чем предыдущие методы, использующие только один кандидат на замену. И эти усилия по атаке придется повторять заново для каждой уязвимости, поскольку они будут зависеть от того, в каком именно блоке был найден уязвимый тип.

Распределение сигнатур

Помимо указателей, скрывающихся в подмапе данных, о которых мы поговорим в следующем разделе, наибольший риск для kalloc_type заключается в том, что злоумышленник может найти полезный тип перераспределения в той же группе сигнатур, что и тип UAF. Такие пары полностью обходят рандомизированную изоляцию типов в kalloc_type, что делает возможным создание надежных эксплойтов. Таким образом, важно понимать распределение типов C/C++ ядра по сигнатурам и группам сигнатур. В кернелкэше iOS 16 beta 1 (сборка 20A5283p) для iPhone 13 Pro было 3574 именованных непеременных типов, 1822 отдельных сигнатур и 1482 групп сигнатур. Некоторые из 3574 именованных типов были дубликатами из-за ограничений компилятора, особенно типы, указанные через typeof(), поэтому истинное количество отдельных типов в C/C++ было меньше. Средняя группа сигнатур содержала одну сигнатуру, а средняя - 2,4 сигнатуры. Группа сигнатур, содержащая наибольшее количество сигнатур, была "1211" с 228 типами.

Signature groupSize classNumber of signatures
121132228
12111112122212121111160149
12111148102
111673
121657
121111121222121211111111111122448
121111216440
121211116437
11113234
12213234

Хотя группа "1211" была экстремальной, всего несколько кекстов внесли основную часть типов: 54 имени типов в этой группе сигнатур начинались с IO80211, 53 - с AppleBCMWLAN и 22 - с RTBuddy. Между тем, следующая по численности группа сигнатур, "1211111212122212121111", была группой IOService, общего базового класса, наследуемого многими драйверами в ядре. А шестая по численности группа сигнатур в основном содержала подклассы IOUserClient.

Из 1482 групп сигнатур 1058 (71%) содержали одну сигнатуру. Однако нас действительно интересует размер группы сигнатур, испытываемых случайным типом.

Signature group size123456
Number of signature groups105819589391915

Случайно выбранный тип принадлежал к группе сигнатур с медианой в четыре сигнатуры и средним значением в 32,5 сигнатуры. Это означает, что если бы уязвимости были распределены равномерно среди типов, мы бы ожидали, что половина уязвимостей будет находиться в типах, по крайней мере, с тремя другими типами, которые гарантированно всегда будут выделены. 29,6% типов принадлежали к группе сигнатур, содержащей только один тип, что является наилучшим сценарием для устранения стабильных пар.

Decile10%20%30%40%50%60%70%80%90%
Signature group size1122481437149


1482 группы сигнатур были распределены по 200 блокам. По результатам восьми прогонов медианный бакет содержал 11 типов, средний бакет содержал 18 типов, а наиболее распространенными размерами бакета были 9, 10 и 11 типов. Минимальный размер блока был равен трем (в классе размеров 32768), а медиана максимального размера блокра в 8 прогонах составила 270 (в классе размеров 32).

Слабые места в kalloc_type

Любой анализ средств защиты будет неполным без тщательной оценки их слабых мест. В этом разделе мы обсудим некоторые из известных ограничений kalloc_type, наиболее существенными из которых являются коллизии сигнатур и указатели в подкарте данных.Как обсуждалось в предыдущем разделе, мы ожидаем, что одна из главных слабостей kalloc_type связана с отдельными типами C/C++, которые всегда выделяются вместе. Случайно выбранный непеременный тип имеет медиану из трех других типов в своей группе сигнатур, поэтому это кажется возможным путем для надежной эксплуатации UAF под kalloc_type.
В целом, типы с одинаковой сигнатурой должны усложнять написание эксплойта по сравнению с типами с разными сигнатурами. Это связано с тем, что перекрытие указателя/данных действительно полезно для построения произвольных примитивов чтения/записи: атакующий хочет указать адрес для чтения или записи как произвольное значение, что означает, что значение, скорее всего, попадает в ядро в виде данных. Несмотря на это, все еще будут существовать жизнеспособные методы эксплуатации UAFs среди типов с одинаковой сигнатурой. Например, мы ожидаем, что злоумышленники будут использовать путаницу между указателями. Наследование C++ в ядре делает столкновения сигнатур более распространенными, поскольку классы-братья имеют общий префикс, унаследованный от родителя. Можно ожидать, что в драйверах IOKit будет больше типов с коллизиями сигнатур, чем в ядре XNU, что делает UAFs в IOKit более привлекательными. Тем не менее, для объектов C++ вызовы виртуальных методов отправляются через указатель vtable с подписью PAC, что уменьшает набор эксплуатируемых путаниц типов. Коллизии сигнатур также являются особой проблемой для массивов указателей. Несмотря на то, что изоляция массивов указателей имеет значительные преимущества, тот факт, что все они имеют одинаковую сигнатуру, означает, что мы не можем выполнить дальнейшую изоляцию типов в этой группе. Такие интересные объекты ядра, как резервные хранилища OSArray, массивы внестрочных портов Mach, массивы IOSurfaceClient и другие, могут детерминированно перераспределять друг друга. К счастью, прямые UAF на массивах встречаются редко, потому что они имеют тенденцию иметь одного владельца; это гораздо более серьезная проблема для пространственной безопасности и примитивов эксплуатации второго порядка.
Отдельным ограничением этой простой схемы подписи "12" является то, что она рассматривает все значения, не являющиеся указателями, как неконтролирующие данные, хотя многие такие значения все еще используются для управления программой. Например, размеры, смещения, индексы, физические адреса и счетчики ссылок принципиально более похожи на указатели, чем произвольные данные под управлением злоумышленника. Поскольку таким полям управления, не связанным с указателями, присваивается сигнатура "2", как и данным, контролируемым злоумышленником, UAF в типе со сталкивающимися сигнатурами потенциально может быть превращен в проблему пространственной безопасности, например, путем перекрытия поля размера с полем данных, контролируемым злоумышленником. Современные эксплойты, как правило, предпочитают создавать перекрытия указателя/данных, а не перекрытия без указателя/управления/данных, вероятно, потому что использование перекрытий указателя/данных приводит к созданию эксплойта с меньшим количеством шагов и, следовательно, меньшей нестабильностью. Но перекрытия без указателей/управления/данных также могут приводить к жизнеспособным стратегиям эксплуатации, и такие перекрытия станут относительно более распространенными в kalloc_type.
Аналогично, у нас все еще есть проблема с указателями, скрывающимися в полях, типизированных для данных, таких как uint64_t. Семантика __kernel_ptr_semantics дает нам возможность переклассифицировать такие поля в указатели без изменения типа поля, но найти все такие случаи - непростая задача. В обычных зонах типа kalloc_type наличие указателя, скрывающегося под данными, является проблемой в основном для сигнатурных коллизий: вместо перекрытия указатель/указатель атакующий снова получает классическое перекрытие указатель/данные.
Однако проблема указателей, маскирующихся под данные, гораздо более существенна в подмапке данных, поскольку мы не так сильно защищаем подмапку данных, исходя из предпосылки, что контроль содержимого этих распределений не является полезным для злоумышленника. Но если UAF в подкарте данных можно использовать для получения контроля над полем указателя, то эксплойту вообще не нужно будет бороться с kalloc_type. Как и в случае с коллизиями сигнатур, это кажется правдоподобным путем к созданию надежного UAF-эксплойта.
В настоящее время также не предусмотрена защита распределений, состоящих только из данных и управления без указателей. Эти типы рассматриваются как типы, состоящие только из данных, поэтому они направляются в субмапу данных так же, как и полностью контролируемые злоумышленниками выделения. Это делает их более легкой мишенью, чем типы kalloc_type.
В силу своей конструкции, объединения являются еще одним способом создания перекрытий указателя/данных, на этот раз в рамках одного типа. Мы проделали большую работу по устранению существующих союзов, содержащих поля указателей и данных, и по состоянию на iOS 16 beta 1 в kernelcache было всего 36 именованных непеременных типов - что соответствует 31 действительно отдельному типу - содержащих объединения указателей и данных. Мы больше не считаем такие объединения большим риском для kalloc_type, хотя идея о том, что часть памяти может содержать объекты разных типов, работает против целей изоляции типов.
Последнее слабое место в kalloc_type, которое мы обсудим, - это риск пропущенного внедрения. Kalloc_type не волшебный; для того чтобы он был эффективным, его нужно правильно внедрить. И все участки выделения, которые не приняли Kalloc_type, будут направляться в кучу по умолчанию, которая не получает такого же уровня изоляции типов. Чтобы снизить этот риск, мы намерены продолжить внедрение kalloc_type в ядро и в конечном итоге полностью отказаться от кучи по умолчанию.

Обеспечение устойчивости и продолжение работы

По крайней мере, не менее важно, чем создание первоначального средства защиты, обеспечить устранение всех пробелов, оставшихся на момент отправки, и поддерживать это средство защиты на уровне, который стоит той безопасности, которую оно обеспечивает. В этом разделе мы описываем наши текущие и предстоящие планы по максимально возможному устранению вышеуказанных недостатков и обеспечению того, чтобы свойства безопасности kalloc_type не ухудшались со временем.
Мы создали инструментарий компилятора на основе clang-tidy для автоматизации крупных внедрений API типизированных аллокаторов как в XNU, так и в расширениях ядра. Этот инструментарий позволил нам масштабироваться до почти 300 расширений ядра гораздо быстрее, чем это было бы возможно в противном случае, а также значительно сократить количество неправильных и пропущенных внедрений.
Но нам также нужно было убедиться, что будущие изменения кода и новые проекты правильно и тщательно примут новый аллокатор. Kalloc_type является более жестким и ограничительным, чем устаревшие API распределения. Если бы новый код мог быть написан с использованием старых API, наша безопасность распределения могла бы со временем ослабнуть.
Именно поэтому мы ввели новое предупреждение компилятора -Wxnu-typed-allocators, чтобы обеспечить корректность и постоянное использование API kalloc_type во всем ядре и всех kexts. Это предупреждение обнаруживает использование нетипизированных API аллокаторов (kalloc(), IOMalloc() и т.д.). Мы также дополнили kalloc_type() и связанные с ним макросы для обнаружения двух других ошибок использования:
  • Сигнатура освобождаемого типа указателя не совпадает с сигнатурой параметра типа, переданного в вызов free.
  • API распределителя данных (например, kalloc_data()) используется для создания распределения для типа, содержащего указатели.
Мы также используем один из представленных нами встроенных модулей Clang, __builtin_xnu_type_summary(), чтобы во время компиляции убедиться, что kalloc_type() не используется для создания распределения переменной длины, состоящего из заголовка, за которым следует массив типов, содержащих только данные. В конечном итоге мы также планируем запретить выделение типов, содержащих объединения указателей и данных. Мы продолжаем исследовать изменения в схеме подписи kalloc_type, включая обработку полей управления, не связанных с указателями, таких как размеры, смещения, количество ссылок и т.д. Мы считаем, что можно увеличить разнообразие сигнатур, не распределяя функционально эквивалентные типы по нескольким блокам. Это позволит нам отличать некоторые формы управления, не связанные с указателями, от потенциально контролируемых злоумышленниками данных, что поможет нам лучше защитить первые и одновременно уменьшить случайное сведение различных типов к одинаковым сигнатурам.
Наконец, мы внесли несколько специфических изменений, чтобы максимально использовать возможности нового аллокатора:
  • Мы разделили ipc_kmsg, чтобы он больше не хранил указатели ядра в подкарте данных для сообщений между пользователями. ipc_kmsg был очень полезен для создания различных примитивов эксплойтов. Это разделение появилось в iOS 16.
  • Мы агрессивно защитили PAC-указатели от типизированных распределений до распределений данных, чтобы минимизировать возможности для путаницы типов второго порядка. Хотя любое перекрытие указатель/указатель потенциально может привести к путанице типов, выделения данных особенно привлекательны по всем причинам, которые мы описали во введении.
  • Мы заставили kfree_type() и другие свободные API обнулять сам освобождаемый указатель, чтобы оппортунистически минимизировать висячие указатели. То, что свободные API обнуляют передаваемые указатели, не полностью устраняет висячие указатели, поскольку вызывающие сайты могут передавать локальную копию указателя. Однако это решение очень быстрое, почти не требует обслуживания и значительно уменьшает количество висячих указателей.

В заключение

В этом посте мы рассмотрели обновления безопасности аллокатора ядра XNU за последние три релиза, уделив особое внимание безопасности временной памяти. Эта работа началась в iOS 14 с внедрения kheaps, разделения данных и секвестрирования виртуальной памяти. Эти изменения заложили основу для kalloc_type в iOS 15, которая добавила рандомизированную изоляцию типов bucketed в распределитель зон, а iOS 16 и macOS Ventura увеличили внедрение kalloc_type во всем ядре XNU. Мы также обсудили свойства временной безопасности kalloc_type, реалистично оценив его сильные и слабые стороны.
Мы надеемся, что исследователи безопасности, изучающие и разрабатывающие средства защиты, найдут в этой статье полезный пример того, что требуется для преобразования такой мощной идеи, как изоляция типов, в реализацию мирового класса, быструю, эффективную с точки зрения памяти и достаточно практичную для внедрения в масштабах миллиарда устройств.
 
Последнее редактирование:


Напишите ответ...
  • Вставить:
Прикрепить файлы
Верх