Теневые стеки для пользовательского пространства

Стек вызовов (call stack) является излюбленной целью злоумышленников, пытающихся скомпрометировать запущенный процесс; если злоумышленник найдет способ перезаписать адрес возврата в стеке, то он сможет перенаправить управление на код по своему выбору, что приведет к ситуации, которую лучше всего можно описать фразой «game over». Именно поэтому для защиты стека прикладывается очень много усилий. Одним из самых многообещающих методов является “теневой стек” (shadow stack); как следствие, множество различных процессоров должным образом поддерживают теневые стеки. С поддержкой защиты программ пользовательского пространства (user-space) с помощью теневых стеков дела обстоят не так хорошо; в настоящий момент она является предметом обсуждения в сообществе разработчиков ядра, но добавить эту функцию сложнее, чем может показаться на первый взгляд. Среди прочего, подобные патчи существуют уже достаточно долго, чтобы у них самих появились собственные проблемы с обратной совместимостью.

Основы теневых стеков

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

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

Процессоры Intel, помимо всего остального, обеспечивают такую ​​поддержку. Если теневой стек был подключен (что является привилегированной операцией), помещение адресов возврата в этот стек и сравнение при возврате функции выполняются самим процессором. Такой теневой стек обычно не доступен для записи из приложения (кроме как с помощью инструкций вызова функции и возврата) и, следовательно, не может быть скомпрометирован злоумышленником. Аппаратная поддержка также предполагает наличие специального «токена восстановления» (restore token) для теневого стека, который, среди прочего, гарантирует, что два процесса не смогут использовать один и тот же теневой стек — ситуация, которая, опять же, облегчила бы атаку.

Поддержка теневых стеков пользовательского пространства

Актуальная версия патчей для поддержки теневых стеков была опубликована Риком Эджкомбом (Rick Edgecombe); большая часть самих патчей была написана Ю-ченг Ю (Yu-cheng Yu), которым уже было опубликовано множество более ранних версий этой работы. Для подключения этой функции требуется 35 нетривиальных патчей, но и они еще не решают проблему полностью. Кто-то может задаться вопросом, почему это так сложно, ведь теневые стеки производят впечатление функционала, который почти всегда может быть просто проигнорирован большей частью кода. Но жизнь никогда не бывает такой простой.

Как и следовало ожидать, ядро ​​должно содержать код для управления теневыми стеками пользовательского пространства. Сюда входит включение этой функции на уровне процессора и передача ее для каждого конкретного процесса. Каждому процессу требуется собственный теневой стек с соответствующим токеном восстановления, а затем на него должен быть нацелен (привилегированный) регистр с указателем на теневой стек. Также нужно обрабатывать ошибки: как обычные отказы страниц (page faults), так и такие вещи, как обнаружение нарушения целостности (integrity-violation traps). И еще больше информации, которой нужно управлять при переключении контекста. Это все довольно стандартно для новой функции такого рода.

Память, выделенная под сам теневой стек, должна обрабатываться особым образом. Она принадлежит пользовательскому пространству, но код пользовательского пространства обычно не должен иметь права производить в нее запись. Также процессору нужно как-то распознавать память, выделенную для теневых стеков, поэтому они должны быть помечены специальным образом в таблице страниц, и здесь все становится немного интереснее. В каждой записи в таблице страниц (page-table entry — PTE) отведено несколько битов для описания применяемых средств защиты и других различных типов состояния, но архитектура x86 не включает бит “это страница теневого стека”. Но все-таки есть некоторые биты PTE, зарезервированные для использования операционной системой; Linux не использует их все и мог бы выделить один для этой цели, но очевидно, что некоторые другие операционные системы не располагают свободными битами PTE, поэтому присвоение одного из них в наших целях не приветствуется.

Решение, к которому пришли инженеры по аппаратному обеспечению, вполне можно назвать хаком. Если бит разрешения записи (write-enable) страницы сброшен (указывает на то, что запись невозможна), но установлен ее грязный (dirty) бит (указывает, что на нее была произведена запись), процессор сделает вывод, что рассматриваемая страница является частью теневого стека. Это комбинация параметров при стандартной эксплуатации не имеет смысла, поэтому, очевидно, что данное решение выглядело хорошим выходом из этой ситуации.

К сожалению, разработчики ядра Linux уже успели прийти к такому же выводу много лет назад, поэтому у Linux есть своя интерпретация этой комбинации битов PTE. Именно так ядро ​​помечает страницы для копирования при записи (copy-on-write). Отсутствие доступа для записи вызовет ловушку, если процесс попытается записать страницу; наличие грязного бита укажет ядру сделать копию страницы и предоставить процессу к ней доступ для записи. И все работает как надо, если только процессор не применяет собственную интерпретацию этой комбинации битов. Так что большая часть патчей, о которых мы говорили выше, сосредоточена на захвате одного из этих неиспользуемых битов PTE для нового флага _PAGE_COW и обеспечении его использования управляющим памятью кодом.

Сложности, которые приносят теневые стеки на этом не заканчиваются. Если процесс вызывает clone(), для дочернего процесса должен быть выделен новый теневой стек; ядро выполняет эту задачу автоматически. Сигналы, как всегда, добавляют свои сложности, так как они уже включают в себя различные манипуляции со стеком. Дела обстоят еще хуже, если процесс установил альтернативный стек для обработчиков сигналов с помощью sigaltstack() — до такой степени, что текущий набор патчей вообще не обрабатывает этот случай. Именно из таких деталей (и не только) и формируется длинная серия патчей.

Проблемы с ABI

Использование теневых стеков должно быть полностью прозрачным для большинства приложений; в конце концов, разработчики редко продумывают все нюансы касательно стека вызовов в своей работе. Но всегда будут приложения, которые делают со своими стеками достаточно сложные вещи, начиная с многопоточных программ, которые явно управляют областью стека для каждого потока. Другие программы могут помещать в стек свои собственные специально созданные “переходники” (thunks). Без особой осторожности все эти приложения будут ломаться, если они внезапно будут настроены для работы с теневым стеком. Такого рода массовая регрессия, как правило, делает средства защиты непопулярными, поэтому были приняты различные меры, чтобы избежать этого.

Тщательно продуманный план, к которому в результате пришли разработчики, заключался в том, чтобы пометить приложения (специальным свойством в разделе ELF .note.gnu.property), которые готовы к работе с теневыми стеками. Приложения, которые не предполагают никаких замысловатых манипуляций со стеком, могут быть просто перестроены и впоследствии запущены с теневыми стеками. Для более сложных случаев был определен набор операций arch_prctl(), позволяющий явно манипулировать теневыми стеками. Библиотека GNU C была расширена, чтобы использовать эти вызовы для правильной настройки среды при запуске приложения, а ядро ​​включало теневые стеки всякий раз, когда запускалась соответствующим образом помеченная программа. Некоторые дистрибутивы, включая Fedora и Ubuntu, создают свои двоичные файлы для теневых стеков; все, что им нужно, — это правильно оборудованное для работы с дополнительной степенью защиты ядро​​.

Всегда опасно выпускать код, использующий функции ядра, которые еще не приняты и не замержены; теневые стеки оказываются ярким примером почему. Согласно сопроводительному письму к текущей серии, API arch_prctl() был “заброшен по причине своей странности”. Но исполняемые файлы, готовые к работе с теневыми стеками, которые уже были развернуты в системах по всему миру, создавались с расчетом на присутствие этого API, странного или нет; если ядро ​​учитывает маркировку в файле ELF и подключает теневые стеки для этих программ, некоторые из них не будут работать. Это заставит системных администраторов по всему миру отключить теневые стеки по крайней мере до 2040 года, что скорее сведет на нет все усилия в рамках этого замысла.

Одним из самых очевидных способов обхода этой проблемы было бы не учитывать текущий маркер ELF для теневых стеков и вместо этого создать новый маркер для маркировки двоичных файлов с использованием интерфейса, фактически поддерживаемого ядром. Однако решение, которое было принято, заключалось в полном отстранении ядра ​​от работы по распознаванию двоичных файлов с поддержкой теневых стеков и позволить библиотеке C позаботиться об этом. Таким образом, если эта версия ABI будет принята, ядро включит теневые стеки только если пользовательское пространство само запросит это.

Предлагаемый интерфейс

Полный контроль над функциональностью теневого стека должен осуществляться с помощью вызова arch_prctl() (как это ни странно):

status = arch_prctl(ARCH_X86_FEATURE_ENABLE, ARCH_X86_FEATURE_SHSTK);

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

Хотя большинству приложений не нужно беспокоиться о теневых стеках, некоторые из них должны иметь возможность создавать таковые. Приложения, использующие makecontext() и подобные ей функции, являются ярким примером. Для создания теневого стека требуется поддержка ядра; связанная память должна включать токен восстановления, а специальные биты страницы должны быть установлены, как было описано выше. Для этой операции есть новый системный вызов:

void *map_shadow_stack(unsigned long size, unsigned int flags);

Желаемый размер стека передается как size. Для параметра flags есть только одно значение: SHADOW_STACK_SET_TOKEN, чтобы запросить сохранение токена восстановления в стеке. Возвращаемое значение в случае успеха является адресом основания этого стека.

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

Приложениям, выполняющим особо сложные задачи, может потребоваться возможность записи в теневой стек. Это обычно не разрешено по очевидным причинам, но, как отметил Эджкомб, это “чересчур ограничивает любые приложения, которые потенциально могут захотеть делать какие-либо экзотические вещи за счет небольшой безопасности”. В таком экзотическом случае можно включить другую функцию (LINUX_X86_FEATURE_WRSS) с помощью arch_prctl(); это, в свою очередь, включает инструкцию WRSS, которая может записывать в память теневого стека. Прямая запись в эту память путем разыменования указателя в этом случае по-прежнему запрещена.

Что дальше?

Эта работа не так уж нова; ее ранняя версия была рассмотрена в этой статье 2018 года. В предыдущем воплощении набор патчей для теневого стека дошел до 30-й версии. Другая половина работы по обеспечению целостности потока управления (непрямое отслеживание ветвлений), которая дошла до 29-й версии, на данный момент отложена (хотя Питер Зийлстра (Peter Zijlstra) как раз представил отдельную реализацию). Есть надежда, что с новым разработчиком, возглавляющим работу, сокращением объема и некоторыми запрошенными изменениями, эта работа, наконец, сможет продвинуться в основную ветку.

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

Также был отдельный сабтред, посвященный проблемам с  Checkpoint/restore in user space (CRIU), которая использует множество закулисных трюков, чтобы выполнить свою работу. Часть процесса создания контрольной точки включает в себя внедрение кода-“паразита” в процесс, для которого создается контрольная точка, получение необходимой информации, а затем выполнение специального возврата из паразита для возобновления нормального выполнения. Это как раз тот вид вмешательства в поток управления, для предотвращения которого предназначены теневые стеки. Обсуждались различные возможные решения, но на данный момент ничего не вылилось в конкретный код. Решение этой проблемы также кажется необходимым, прежде чем можно будет замержить теневые стеки; как сказал Томас Гляйкснер (Thomas Gleixner): “Мы не должны ломать системы CRIU обновлениями ядра”.

Наконец, диапазон поддерживаемого оборудования почти наверняка потребуется расширить. Некоторые процессоры AMD реализуют теневые стеки, очевидно, вполне совместимым образом, но в этом наборе патчей поддерживаются только процессоры Intel; в качестве причины указывается недостаток тестирования. Очевидно, что этим необходимо заняться, чтобы работа продолжалась. Теневые стеки также не поддерживаются в 32-разрядных системах; исправить это может быть сложно, и неясно, действительно ли существует мотивация для выполнения этой работы. Тем не менее, с поддержкой 32-битных систем или без нее, очевидно, что еще предстоит проделать большую работу, прежде чем этот код войдет в основную ветку. Не следует ожидать его появления в ближайшем будущем.


Приглашаем всех желающих на открытый урок «Введение в docker». На занятии мы рассмотрим основы контейнеризации и ее отличие от виртуализации, плавно перейдем к рассмотрению самого популярного на данный момент инструмента контейнеризации Docker — узнаем из каких основных компонентов и сущностей он состоит, и как они взаимодействуют между собой. Регистрация по ссылке.


ссылка на оригинал статьи https://habr.com/ru/company/otus/blog/658491/

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *