Динамический анализ кода

Динамический анализ кода

Разбираем, что такое динамический анализ и как его использовать для поиска уязвимостей. 

Изображение записи

Динамический анализ — это метод проверки программного обеспечения (включая поиск уязвимостей), который основан на запуске программы с определенными входными данными и анализе ее поведения и результатов. 

Ограничения динамического анализа

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

Рассмотрим простейшую программу, которая складывает два 64-битных целых числа. Количество возможных входных комбинаций составит (264)*(264) = 2128. Если бы мы запускали тесты на четырехгигагерцовом процессоре и тратили на каждую комбинацию пять тактов, полное тестирование заняло бы 13,5 × 1021 лет. 

Даже миллион восьмиядерных процессоров не решит проблему: это сократило бы время лишь до 1,7 × 1015 лет. Реальные приложения работают с гораздо более сложными входными данными, поэтому проверить даже 0,00001% всех возможных вариантов в пределах человеческой жизни невозможно. 

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

Традиционное тестирование

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

Модульное тестирование (unit testing)

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

Интеграционное тестирование

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

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

Чтобы ваше ПО работало корректно, критически важно иметь качественный набор автоматизированных тестов и применять его в пайплайне непрерывной интеграции (CI). Под «качественным» подразумевается «с высокой вероятностью обнаруживающий серьезные проблемы». Хотя это не гарантирует отсутствия ошибок, хороший набор тестов значительно повышает вероятность их выявления и особенно важен при обновлении сторонних компонентов.

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

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

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

С точки зрения безопасности важно включать тесты, которые проверяют выполнение требований безопасности. Нужно тестировать как ожидаемое поведение, так и то, что должно быть запрещено — так называемое негативное тестирование. Часто именно проверка «запретных» сценариев упускается. Например, там, где это применимо, следует добавить такие тесты:

  • «Можно ли получить доступ к данным без авторизации?» — должно быть невозможно;
  • «Можно ли получить доступ к системе с недействительным или отсутствующим сертификатом?» — должно быть невозможно.

Разработка через тестирование (TDD)

Один из подходов к разработке ПО называется разработкой через тестирование (Test-Driven Development, TDD). В таком подходе тесты для новой функциональности пишутся до кода, который ее реализует.

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

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

Покрытие тестами

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

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

Покрытие операторов (statement coverage) — это процент операторов приложения, которые выполнились хотя бы в одном тесте.

Покрытие ветвлений (branch coverage) — это процент ветвей приложения, которые были пройдены хотя бы одним тестом: 

  • в конструкции if–then–else ветвь then — одна ветвь, ветвь else — другая;
  • в цикле проход по телу цикла — одна ветвь, а обход цикла (нулевые итерации) — другая; 
  • в операторе switch (или case) каждая возможная альтернатива считается отдельной ветвью.

У метрик покрытия операторов и ветвлений есть одна сложность: часть кода может быть недостижимой по разным причинам. Если оператор невозможно выполнить, стоит добавить что-то вроде assert(false), чтобы явно указать инструментам и людям, что выполнение здесь не ожидается. В идеале нужно измерять процент достижимых операторов и ветвей, покрытых тестами.

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

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

Например, в предыдущей части курса мы упоминали опасную уязвимость во многих версиях операционных систем Apple (CVE-2014-1266), которую неофициально называют «goto fail; goto fail;». Из-за дублирования оператора goto пропускалась часть кода, отвечающая за проверку сертификатов безопасности. Измерение покрытия операторов сразу бы показало, что критически важный для безопасности код не выполняется ни в одном тесте, и этого должно было быть достаточно, чтобы обратить внимание на проблему.

Главный недостаток метрик покрытия операторов и ветвлений в том, что они не гарантируют качество тестов. Даже слабый набор тестов может достичь 100% покрытия. Например:

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

Таким образом, метрики покрытия полезны для выявления непроверенного кода, но не отражают всех проблем тестирования.

Фаззинг и сканеры веб-приложений

Фаззинг-тестирование

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

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

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

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

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

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

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

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

Многие фаззеры работают по мутационному принципу: они начинают с начального набора входных данных («семян»), а затем последовательно модифицируют их для генерации новых тестовых случаев. Эффективность таких фаззеров критически зависит от качества семян. Практическое правило: выберите минимальный набор входных данных, достаточный для покрытия или почти полного покрытия кода — то есть для достижения 100% покрытия операторов. Подробнее — в статье «Optimizing Seed Selection for Fuzzing» (2014).

Важный подкласс фаззеров — фаззеры с контролем покрытия (coverage-guided fuzzers). Эти инструменты внедряют в тестируемое ПО специальный код, чтобы отслеживать, какие участки программы выполняются при обработке каждого входного значения. Эти данные используются для выбора следующих входных данных для генерации.

Инструмент American Fuzzy Lop (AFL) продемонстрировал силу этого подхода: он учитывает не только то, какие части кода были выполнены, но и сколько раз. AFL отдает предпочтение данным, которые приводят к выполнению новых участков кода. Другие инструменты, такие как libFuzzer, также используют эту методику.

Такие фаззеры также называют:

  • фаззеры на основе обратной связи (feedback-based fuzzers);
  • инструменты тестирования безопасности на основе обратной связи (FAST-инструменты, «What is FAST?», Sergej Dechand, 2020).

Подход сочетает статический и динамический анализ, поэтому эти инструменты можно отнести к гибридным средствам анализа.

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

Это не означает, что фаззеры бесполезны, но их стоит рассматривать как лишь один из инструментов в наборе средств обеспечения безопасности ПО.

Сканеры веб-приложений (WAS)

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

Сканер веб-приложений (Web Application Scanning, WAS), также называемый сканером уязвимостей веб-приложений, имитирует действия пользователя или веб-браузера, выполняя различные операции для выявления потенциальных проблем. Представьте WAS как активного и зловредного пользователя браузера: он нажимает все кнопки, вводит странный текст в каждое поле и пытается выполнить необычные или недопустимые действия, чтобы вызвать сбой. Многие WAS построены на базе фаззеров, но специализированы именно для тестирования веб-приложений. 

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

WAS всегда следует запускать только в тестовом окружении, а не в рабочей (production) системе, поскольку он намеренно провоцирует сбои.

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

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

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