Автор: Иннокентий Сенновский Все уже привыкли, что программу надо покрывать тестами, чтобы потом не было стыдно. Но вот проблема: разработчик может защититься только от багов, которые он способен предугадать. Да и тестировщик вряд ли сможет перебрать все варианты входных данных, которые приводят к ошибке. Чтобы справиться с этой проблемой, придумали фаззеры — инструменты тестирования, которые сами пытаются найти баги в программе. В этой статье я рассмотрю, какие они вообще бывают (для C/C++, потому что баги в таких программах особенно болезненны), но сделаю упор на Greybox-фаззеры (почему, расскажу в статье). Речь пойдет не об инструментах пентестеров, вроде Dirbuster или Burp Suite, которые проверяют программы на типичные проблемы и затрагивают лишь поверхность, а о фаззерах вроде AFL и LibFuzzer, которые тестируют программу, руководствуясь ее внутренним строением и механизмами. Давайте начнем с того, что такое собственно фаззинг. Допустим, у нас есть программа, которую мы хотим протестировать. Будем называть ее на заморский манер — PUT (Program Under Test). В академической среде фаззинг — это когда мы исполняем PUT с использованием входных данных, которые расширяют входное пространство PUT. Если по-простому, то пытаемся найти данные, которые выявят новую функциональность в программе. Но в сфере кибербезопасности под фаззингом обычно подразумевают фазз-тестирование. Это когда мы используем фаззинг, чтобы проверить, что PUT не нарушает какую-либо политику безопасности. То есть мы смотрим, существуют ли данные, при которых поведение программы может сделать больно разработчику. Фаззер, как следует из названия, это инструмент для проведения фаззинга. Его общий алгоритм можно высокоуровнево представить так: Сразу поясню две сущности, которые могли показаться непонятными: Таким образом, фаззер: Основных видов фаззинга — три: В каждой статье должна быть хоть одна кринжовая картинка. Вот она:  Blackbox — самый примитивный вариант (инструменты — Burp Suite, DirBuster, Radamsa и др.). Обычно с их помощью пытаются найти типичные баги или баги в крайне дырявых программах. В этом случае информация об исполнении, которую фаззер получает при обращении к программе с указанным тест-кейсом, ограничена выводом программы или фактом ее отказа. Поэтому для большинства таких программ очень важны изначальные тест-кейсы. Whitebox — самый тяжелый вариант (инструменты — KLEE и подобные). У него есть три самых явных подвида: Symbolic Execution (символическое, оно же символьное, исполнение). При нем вместо преобразования кода программы в исполняемый код компилятор выплевывает набор формул, который описывает всю работу программы. Далее специальный решатель (обычно это SMT-решатель вроде z3) преобразует SMT в SAT и пытается найти определенные пространства решений. Формулы модифицируются таким образом, чтобы пройти каждую ветвь исполнения изначальной программы.  Если вы хотите поиграться с таким фаззером, воспользуйтесь старым и проверенным KLEE или более новым и, на мой взгляд, простым SymCC. Последний очень советую, если надо протестировать маленькую программу для парсинга файлов.  У всех машин для символического исполнения есть одна проблема: в общем случае решение SAT, к которому в итоге приводятся программы, является NP-полной задачей. SMT-решатели находят решения за счет крайне эффективного упрощения теорем на каждом этапе, но чем больше программа и чем глубже в нее надо проникнуть, тем менее вероятно, что решатель справится за приемлемое время.  Concolic Execution (конколическое исполнение). Это попытка немного упростить символическое исполнение за счет обогащения решателя данными от реального исполнения программы. Выполнили программу на известном тест-кейсе → сохранили данные → запустили решать попадание на другую ветвь с полученной информацией.  Data Taint Tracking (термин на русском я не встречал). Это самый легкий с точки зрения вычислений метод, который относят к Whitebox-фаззингу. При исполнении программы все байты, которые зависят от определенных байтов входных данных, помечаются (taint). За счет этого фаззер отслеживает, что влияет на выбор пути исполнения, данные на какой позиции нужно менять. Blackbox туповат, Whitebox неэффективен для сложных программ и ест много ресурсов, поэтому придумали нечто среднее. Greybox — это фаззинг, при котором каждый раз, когда PUT исполняет тест-кейс, фаззер получает не только информацию о выводе и падении программы (зачастую вывод вообще игнорируется), но и информацию о ходе исполнения программы. В общем случае информация может быть любой, но обычно используют такую метрику, как покрытие кода программы. Под этим подразумевают то, какие части программы были исполнены. Например, если рассмотреть программу как граф, покрытие может быть списком вершин, которые были пройдены при исполнении. Но может быть и чем-то другим — о видах покрытия я расскажу чуть позже. Покрытие программы используется как лакмусовая бумажка, чтобы определить, надо ли добавлять тест-кейс в корпус или он бесполезен. Если исполнение PUT достигает новой части программы (например, изменяет условие в каком-то if), то фаззер детектирует это событие и добавляет тест-кейс в корпус. Таким образом покрытие программы расширяется и вероятность обнаружения какой-то проблемы увеличивается. Дальше в статье я сосредоточусь именно на Greybox как на самом эффективном виде фазз-тестирования. Возьмем код, который требует решение системы линейных алгебраических уравнений на трех переменных: Вот как сработает программа, если мы подадим на вход числа, которые не удовлетворяют системе уравнений :  (Да, у меня немного поехала нумерация переменных в принтах, но, когда выпускали статью, переделывать картинки уже было поздно.) Если скомпилировать эту программу, вероятность попадания Greybox-фаззера в точку будет  ​(размер int на системе — 4 байта). А теперь давайте попробуем найти решение при помощи Whitebox-фаззера SymCC. Клонируем SymCC. Собираем контейнер (так намного проще, чем устанавливать все зависимости и собирать компилятор на своей системе). Запускаем контейнер и пробрасываем папку с файлом (находимся в ней): Компилируем: Заодно на хосте можно скомпилировать без символического исполнения, чтобы проверить результат позже: Создадим несколько папок для результатов и пример ввода: Несколько папок нужно, потому что на каждом запуске SymCC перезаписывает файлы в папке вывода. Если мы не хотим потерять предыдущие результаты, то лучше сохранять результаты в отдельные папки. Алгоритм работы SymCC достаточно прост: Из-за такого принципа работы использовать SymCC напрямую в качестве фаззера не очень удобно. Нужен какой-нибудь хэндлер (он есть в AFL++), чтобы проверять, найдена ли новая функциональность, и подавать на вход только те тест-кейсы, которые открыли новые ветви. В нашем случае программа очень простая и не содержит много ветвей, так что мы можем проделать этот путь вручную. Запустим с начальным тест-кейсом:  SymCC показывает вывод программы при исполнении ее с заданным тест-кейсом, а также информацию о затраченном на решение времени и полученных новых тест-кейсах. Давайте сразу отправим новый тест-кейс снова в SymCC:  Получили три тест-кейса. Последний и есть решение уравнения:  На каждой итерации SymCC пробирался чуть дальше внутрь программы и в итоге смог решить уравнение. Greybox бы такого не смог. Пришло время вернуться к покрытию кода программы и оракулам багов. Какие их варианты использовать — это одно из множества решений, которые нужно принять разработчику или пользователю Greybox-фаззера. Сюда же можно отнести выбор механизма исполнения PUT и модели тест-кейсов. (Я остановлюсь на этих четырех аспектах для краткости — конечно, ими дело не ограничивается.) Как уже сказано выше, Greybox-фаззеры используют покрытие, чтобы понять, какие тест-кейсы полезны. Покрытие фаззер может понимать по-разному. Вот несколько типов, которые встречаются в доступных решениях: Во всех случаях при обнаружении нового покрытия фаззер добавляет тест-кейс в корпус. По умолчанию в большинстве современных фаззеров используется покрытие ветвей. Механизм исполнения влияет на быстродействие фаззера, то есть на количество тест-кейсов, которые фаззер успеет обработать за секунду. В самом примитивном варианте фаззер заново запускает программу на каждый тест-кейс. Но запуск — относительно долгий процесс: нужно выделить ресурсы, найти библиотеки, все это замапить. Для маленьких программ львиная доля системного времени, потраченного фаззером, будет приходиться на подготовку, а не на исполнение кода программы. Чтобы не терять время, предусмотрели три более сложных механизма: Fork mode. Процесс запускают один раз, после чего для каждого тест-кейса делают форк процесса. Это позволяет пропустить инициализацию при каждом тест-кейсе, и подготовка занимает намного меньше времени, чем при создании все новых и новых процессов.  Fork — самый простой способ ускориться, если приложение принимает данные из STDIN. Этот механизм встроен в фаззер AFL++.  Fork mode ++ (название мое). Процесс запускается один раз, после чего делается форк и сохраняется содержимое всех страниц. Как только исполнение на тест-кейсе завершено, модуль проверяет, какие страницы надо восстановить, и заменяет только их.  Этот механизм написали тоже для AFL++. Неудобство в том, что для его использования нужен специальный модуль ядра, который следит, какие страницы были изменены (флаг Dirty в структурах пэйджинга). Зато этот способ быстрее Fork: со слов разработчиков модуля, прирост скорости составляет от 20 до 360% в зависимости от PUT.  Persistent mode. Программа инициализируется, доходит до функции X, после чего эта функция исполняется в цикле с подменой аргументов.  Это самый быстрый с точки зрения фаззинга способ, но у него есть свои минусы. Во-первых, обычно он требует множества модификаций PUT. Во-вторых, он может вызывать проблемы, так как отдельные исполнения перестают быть независимыми. В-третьих, он создает false positive: так как процесс не меняется, есть большой риск изменения состояния процесса и наведенных ошибок. Поэтому обычно цикл исполняется N-е количество раз, после чего либо создается новый форк процесса из оригинального состояния, либо процесс перезапускается.  Persistent mode — основа работы фаззера libFuzzer, но его можно добавить и в AFL++ (в документации все объясняется). Эта модель отвечает за то, как фаззер мутирует и воспринимает наши тест-кейсы. Основных вариантов два: При первом варианте фаззер воспринимает тест-кейсы просто как последовательность бинарных данных: не пытается парсить и изменять отдельные части (в 99% случаев), а случайным образом выбирает место и способ, которым нужно менять тест-кейс. В AFL этот метод был доведен до совершенства с точки зрения используемых мутаторов (но не способа их использования — впрочем, это тема следующей статьи). Проблема фаззинга бинарных данных в том, что большинство программ ожидают определенной структуры от вводимых данных, например: Каждое из таких ожиданий программы очень сильно снижает эффективность фаззера с бинарным представлением тест-кейсов. Представьте, что на старте программы стоит фильтр и лишь малый процент тест-кейсов его проходит и задевает внутреннюю логику, которая нам и интересна. Второй вариант — фаззинг на основе предоставленной модели — гораздо более действенный, хотя и трудозатратный. При нем вы определяете структуру тест-кейсов, чтобы фаззер изменял их в соответствии с ней. Вот несколько форматов модели данных: Форма, используемая в Peach Fuzzer. Останавливаться на ней мы не будем, потому что она не распространена.  Расширенная форма Бэкуса-Наура (РБНФ/EBNF), которую, например, использует грамматический мутатор AFL++. Она наиболее удобна для тестирования текстовых форматов и протоколов, таких как SMTP. К примеру, здесь представлена сохраненная в JSON грамматика для JSON:  Проблема с РБНФ заключается в том, что это грамматика второго типа или бесконтекстная грамматика, из-за чего технически определять ей XML или HTML нельзя. И парсить их RegEx, кстати, тоже. Пространство генерируемых ею тест-кейсов заведомо меньше пространства, определяемого данными форматами, что ограничивает сложные граничные случаи, которые могут быть сгенерированы РБНФ. Например, в этой модели мы не можем задать правило «если в тест-кейсе четное число букв a, то последней должна быть обязательно b», не скатившись в перебор всех возможных вариантов.  Использование Protocol Buffers. Это удобный способ определить структуру файла или протокола: вы описываете их при помощи Protocol Buffers и пишете свой преобразователь из protobuf в бинарные данные.  Существует специальная библиотека libprotobuf-mutator, которая позволяет использовать заготовленные мутаторы на внутренние типы и в целом сама мутирует protobuf-представление тест-кейса. С ее помощью можно с минимальным вложением сил получить достаточно умный мутатор (а если он и тупит, то документация подскажет, как легко добавить логику).  Пример части определения для фаззинга парсера DNS-сообщений: Вот сравнительная таблица стандартных оракулов багов, которые есть в компиляторе Clang (его используют фаззеры под C/C++): Чтобы было понятно, как оракулы работают, приведу пример с ASAN — он мне кажется наиболее полезным для обнаружения эксплуатабельных багов. Допустим, у нас есть программа с возможностью чтения за границей выделенного буфера: Если скомпилировать программу с ASAN, он покажет чтение элемента за границей:  Мне кажется, что стоит упомянуть три семейства (хотя вы можете со мной не согласиться). Вот они: AFL — это фаззер, который дал толчок в массовому использованию Greybox-фазз-тестирования, когда Michal Zalewski (lcamtuf) выпустил его в 2013 году. Базовая идея фаззера была такой: собираем покрытие ветвей при каждом исполнении, цель — максимизировать покрытие. Сейчас «ванильный» AFL уже не используется, но от него отпочковалось много проектов, например WinAFL и TinyAFL, которые позволяю проводить фаззинг приложений на Windows при помощи бинарной инструментации. Самый популярный и быстроразвивающийся проект я уже называл выше: это AFLPlusPlus (AFL++). Он вбирает в себя новые техники и постоянно расширяет возможности исследователей. AFL и AFL++ достаточно просты, чтобы их мог использовать новичок, но придется немного почитать документацию. LibFuzzer распространяется вместе с Clang. Он эволюционирует не так быстро, как AFL++, но там тоже есть встроенные приятные фичи (например, алгоритм entropic, который выбирает наиболее эффективные тест-кейсы из корпуса). Этот фаззер рассчитан только на использование в persistent mode. Однако на случай, если приложение отжирает слишком много памяти, у него есть и fork mode: он форкает процесс для небольшого числа итераций в persistent mode, после чего прибивает его. Использовать libFuzzer очень легко. Honggfuzz отличается методами сбора покрытия: фаззер позволяет использовать Intel Branch Trace Store или Intel Processor Trace. Также в нем есть есть стандартная возможность инструментировать приложение во время компиляции и поддержка фаззинга скомпилированных приложений при помощи qemu mode (хотя это есть и у AFL++). Легко ли его использовать, сказать не могу: я обходился AFL++ и libFuzzer. Так как собственного опыта работы с honggfuzz у меня нет, дальше в статье я его не касаюсь. А вот libFuzzer и AFL++ давайте рассмотрим подробнее.

 Давайте попробуем написать простой обвяз (harness) для фаззинга с libFuzzer. Установите себе Clang, если он еще не поставлен (инструкция). Скопируйте следующий код в basic_example.c: В качестве входа, в который будут подаваться тест-кейсы, в libFuzzer используется следующая функция: На вход передается тест-кейс и его размер. Если возвращаемое значение не равно нулю, то фаззер воспринимает это как обнаруженный баг. Так легко добавлять обнаружение нарушений логики приложения. Если что-то не соответствует ожиданиям, достаточно вернуть не ноль. В libFuzzer еще определена такая стандартная функция: В нее передаются указатели на аргументы запуска фаззера. Вообще функция нужна не только чтобы изменять их, если они используются внутри программы, но и чтобы произвести всю необходимую инициализацию. Поскольку в нашем примере нет никаких глобальных переменных, мы обошлись без этой функции. Скомпилируем наш обвяз: Если бы мы хотели добавить ASAN и UBSAN, то нужно было бы компилировать так: Clang сам добавит все необходимое для persistent mode. Сейчас мы запустим фаззер без каких-либо параметров. В реальном исследовании так делать не стоит: можно потерять много времени впустую из-за пропавших тест-кейсов. Но мы просто хотим проверить игрушечный пример, поэтому запускаем:  Фаззер тут же находит тест-кейс (его можно заметить на видео в конце снизу — «H!HH»), который вызывает прерывание. Тест-кейс сохранен под именем crash-<хеш от содержимого>. Его можно снова передать в исследуемое приложение, чтобы посмотреть стек вызовов: 

 Если исходники уже подготовлены к использованию с libFuzzer, то переделать их под работу с AFL++ в persistent mode тривиально. Достаточно скачать afl_driver.cpp, который распространяется в составе LLVM, и скомпилировать под AFL++. Качаем, собираем и устанавливаем AFL++. Компилируем basic_example.c, afl_driver.cpp и линкуем: Создаем директории, кладем какой-нибудь файл в input и запускаем: Как видно, AFL++ тоже находит прерывание, но в базовой конфигурации делает это медленнее libFuzzer. Я упоминал, что именно AFL привел к взрыву исследований и разработки в области фаззинга. Но почему именно AFL? Это был первый фаззер, который сочетал одновременно несколько особенностей: О последней особенности — механизмах мутации — надо рассказать подробнее. Мутации — это еще одна составляющая фаззинга, наравне с покрытием, механизмом исполнения PUT, моделью тест-кейсов и оракулами. Есть две основные фазы мутаций: В конфигурации по умолчанию AFL начинает с детерминистической фазы. Для каждого тест-кейса в стартовом корпусе AFL последовательно применяет следующие мутации (после каждой мутации проверяет PUT с данным тест-кейсом): Такие мутации с высокой вероятности изменят работу PUT, если там производится обработка бинарных данных. При этом AFL анализирует поведение программы при осуществлении отдельных битфлипов. Например, AFL видит, что работа PUT при флипе 0-го бита меняется, с 0–31 она изменена, а когда флип производится на 32 бите, все возвращается к прежней работе. В этом случае AFL понимает, что, скорее всего, в этих четырех байтах находится MAGIC или другая константа, запоминает ее и добавляет в словарь, который потом использует на хаотической фазе. Это делает его крайне эффективным: если собран большой корпус файлов, которые должна обрабатывать PUT, то AFL с большой вероятностью сможет создать интересную комбинацию из частей известных тест-кейсов. Когда AFL заканчивает детерминистическую фазу (или сразу, если пользователь ее пропускает), то переходит в хаотическую фазу. В этой фазе есть две стратегии: Первая стратегия — просто хаос — одновременно использует несколько мутаций на одном тест-кейсе (не обязательно все сразу): Вторая стратегия — хаос со сплайсингом — делает так: Вторая стратегия пытается скомбинировать характеристики двух тест-кейсов, чтобы удовлетворить одновременно несколько условиям в парсере.

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