Фаззинг — очень популярная методика тестирования программного обеспечения случайными входными данными. В сети огромное количество материалов о том, как находить дефекты ПО с его помощью. При этом в публичном пространстве почти нет статей и выступлений о том, как искать уязвимости с помощью фаззинга. Возможно, исследователи безопасности не хотят делиться своими секретами. Тем интереснее рассмотреть эту тему в данной статье.
Я занимаюсь исследованиями безопасности операционных систем и фаззингом несколько лет. Мне нравится этот инструмент, поскольку он позволяет делегировать компьютеру утомительную задачу написания тестов для ПО. При этом способы использования фаззинга могут сильно различаться в зависимости от целей его применения.
В частности, разработчик использует фаззер для своего кода, чтобы найти все имеющиеся в нем ошибки. Поэтому обычно разработчик включает в своем проекте все доступные детекторы ошибок и разбирает все случаи их срабатывания, которые обнаруживаются при фаззинге.
У исследователя безопасности иные цели:
В отличие от разработчика, он интересуется не всеми ошибками в коде, а ищет именно уязвимости. Это ошибки, которые может спровоцировать атакующий, взаимодействующий с поверхностью атаки системы.
Более того, для исследователя безопасности большую ценность представляют ошибки, которые можно сравнительно быстро и надежно спровоцировать в системе.
Наконец, исследователь безопасности стремится найти именно уникальные уязвимости, которые вряд ли найдут его конкуренты. Очень обидно потратить силы и время на анализ сбоя в программе и затем выяснить, что его обнаружил и исправил кто‑то другой.
В данной статье я расскажу, как перечисленные особенности влияют на настройку и использование фаззера.
Для того чтобы статья была конкретной, я рассмотрю свой любимый ядерный фаззер syzkaller. Это известный открытый проект, он используется для динамического анализа ядра во многих операционных системах. Я уже несколько лет использую его для исследования безопасности ядра Linux.
Архитектура фаззера syzkaller
На схеме представлена архитектура syzkaller — см. рис. 1.
Главная часть и основная логика фаззера syzkaller находится в компоненте syz-manager. Он отвечает за управление виртуальными машинами в процессе фаззинга. Также syz-manager работает с набором программ для тестирования ядра, который называется корпус. Он добавляет в корпус новые перспективные программы и удаляет бесполезные. Эти программы по сути являются случайными входными данными для фаззинга ядра. Они написаны на специальном языке syzlang, который задает формат и аргументы системных вызовов Linux.
Если в процессе фаззинга ядро уходит в отказ (возникает kernel crash), то фаззер сохраняет это событие в базу и пытается сгенерировать минимальный репродюсер — самую короткую комбинацию системных вызовов, которая способна вызвать эту ошибку в ядре.
Сам процесс фаззинга ядра происходит внутри виртуальной машины. В ее пользовательском пространстве работают части syzkaller, исполняющие системные вызовы и собирающие метрики покрытия кода ядра по итогу тестирования. Эта информация передается в syz-manager, который использует ее при выборе перспективных программ для фаззинг-корпуса. Это очень эффективная технология, которая называется обратной связью по покрытию (coverage guided fuzzing).
Также при фаззинге Linux очень важны детекторы ошибок в ядре и так называемые санитайзеры. Они нужны для того, чтобы увести ядро в отказ при возникновении нештатной ситуации. Без них возникшая ошибка, например использование памяти после освобождения, не будет обнаружена и фаззинг, по сути, будет бесполезен.
Такова базовая архитектура фаззера syzkaller. А теперь рассмотрим, как адаптировать его для поиска уязвимостей в ядре Linux.
Как искать уязвимости в ядре Linux с помощью фаззинга
Уязвимости в ядре Linux можно разделить на два класса:
Уязвимости, позволяющие выполнить локальное повышение привилегий (Local Privilege Escalation, LPE). При эксплуатации такой уязвимости локальный непривилегированный пользователь становится пользователем root или другим пользователем с повышенными привилегиями в системе.
Уязвимости, приводящие к удаленному выполнению кода в ядре (Remote Code Execution, RCE). При эксплуатации такой уязвимости атакующий, взаимодействующий с Linux‑системой по сети, добивается исполнения произвольного кода в пространстве ядра.
Для того чтобы syzkaller находил исключительно ошибки, потенциально приводящие к LPE, нужна единственная модификация — запуск фаззера внутри виртуальной машины без привилегий администратора. В этом случае системные вызовы будут исполняться под учетной записью непривилегированного пользователя и тестироваться будет только поверхность атаки ядра Linux, что отражено на схеме (см. рис. 2).
Для поиска ошибок, потенциально приводящих к RCE, требуется другой подход: нужен фаззинг сетевых интерфейсов ядра Linux. Это детально описано в отличной статье Андрея Коновалова Looking for Remote Code Execution bugs in the Linux kernel. В ней он показал устройство виртуального сетевого интерфейса TUN/TAP и специального вызова syz_emit_ethernet, которые позволяют фаззеру syzkaller взаимодействовать с сетевым стеком ядра Linux.
Как находить стабильно проявляющиеся уязвимости
Как было сказано выше, для исследователя безопасности большую ценность представляют ошибки, которые можно сравнительно быстро и надежно спровоцировать в системе.
В syz-manager заложена определенная логика, которая срабатывает при обнаружении отказа ядра. Он начинает тестировать весь большой комплект системных вызовов, который спровоцировал ошибку, и методом дихотомии постепенно находит минимальную программу-репродюсер, которая приводит к искомому эффекту. Этот процесс работает нестабильно из-за различных побочных эффектов и состояний гонки в ядре. Поэтому при поиске репродюсера часто бывают ошибки 1-го и 2-го рода.
Чтобы исследователь безопасности не тратил время и силы на разбор нестабильных репродюсеров, ему стоит спроектировать автоматическую систему сортировки результатов фаззинга (отражено на схеме — см. рис. 4). Я тоже разработал такую автоматизацию под свои критерии поиска. Это легко сделать с помощью утилиты syz-repro, которая позволяет несколько раз повторять процесс выявления минимального репродюсера.
Как находить уникальные уязвимости
Перейдем к наиболее интересной части статьи и рассмотрим, как находить уникальные уязвимости, которые с невысокой вероятностью найдут другие исследователи.
Дело в том, что невозможно найти что-то уникальное, пользуясь стандартными инструментами, которые есть у всех. Поэтому вам нужно как-то модифицировать свой процесс фаззинга, чтобы находить уникальные уязвимости.
На представленной схеме (см. рис. 5) я отметил красным цветом и пронумеровал компоненты фаззера syzkaller и ядра Linux, которые должны быть модифицированы для того, чтобы получать уникальные находки.
Самая простая идея — ограничить разрешенные системные вызовы Linux, которые исполняет фаззер. Это можно сделать в конфигурации syzkaller. С помощью этого метода можно сузить поверхность атаки, которая подвергается фаззингу. За счет этого syzkaller может продвинуться глубже в коде ядра и получить большее покрытие в исследуемой подсистеме.
Другой действенный способ найти еще не открытые уязвимости — разработать новые описания ядерного API на языке syzlang. Как было сказано выше, syzlang — это специальный язык, описывающий формат и аргументы системных вызовов ядра. Те из них, которые еще не описаны в syzkaller, не подвергаются фаззинг‑тестированию и поэтому представляют собой интересную цель. Множество уязвимостей было найдено исследователями с помощью этого метода.
Существует множество фаззеров для программ в пользовательском пространстве, и они конкурируют между собой за счет совершенствования механизмов мутации фаззинг‑корпуса и применения символьного исполнения. Эта зона роста актуальна и для syzkaller: изменение движка мутаций влияет на то, какие кодовые пути в ядре затрагивает фаззер. Значит, это может помочь найти уникальные уязвимости. Однако такая модификация фаззера требует глубокого погружения в его устройство.
Более простой способ повлиять на процесс фаззинга — старт со специально подготовленным корпусом. Множество исследований говорят о том, что программы в начальном корпусе (также называемые seeds) оказывают существенное влияние на процесс фаззинга.
Переходим к модифицированию компонентов ядра Linux. Наличие исходного кода у исследователя делает возможным замечательный трюк — доработать ядро Linux так, чтобы оно стало более удобным для фаззинг‑тестирования. Именно так я нашел уязвимость CVE-2019–18 683, для которой затем разработал прототип эксплойта, выполнил ответственное разглашение и разработал исправляющий патч. Эта уязвимость ядра Linux была скрыта за предупреждением (kernel warning), и я нашел ее за счет того, что модифицировал ядро, выключив в нем все предупреждения. Изменение фаззинг‑цели может быть очень эффективным для поиска новых ошибок.
Теперь рассмотрим самый очевидный способ отличаться от конкурентов — использовать еще больше вычислительных мощностей. Чем больше серверов выполняют фаззинг, тем больше виртуальных машин на них запущено, тем больше отказов ядра они обнаруживают. При этом важно, чтобы у исследователя хватало сил и времени на их анализ.
Еще один необычный способ найти уникальные ошибки в ядре Linux — изменить rootfs. Образ файловой системы виртуальной машины не имеет непосредственного влияния на ядро Linux, которое подвергается фаззингу, но порой изменения в rootfs могут дать неожиданный эффект и включить дополнительные API ядра. Именно так я обнаружил уязвимость CVE-2017–2636, для которой также удалось разработать прототип эксплойта и выполнить ответственное разглашение. В том случае я добавил в образ файловой системы VM скомпилированные модули ядра, и при фаззинге ядро автоматически загрузило модуль n_hdlc, в котором затем была обнаружена ошибка, которую я проанализировал.
Довольно сложный, но очень результативный подход — доработка санитайзеров и других средств обнаружения ошибок в ядре Linux. Некоторые типы ошибок остаются незамеченными при фаззинге из‑за того, что они не отслеживаются детекторами и, следовательно, не приводят к отказу ядра (kernel crash). В качестве примера можно привести выход за границу поля внутри ядерного объекта sk_buff, который является представлением сетевого пакета в памяти ядра Linux. Разработка детектора для такого класса ошибок позволила бы выявлять при фаззинге уязвимости, потенциально приводящие к RCE.
Еще один подход, распространенный в разработке фаззеров для пользовательского пространства, — направленный фаззинг, при котором ограничивается множество тестируемого кода. То же самое можно сделать при фаззинге ядра Linux. Для этого нужно настроить cover_filter в syzkaller или модифицировать ядерную подсистему kcov, чтобы собирать покрытие только для подсистемы Linux, в которой мы ищем уязвимости.
Заключение
Поделившись этими идеями, в заключение расскажу короткую историю.
В 2021 году я нашел в ядре Linux уязвимость CVE-2021-26708. При исследовании методов ее эксплуатации мне нужен был особый heap-spraying-примитив — ядерный объект, размер и содержимое которого может контролировать атакующий из пользовательского пространства. Ни один из публично известных эксплойт-примитивов не подходил. После долгого изнурительного чтения исходного кода ядра я решил делегировать эту задачу компьютеру и использовать фаззинг для поиска нужного объекта. Так я изобрел heap spraying с помощью ядерного объекта msg_msg, который затем стал очень популярным в исследовательском сообществе.
Поэтому фаззинг — это замечательный инструмент, который может быть полезен исследователю безопасности не только для поиска уязвимостей. При этом фаззинг требует от исследователя готовности рискнуть своим временем и вычислительными мощностями своих серверов.
Спасибо за внимание!