DevSecOps: организация фаззинга исходного кода

09.09.2020

Узнав результаты голосования, проведённого среди читателей нашего блога на habr, мы решили подробно обсудить вопрос организации фаззинга. Этой теме мы посвятили доклад на онлайн-встрече по информационной безопасности Digital Security ON AIR, в котором поделились опытом в DevSecOps. Запись доклада можно посмотреть на нашем Youtube-канале. Если же вы предпочитаете текстовый формат, эта статья для вас.

Фаззинг

Введение

Относительно недавно вышла замечательная книга Building Secure and Reliable Systems от инженеров компании Google. Её главным лейтмотивом является мысль о том, что безопасность и надёжность систем и ПО тесно взаимосвязаны. Публикация подобного материала одним из крупнейших лидеров IT-рынка отлично демонстрирует, что безопасность становится неотъемлемой частью как процесса разработки, так и всего жизненного цикла системы или ПО.

Эффективно ли тестирование?

Чтобы сделать безопасную и стабильную систему, её нужно тестировать. К сожалению, писать идеальный код, не требующий доработки, не может никто. Поэтому в написанном коде априори есть ошибки и уязвимости. Как и в других сферах жизни, понимание этого приходит лишь с опытом. Аналогично в IT в целом и в разработке в частности: каждая компания или команда может находиться на своем уровне профессиональной зрелости. Какие-то подходы на определённом этапе будут просто недоступны для них. Так, если не внедрена непрерывная интеграция, то и о нормальном тестировании речи быть не может, не говоря уже о тестировании безопасности во время разработки. Так что, если вы не внедрили непрерывную интеграцию (CI), мы надеемся, что после прочтения нашей статьи вы начнёте делать шаги в нужном направлении. Далее мы предполагаем, что у вас есть настроенный и функционирующий CI, на базе которого и будут предлагаться улучшения.

CI/CD нужны не только для автоматизированных тестов, но и в глобальном плане для того, чтобы соответствовать требованиям времени. Каскадная модель разработки, согласно которой этапы проектирования, разработки и тестирования происходят строго последовательно, постепенно уходит в прошлое или остается лишь в определенных нишах. Сейчас очень важна скорость разработки. Важно, чтобы каждая часть ПО могла развиваться независимо от других частей. Таким требованиям соответствует метод непрерывной разработки. А непрерывной разработке нужно непрерывное тестирование.

Но перед тем, как продолжить, нам хотелось бы напомнить, что чем раньше найдена проблема, тем проще и менее затратно её исправить как во временном, так и в денежном эквиваленте. С точки же зрения обнаружения уязвимостей, это ещё и временные затраты на доставку патчей до ваших клиентов, а также их своевременная установка. Ведь закрытие неизвестной уязвимости нельзя заранее запланировать. А отвлекать разработчиков по малейшему поводу от их основной деятельности — разработки продукта — значит терять время и деньги. И ваши клиенты не будут рады часто прилетающим экстренным патчам. Равно как и разработчики 🙂

securitycompass.com

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

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

Что такое фаззинг?

Для начала небольшой исторический экскурс. Сам термин "фаззинг" появился впервые около 30 лет назад в работе An Empirical Study of the Reliability of UNIX Utilities. С его появлением связана одна интересная легенда. В 1988 удар молнии исказил передаваемые по линии данные, что привело к падению утилиты автора исследования. После этого уже профессор Брет Миллер вместе со своими студентами сделал полноценное исследование в данном направлении и на одном из семинаров представил программу fuzzer. Данная программа предназначалась как раз для тестирования ПО. Сам Брет Миллер так описывал свой подход: “Наш подход не является заменой формальной верификации или тестирования. Скорее, это легковесный механизм для поиска ошибок в системе и повышения её надёжности”. Официальное же определение фаззинга звучит так:

Фаззинг — это техника автоматизированного тестирования, при которой на вход программе подаются специально подготовленные данные, которые могут привести её к аварийному состоянию или неопределённому поведению.

Сегодня мы сконцетрируемся на использовании фаззинга для кода на таких языках программирования, как С и С++, потому что:

  • в ПО на таких языках чаще находят бинарные уязвимости;
  • эффективные средства поиска уязвимостей, такие как libFuzzer и AFL, ориентированы, в первую очередь, на языки C и C++;
  • огромное количество программ и библиотек написано на C и C++.

Тем не менее все указанные наработки можно использовать без особых проблем для ПО на Rust или Go.

К сожалению, до сих пор в индустрии нет чётких определений и классификаций фаззеров. На рисунке ниже вы можете видеть попытку описать существующие вариации популярных фаззеров.

The Art, Science, and Engineering of Fuzzing: A Survey

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

1. По знанию о входных данных:

  • Black-box — мы ничего не знаем про формат данных. Нужно реверсить
  • Gray-box — что-то о формате мы выяснили, но полная информация о нём нам всё ещё неизвестна
  • White-box — у нас есть спецификация. Мы располагаем всеми сведениями о формате данных

2. По цели, которая будет подвергнута фаззингу:

  • source-based — у нас есть исходники проекта
  • binary-based — у нас нет исходников проекта

3. По наличию обратной реакции от тестируемого приложения:

  • feedback driven — есть обратная реакция
  • not feedback driven — нет обратной реакции

4. По операциям, которые будут совершаться над входными данными:

  • генерационные
  • мутационные
  • комбинированные

Стоит отметить, что часто binary-based фаззеры называют black-box, а source-based — gray- или white-box. В данной статье нас в первую очередь интересует source-based фаззинг с обратной связью, поскольку мы строим DevSecOps для разработки собственного продукта или анализа opensource-проектов, а это значит, что исходный код для него у нас есть.

Фаззинг всё ещё популярен?

Кто-то может задаться вопросом: Если фаззингу уже более 30 лет (согласно некоторым источникам и с учетом прообраза этого метода тестирования, и все 70), то почему только в последнее время он стал так популярен?

В начале тысячелетия Microsoft в рамках описания создания Secure Development Lifecycle выпустили несколько статей, посвящённых тематике фаззинга, но тогда речь в основном шла о black-box-фаззинге. Однако в 2013 году фаззинг получил вторую жизнь, благодаря созданию feedback-driven fuzzing, который позволил достичь новых высот в этой сфере. Важную роль также сыграло появление простых и понятных инструментов, о которых мы расскажем далее.

Feedback-driven fuzzing — это вид фаззинга, при котором фаззер изменяет входные данные так, чтобы их обработка затрагивала как можно больше участков кода программы. Работа таких фаззеров возможна, благодаря их способности реагировать на отклик (feedback) программы. Обычно таким откликом является покрытие кода. Метрики покрытия кода отслеживают суммарное количество выполненных строк кода, базовых блоков, количество сделанных условных переходов. Задача фаззера — генерировать данные, которые приводят к увеличению покрытия кода.

Одним из наиболее популярных feedback-driven фаззеров является American Fuzzy Lop от Michael Zalewski. AFL был создан в 2013 году и стал главным двигателем прогресса в современном фаззинге.

Про AFL уже написан ряд подробных статей, но всё же повторим, как он работает:

  1. В исполняемый файл добавляется инструментация для сбора информации о покрытии кода. Информация будет сохраняться в shared bitmap;
  2. AFL запускает программу и доходит до функции, которую нужно профаззить. Здесь он сохраняет своё состояние через системный вызов fork;
  3. Далее происходит цикл "мутация данных — запуск — откат". При этом данные мутируются с учётом покрытия кода, получаемого от программы.

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

AFL оказался чрезвычайно эффективным при фаззинге различных программ. Это и сделало его знаменитым. Он был добавлен в Google Summer of Code (GSoC), а вокруг него сформировалось большое сообщество. Многочисленные энтузиасты предлагают собственные модификации AFL, которые по разным причинам не были приняты в основную ветку (например, существуют вариации для других ЯП). Об этом можно узнать подробнее в ещё одной нашей статье, посвященной различным вариациям AFL фаззера. Ещё хотелось бы заметить, что создатель AFL отошёл от дел и до недавнего времени проект почти не обновлялся, из-за чего многие стали использовать его активно поддерживаемый форк — AFL++.

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

В итоге так и случилось. В 2015 году появился libFuzzer — один из подпроектов LLVM. LibFuzzer позволяет реализовать feedback-driven in-memory фаззинг и имеет все преимущества, характерные для средств фаззинга исходного кода:

  • позволяет создавать отдельные фаззеры для каждой функции из рабочего проекта;
  • использует санитайзеры;
  • может быть интегрирован в проект через toolchain.

Статический анализ

Прежде чем обратиться непосредственно к Continuous Fuzzing, скажем пару слов об анализаторах кода и фаззерах. Для тех, кто думает, нужно ли это всё, если те или иные анализаторы кода уже используются в процессе разработки.

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

Фаззинг относится к динамическому тестированию. Он обеспечивает покрытие кода в зависимости от качества предоставленных тестовых данных и собственных алгоритмов мутации тестов. Все его находки — это точно проблемы, которые можно сразу воспроизвести.

Статический и динамический методы тестирования прекрасно дополняют друг друга — гибридный подход самый лучший. Каждый из них имеет свои преимущества и недостатки, и только вместе они дают лучший результат.

На эту тему есть отличная статья "How to Prevent the next Heartbleed", в которой автор сравнил эти подходы для улучшения качества openssl на примере нашумевшей уязвимости heartbleed. И именно подход, объединяющий анализ кода и фаззинг, помог выявить проблему с heartbleed. А обычное покрытие кода тестами, пусть и 100% по всем веткам разработки, оказалось не столь эффективным.

Непрерывный фаззинг в непрерывной разработке

Что же такое Continuous Fuzzing? Это подход к организации процесса. Иными словами, его эффективность будет зависеть от правильности организации процесса разработки в вашей компании. Все инструменты для организации такого процесса уже давно созданы — осталось лишь правильно встроить их в свою систему.

К сожалению, многие разработчики и QA-тестировщики не так пристально следят за тенденциями в мире информационной безопасности. Поэтому для них лучше всего сделать так, чтобы процесс тестирования безопасности был максимально простым и прозрачным. Он не должен требовать от них особых знаний в этой сфере и сильно влиять на их трудовые будни. Правильно встроенный в процесс разработки continuous fuzzing не отвлекает и работает достаточно просто: написал код, сделал тест, подготовил тестовые данные — и всё. Остальное сделает система. Настроенная среда разработки и отлаженные процессы будут служить вам годами, а разработчики — приходить и уходить. Таким образом, лучше всё автоматизировать и не зависеть от конкретных людей.

Крупные компании уже не первый год настраивают у себя процесс фаззинга и развивают используемые инструменты. Этот подход уже множество раз доказал свою эффективность. Одна только статистика от Google ошеломляет числом найденных за время ее работы уязвимостей. По данным на 2019-й год, за все время существования ClusterFuzz было обнаружено около 16000 ошибок в Chrome и 11000 ошибок в более чем 160 популярных проектах с открытым исходным кодом.

Для организации фаззинга большие компании используют разный инструментарий. Выбор инструментов зависит от того, какие цели преследуются фаззингом, а также насколько безболезненно возможно внедрить этот инструментарий в уже существующий процесс разработки. В основе ClusterFuzz от Google лежат LibFuzzer, AFL и HonggFuzz (создан на базе LibFuzzer’a). Компания Microsoft недавно сделала публичным свой проект Springfield, превратив его в полноценный SaaS. Mozilla, помимо уже упомянутых фаззеров, разработала свой фреймворк для быстрого построения фаззеров (Grizzly) и дашборд для агрегации результатов фаззинга. Github не отстает. Так, он интегрировал ossfuzz в свою систему.

Вы, конечно, можете сказать, что это всё довольно большие компании, которые могут выделить значительные "лишние" мощности на "генерацию случайных данных". Но что мы видим? Несмотря на работу ClusterFuzz’a, та же компания Google активно поддерживает сторонних разработчиков и исследователей безопасности, позволяя запускать написанные ими фаззеры на своих мощностях. А если в процессе были обнаружены баги, за каждый из них выплачивается денежное вознаграждение. Всё-таки предварительный анализ и творческий подход исследователя безопасности позволяют найти новые векторы и уязвимости там, где "бездушная машина"заходит в тупик.

Поэтому, чтобы начать строить процесс Continuous Fuzzing у себя, необязательно быть крупной компанией, как тот же Google. Для этого не нужно крупных кластеров или мейнфреймов — стартовые требования для внедрения Continuous Fuzzing минимальны. Речь о них пойдёт ниже, так как мы в своих проектах чаще используем значительно меньшие мощности и при этом успешно находим уязвимости.

Этапы интеграции фаззинга в проект

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

Выбор инструментов для организации фаззинга

Начнём со списка инструментов, которые необходимы для организации фаззинга, и разделим их условно на 3 большие категории:

1. Составление поверхности атаки (Attack Surface)

  • ПО, в котором удобнее проводить аудит кода (Пример: Sourcetrail)
  • ПО для построения MindMap-схем (Пример: Xmind)

2. Фаззинг

3. Санитайзеры (Sanitizers)

Для составления поверхности атаки необходимо ПО для аудита кода. Например, это может быть sourcetrail, understand, или подойдёт какая-нибудь популярная IDE. Лучше, чтобы ПО для инспекции кода имело возможность автоматического "рисования"; блок-схем. Если у разработчиков уже есть готовые блок-схемы для программ из их проекта, то это значительно облегчит процесс. Если же нет, то искренне соболезнуем. Далее из составленной блок-схемы необходимо извлечь те куски, где тем или иным способом могут попасть на вход данные из недоверенных источников. Так и определяется поверхность атаки. На практике весьма удобно оформлять поверхность атаки в виде майндмап. Поверхность атаки служит не только для составления метрик "безопасности" (code coverage), а также, можно сказать, выступает чек-листом для написания фаззеров.

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

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

Сегодня работу с санитайзерами поддерживают компиляторы GCC и Clang. Настоятельно рекомендуем обратить на них внимание и попробовать, если еще не пробовали.

Мы не зря начали наш список инструментов с тех, что помогают составить Attack Surface. В презентации для FuzzCon, посвящённой 25-летию фаззинга, была представлена следующая схема:

Из неё хорошо видно, что это необходимый предварительный этап для организации качественного фаззинга. Далее идут этапы определения входных данных, непосредственно самого фаззинга и, конечно же, разбора результатов. Давайте рассмотрим каждый из них отдельно.

Определение Attack Surface

Что же стоит за словосочетанием "attack surface" (поверхность атаки)? Возможно, вы уже знаете официальное значение, так как оно так прочно обосновалось в глоссарии, что даже удостоилось отдельной страницы в словаре хакерских терминов журнала Wired. Однако повторимся: если говорить по простому, это возможность получения некорректных данных из недоверенных источников.

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

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

  • Файлы — различные парсеры (XML, JSON), мультимедиакодеки (аудио, видео, графика);
  • Сеть — сетевые протоколы (HTTP, SMTP), криптография (SSL/TLS), браузеры (Firefox, Chrome) и архиваторы файлов (ZIP, TAR).

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

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

В зависимости от структуры проекта количество нод может быть даже меньше, но в данном случае мы имеем следующее разделение:

  • точки входа
  • модули
  • классы, в некоторых случаях модуль это и есть класс
  • непосредственно сами файлы с функциями, которые и будут фаззиться.

Иногда фаззер будет взаимодействовать сразу с несколькими функциями. Например, входные данные поступают только после распаковки файла, поэтому верным решением будет задействовать весь модуль.

Анализ целей для фаззинга

Цель для фаззинга (fuzz target) — это функция, которая принимает на вход данные и обрабатывает их с использованием тестируемого API. Иными словами, это то, что нам необходимо фаззить.

Данный этап заключается в тщательном анализе каждой цели для фаззинга из attack surface. Вот что необходимо узнать:

  1. Аргументы функции, через которые передаются данные для обработки. Нужен сам буфер для данных и его длина, если её возможно определить.
  2. Тип передаваемых данных. Например, документ html, картинка png, zip-архив. От этого зависит то, как будут генерироваться и мутироваться поступающие на вход данные.
  3. Список ресурсов (память, объекты, глобальные переменные), которые должны быть инициализированы перед вызовом целевой функции.
  4. Если мы фаззим внутренние функции компонентов, а не API, то понадобится составить список ограничений, которые накладываются на данные кодом, выполненным ранее. Бывает так, что проверка данных происходит в несколько этапов — это нам тоже следует учитывать.

Анализ opensource-проекта с использованием sourcetrail

Этот этап является самым кропотливым, ведь целей для фаззинга может быть очень много: сотни или даже тысячи! Как раз тут у нас появился термин "реверс исходников", поскольку времени и сил на анализ может быть потрачено примерно столько же, сколько уходит на реверс приличного бинарного файла.

Подбор входных данных

Перед тем, как начать фаззить, необходимо выбрать набор входных данных, который послужит отправной точкой для фаззера — сиды (seeds). По своей сути сиды — это папка с файлами, содержимое которых должно быть валидным, с точки зрения целевой программы или функции. Сиды будут подвергаться многочисленным мутациям в процессе фаззинга и приводить к росту покрытия кода.

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

При создании набора seeds нужно учитывать, что:

  • Его элементы должны влиять на покрытие кода программы. Чем выше покрытие, тем меньше неисследованных мест остаётся в программе.
  • Размер его элементов не должен быть большим, иначе это повлияет на скорость фаззинга. Ведь чем больше длина входных данных, тем дольше функция будет их обрабатывать и меньше запусков программы сможет сделать фаззер за единицу времени.
  • Кейсы в сидах должны быть функционально различными. Сильно похожие данные будут тормозить процесс фаззинга, поскольку фаззер часто будет попадать в места, которые уже были им исследованы. Перед запуском фаззера лучше провести минимизацию данных и убрать всё лишнее.

В процессе фаззинга сиды трансформируются в корпус. Корпус (corpus) -- это набор тест-кейсов, которые привели к росту покрытия кода во время фаззинга целевой программы или функции. Иначе говоря, это те самые интересные инпуты, которые потенциально могут привести к крешу программы. В корпусе сначала содержатся сиды, потом — их мутации, затем — мутации мутаций и т.д. Здесь мы видим, что фаззинг (feedback-driven) — это циклический процесс, где на каждой новой итерации у нас всё больше шансов сгенерировать набор входных данных, который позволит найти уязвимости в программе.

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

Написание фаззеров

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

Допустим, у нас есть функция, которую надо фаззить. Для этого мы скачиваем набор библиотек llvm, среди которых есть компилятор clang и санитайзеры. Далее пишем код для фаззера:


#include "MyAPI.h"

extern "C" int LLVMFuzzerTestOneInput(const uint8_t *Data, size_t Size) {
  MyAPI_ProcessInput(Data, Size);
  return 0;
}

Данный код скомпилируется в отдельный исполняемый файл, где в цикле на вход функции MyAPI_ProcessInput будут подаваться случайные данные Data размером Size. Если фаззер обнаружит ошибку, то на консоль будет выведено сообщение о ней. Настанет время устранить ошибку, а после — запустить фаззинг дальше.

Внимание, спойлер! Сообщение об ошибке от libfuzzer

<!--
INFO: Seed: 2240819152
INFO: Loaded 1 modules   (6 inline 8-bit counters): 6 [0x565e90, 0x565e96), 
INFO: Loaded 1 PC tables (6 PCs): 6 [0x541908,0x541968), 
INFO: -max_len is not provided; libFuzzer will not generate inputs larger than 4096 bytes
INFO: A corpus is not provided, starting from an empty corpus
#2    INITED cov: 3 ft: 4 corp: 1/1b lim: 4 exec/s: 0 rss: 35Mb
=================================================================
==29562==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7fff2fba1ba0 at pc 0x0000004dda61 bp 0x7fff2fba1b30 sp 0x7fff2fba12d0
WRITE of size 65 at 0x7fff2fba1ba0 thread T0
    #0 0x4dda60 in strcpy (/home/user/Documents/tmp/main+0x4dda60)
    #1 0x52540f in MyAPI_ProcessInput(char const*) (/home/user/Documents/tmp/main+0x52540f)
    #2 0x52561e in LLVMFuzzerTestOneInput (/home/user/Documents/tmp/main+0x52561e)
    #3 0x42fe0a in fuzzer::Fuzzer::ExecuteCallback(unsigned char const*, unsigned long) (/home/user/Documents/tmp/main+0x42fe0a)
    #4 0x42f3a5 in fuzzer::Fuzzer::RunOne(unsigned char const*, unsigned long, bool, fuzzer::InputInfo*, bool*) (/home/user/Documents/tmp/main+0x42f3a5)
    #5 0x4310ee in fuzzer::Fuzzer::MutateAndTestOne() (/home/user/Documents/tmp/main+0x4310ee)
    #6 0x431dc5 in fuzzer::Fuzzer::Loop(std::vector<std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >, fuzzer::fuzzer_allocator<std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > > > const&) (/home/user/Documents/tmp/main+0x431dc5)
    #7 0x427df0 in fuzzer::FuzzerDriver(int*, char***, int (*)(unsigned char const*, unsigned long)) (/home/user/Documents/tmp/main+0x427df0)
    #8 0x44b402 in main (/home/user/Documents/tmp/main+0x44b402)
    #9 0x7f7ee294409a in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6+0x2409a)
    #10 0x421909 in _start (/home/user/Documents/tmp/main+0x421909)

Address 0x7fff2fba1ba0 is located in stack of thread T0 at offset 96 in frame
    #0 0x5252ef in MyAPI_ProcessInput(char const*) (/home/user/Documents/tmp/main+0x5252ef)

  This frame has 1 object(s):
    [32, 96) 'buf' <== Memory access at offset 96 overflows this variable
HINT: this may be a false positive if your program uses some custom stack unwind mechanism, swapcontext or vfork
      (longjmp and C++ exceptions *are* supported)
SUMMARY: AddressSanitizer: stack-buffer-overflow (/home/user/Documents/tmp/main+0x4dda60) in strcpy
Shadow bytes around the buggy address:
  0x100065f6c320: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x100065f6c330: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x100065f6c340: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x100065f6c350: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x100065f6c360: 00 00 00 00 00 00 00 00 f1 f1 f1 f1 00 00 00 00
=>0x100065f6c370: 00 00 00 00[f3]f3 f3 f3 00 00 00 00 00 00 00 00
  0x100065f6c380: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x100065f6c390: f1 f1 f1 f1 00 00 00 00 f2 f2 f2 f2 f8 f3 f3 f3
  0x100065f6c3a0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x100065f6c3b0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x100065f6c3c0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
Shadow byte legend (one shadow byte represents 8 application bytes):
  Addressable:           00
  Partially addressable: 01 02 03 04 05 06 07 
  Heap left redzone:       fa
  Freed heap region:       fd
  Stack left redzone:      f1
  Stack mid redzone:       f2
  Stack right redzone:     f3
  Stack after return:      f5
  Stack use after scope:   f8
  Global redzone:          f9
  Global init order:       f6
  Poisoned by user:        f7
  Container overflow:      fc
  Array cookie:            ac
  Intra object redzone:    bb
  ASan internal:           fe
  Left alloca redzone:     ca
  Right alloca redzone:    cb
  Shadow gap:              cc
==29562==ABORTING
MS: 1 InsertRepeatedBytes-; base unit: adc83b19e793491b1c6ea0fd8b46cd9f32e592fc
0xa,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,
\x0a\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff
artifact_prefix='./'; Test unit written to ./crash-76387a0aaeb6a1d2b6b6f095ab49c927c00243e5
-->

Сборка и её особенности на разных платформах

Сделать сборку продукта вместе с фаззерами может оказаться более масштабной задачей, чем кажется на первый взгляд. Есть три основных момента, на которые надо обратить внимание: платформа, компилятор, система сборки.

Платформа

Вопрос о выборе платформы для фаззинга упирается в работоспособность инструментов на каждой из них. На данный момент white-box тестирование намного лучше показывает себя на unix-системах.

Однако, если продукт работает лишь на ОС Windows, не стоит отчаиваться. Во-первых, инструментарий для фаззинга активно развивается. Во-вторых, код крупных проектов, как правило, не является платформозависимым. Можно собрать необходимую часть проекта на unix–системе и начать его фаззить. Ошибки никуда не денутся.

Компилятор

Думая о том, каким компилятором собирать фаззеры, мы всегда вспоминаем, что libfuzzer – это подпроект LLVM, а в AFL предусмотрен llvm_mode, про который в его readme написано:

Настоящая инструментация на этапе компиляции, а не просто грубые изменения на уровне ассемблера.

Возможность добавления инструментации во время компиляции и поддержка LLVM самыми популярными фаззерами делает выбор компилятора очевидным — для сборки лучше всего использовать clang. Конечно, нам попадались проекты, которые не были ориентированы на сборку этим компилятором, но нас это никогда не останавливало.

Система сборки

Касательно системы сборки, отметим, что это, скорее, вопрос вкуса и, возможно, привычки использования того или иного инструмента. Конкретно наш опыт показывает, что использование cmake позволяет в разы ускорить внедрение фаззинга в проект:

# Step 1
add_custom_target(${FUZZ_TARGET_NAME})

# Step 2
function(add_fuzzer name path)
    add_executable(${name} ${SRC} ${path})
    target_compile_options(...)
    set_target_properties(...)
    add_dependencies(${FUZZ_TARGET_NAME} ${name})

# Step 3
set(fuzzers my_ideal_fuzzer1 my_ideal_fuzzer2 ...)
foreach(fuzzer ${fuzzers})
    add_fuzzer(${fuzzer} ${FUZZERS_DIR})
endforeach()

Кратко интеграцию фаззеров в сборку проекта на cmake можно описать в трех шагах на примере одного из проектов Digital Security:

  • Создаём цель ${FUZZ_TARGET_NAME}, к которой в зависимости будем добавлять фаззеры.
  • Пишем простую функцию, которая принимает на вход название фаззера и путь к его исходному коду. Реализация этой функции сводится к нескольким вызовам других cmake-функций. Сначала необходимо указать список исходников для сборки компонента который мы будем фаззить. Он хранится в переменной ${SRC}. Потом идут два вызова для установки опций компилятора и компоновщика. Последним вызовом добавляем новый исполняемый файл в качестве зависимости к цели, созданной ранее.
  • Остаётся дописать простой цикл который поочередно добавит все фаззеры к созданной ранее цели.

Другой пример – система сборки autotools. Она является достаточно старой и не такой удобной, как cmake. Однако это не помешает внедрению фаззинга в проект. Например, здесь показан пример интеграции фаззинга в WebRTC — сервер janus-gateway. Сам скрипт занимает где-то 100 строчек на bash. Конечно, это не так много, однако, как только проект начнёт меняться, эти скрипты тоже необходимо будет поддерживать в актуальном состоянии. На это может потребоваться дополнительное время.

Фаззинг в облаке

Наконец, мы добрались до финальной главы — непосредственно про организацию continuous fuzzing. Сначала мы посмотрим, как с этой задачей справились в Google, а затем — что получилось у нас.

Фаззинг-ферма от Google

В 2012 году компания Google анонсировала ClusterFuzz — облачную инфраструктуру для поиска уязвимостей в компонентах своего браузера. В его описании приведена схема, наглядно показывающая, как должен выглядеть continuous fuzzing, интегрированный в процесс разработки.

Схема сontinious fuzzing от google

Пробежимся по элементам схемы:

  • Developer — команда разработчиков проекта
  • Upstream project — репозиторий с проектом
  • Oss-fuzz — вспомогательный репозиторий для интеграции continuous fuzzing. Хранит build-конфиги фаззеров
  • Builder — Сборщик фаззеров. Его задача — скачивать исходники фаззеров из репозитория с проектом, подхватывать build-конфиги из oss-fuzz и производить сборку фаззеров. После сборки builder закачивает в хранилку (GCS bucket) файлы, необходимые для запуска фаззера
  • Clusterfuzz — масштабируемая система для управления процессом фаззинга. Отвечает за планирование запусков фаззеров, обработку полученных от них данных, сбор статистики и многое другое. Пополняет свою коллекцию фаззеров из хранилки.
  • Issue tracker — система отслеживания задач: youtrack, jira и подобные им программы. Сюда приходят отчёты о найденных уязвимостях

А теперь рассмотрим сам процесс интеграции. Начнём с того, что у команды разработчиков должен иметься репозиторий с кодом проекта. Здесь же должны храниться исходные коды фаззеров. Для интеграции необходимо подготовить build-конфиги для сборки и запуска фаззеров в инфраструктуре Google. Готовые build-конфиги нужно добавить в репозиторий oss-fuzz через пулл-реквест. После принятия пулл-реквеста можно радоваться: фаззеры будут автоматически собраны и запущены в Clusterfuzz. С каждым новым коммитом в oss-fuzz будут подхватываться фаззеры из репозитория с проектом. Если вдруг нашлась уязвимость, то уведомление придёт к разработчикам в issue tracker. Разработчики своевременно исправляют баги и все остаются довольными.

Почему решение от Google подходит не всем?

Отметим, что ферма для фаззинга от Google — это круто. Впрочем, и в этой бочке мёда есть ложка дёгтя. Несмотря на то, что Clusterfuzz имеет открытый код, он жёстко привязан к облачным сервисам Google.

Это означает, что:

  • Для закрытых проектов такая система не подходит.
  • Изменить что-то под себя вряд ли получится.

Поэтому мы сделали свою фаззинг-ферму. Собственное решение для continuous fuzzing позволяет нам:

  • Снять ограничения на используемую инфраструктуру. Ферму можно запускать как в публичном облаке, так и у себя локально.
  • Полностью контролировать работу фермы: мониторинг, конфигурирование, добавление собственных фич и т.д.
  • Осуществлять фаззинг проприетарного программного обеспечения.

Схема нашей фермы похожа на схему от Google, однако она проще. Основная логика сосредоточена в компоненте FuzzFarm. Он выполняет те же задачи, что и Clusterfuzz. Ферма использует только наши внутренние ресурсы и может работать без доступа к интернету.

Схема работы нашей фаззинг-фермы

Интерфейс пользователя мы сделали простым и минималистичным. При этом он покрывает все необходимые задачи в continuous fuzzing.

Интерфейс пользователя нашей фаззинг-фермы

Что получаем в итоге?

В итоге мы имеем следующие преимущества использования continuous fuzzing:

  • Для разработчика это более оперативное обнаружение ошибок и готовые данные для юнит-тестов. В баг-трекер приходит уведомление, разработчик устраняет ошибку и быстро делает тест с использованием полученного от фермы кейса.
  • Ещё один шаг к оптимизации заключается в автоматическом создании юнит-тестов из полученных результатов фаззинга. Тестировщик будет рад 🙂
  • Наконец, можно добавить автоматический анализ крэшей. Специалисту по безопасности будет намного удобнее, если система сама будет оценивать критичность найденных уязвимостей и предоставлять детальный отчёт. Более того, запуск фаззеров и обработка их результатов производятся автоматически.

Заключение

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

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

Материалы

Обсудить статью вы можете на habr

Авторы: Борис Рютин, Павел Князев, исследователи Digital Security