В настоящий момент фаззинг-тестирование входит в число обязательных практик для достижения определенного уровня зрелости в процессе безопасной разработки ПО. Именно оно позволяет выявить уязвимости и недостатки, которые сложнее или вообще невозможно обнаружить другими техниками.
С момента появления этой технологии прошло уже порядка сорока лет. Есть и масса эффективных инструментов фаззинг-тестирования, и написаны тысячи строк, популяризирующих его. Однако в процессе внедрения значительные затруднения вызывает зачастую не столько техническая сторона вопроса, сколько решение двух ключевых задач:
Как эффективно донести информацию о пользе фаззинга до людей, которым предстоит этим заниматься?
Как не превратить фаззинг в пустую формальность?
С этими двумя проблемами мне довелось столкнуться на практике, и эту статью я посвящу изложению личного опыта их решения.
Первое мое знакомство с применением этой технологии состоялось в 1998-го году во время расследования взлома одного интернет-провайдера. В ходе анализа протоколов я обнаружил нестандартное поведение злоумышленников. Они занимались не только «банальным» подбором логина и пароля к консоли административного доступа, но и фаззингом. Выглядело это следующим образом: один из их инструментов подставлял в значения имени пользователя и пароля совершенно бессмысленные символьные длинные значения до тех пор, пока функция не дала сбой и «разрешила» злодеям вход с административными правами. Это произошло по причине неверной обработки данных пользовательского ввода – если бы фаззинг применили авторы программы, которая была атакована, ошибка была бы выявлена и устранена. Эта техника меня заинтересовала, я ее запомнил, хотя наблюдать или применять мне ее не доводилось.
В следующий раз я соприкоснулся с этой технологией относительно недавно, в 2021 году, на одном из своих прошлых мест работы, где занимался организацией процесса безопасной разработки ПО. При знакомстве с сотрудниками группы тестирования я понял, что нужно делать применительно к области статического анализа. Мы ввели практику многоуровневого анализа при использовании зависимого кода, критериев Security gate`s, наведения порядка в «зоопарке зависимостей».
Но, как только речь зашла о фаззинг-тестировании кода, создавалось впечатление, что у людей просто «падала шторка», и они не понимали необходимости внедрения данного тестирования. Гора литературы и пояснения с картинками на доске в ходе планерок не помогали.
Как оказалось, причин, лежащих в корне непонимания, было ровно две:
Основная, «психологическая», заключалась в том, что фаззинг не воспринимался всерьез разработчиками по разным причинам.
Вторая же была чисто технической и заключалась в отсутствии наглядного, очевидного примера, позволяющего понять, что это такое, и какие ошибки позволяет выявить.
Для них этот пример я и решил создать своими руками, написав небольшую программу, призванную наглядно продемонстрировать моим коллегам, что бывает, когда пользовательский ввод плохо или вообще никак не контролируется перед попаданием в обработку анализируемым софтом или его отдельными функциями.
Для этого я применил Delphi, который у меня всегда под рукой. Delphi, конечно, вполне «безопасный» язык программирования, как говорили ранее, «если ПО вашего «умного» пистолета будет написано на Delphi, вы никогда не сможете застрелиться» (хотя, как сказать: до 10-15% malware написано с его использованием), но с его помощью можно провести и несколько наглядных демонстраций – а большего мне и не было нужно.
Функция предельно примитивна, она производит вычисление экспоненты от степени двух целочисленных переменных…ну что тут может пойти не так?
А «не так» может пойти все, что угодно. В нашей функции никак не контролируется возможная ситуация возникновения переполнения, ни на стадии проверки входных данных (отсутствующей в принципе), ни на стадии вычислений – и результатом стало аварийное завершение, или «падение» нашей программы. Кроме того, не контролируется попадание результата в диапазон допустимых значений, ни в самой функции, ни при ее вызове…но это, в данном примере, не имеет значения.
А теперь представим, что было бы, если б он вез патроны это был управляющий компьютер в космическом аппарате стоимостью в Х (нет, лучше «К» – «К» - больше, чем Х!) миллиардов?..
Очень жаль, конечно, что сейчас не найти Windows 95/98/Me и процессоров AMD Athlon или Duron старых поколений – эта связка падала в BSOD при банальном необрабатываемом делении на ноль в пользовательском приложении: пример фатальной неисправности, вызванной маленькой небрежностью – выглядит очень наглядно:
// Очень старый небезопасный код на С!
#include <stdio.h>//библиотека ввода-вывода
#include <float.h> //Библиотека плавающей точки
double a=1.,
b=0.,
c=0.;
void main()
{
_control87(0x1332,0xFFFF); //используем мат.сопроцессор
c=a/b; // просто деление … на 0
printf("%g %g %g",a,b,c); // и этот код уже не выполнится…
}
}
Правда, сейчас особо лучше не стало: если из-под BSOD хакер, пытающийся эксплуатировать эту уязвимость, вряд ли вытащит данные (падение системы, конечно, тоже плохо, но не приводит к утечке данных), то вот эта, относительно свежая уязвимость: CVE-2023-20588 («A division-by-zero error on some AMD processors can potentially return speculative data resulting in loss of confidentiality») может быть использована для создания скрытого канала передачи данных между процессами, «песочницами» или виртуальными машинами… – это выглядит совсем плохо!
Продемонстрировав все это, я предложил коллегам ответить на ряд вопросов:
Как бы они вылавливали такого рода проблемы стандартными функциональными тестами, в реальных наших программах, обладающих стократ куда более сложной структурой? Ведь заранее-то неизвестно, что подаст на вход ее пользователь (а если он, например, загрузит файл с случайно или умышленно нарушенной структурой – как отреагирует на это функция предварительного просмотра, или иной обработки файла, тем более, находящаяся глубоко в заимствованном коде?)
Продумать все возможные варианты пользовательского ввода или искажения структур данных, а затем перебрать их руками поочередно? А если к ошибке приводит не какое-то фиксированное значение или искажение данных, а последовательность обрабатываемых значений? Да тому моменту, как мы закончим вручную тестировать какой-нибудь браузер документов, Солнце, пожалуй, остынет.
На этих словах коллеги призадумались. А я привел пример, зачем нужен фаззинг:
Представьте себе, что у вас есть машинка для измельчения бытовых отходов, и вам необходимо узнать, годится ли она для того, чтобы переработать весь тот мусор, который вы в нее сбросите. Для этого вы устанавливаете машинку, включаете ее, и забрасываете вход всем тем мусором, который потенциально способен (и не способен – тоже) попасть в нее: как только она остановится – вы сразу и узнаете, что можно в нее бросать, а что нет, что именно сломалось, и сможете понять, как предотвратить поломку.
То же самое и с фаззером: он, заменяя вас, производит мусор по своему усмотрению, или по заданным правилам подбирает самый лучший для остановки дробилки мусор. Если замечает, что машинка начинает сбоить, он автоматически записывает, какой мусор сломал ее, и какая именно деталь или какой узел при этом сломались. При использовании фаззинг-тестирования не бывает ложных срабатываний, подобных тем, которые нам выдают, например, статические анализаторы: если что-то упало, то оно упало «по-настоящему», исключительно в силу наличия объективного недостатка в логике или коде.
А вот и наша «дробилка», такой себе «квази-фаззер», примитивный, написанный на коленке, лишенный большинства функций «настоящих» фаззеров, вроде AFL или JAZZER, но, тем не менее, наглядно иллюстрирующий пример с дробилкой и мусором:
В целях повышения наглядности изложения я специально избегал использования встроенных модулей фаззинг-тестирования, имеющихся, например, в Golang, поэтому и «фаззер» свой написал на все том же старом, но небесполезном Delphi.
Демонстрационный пример достаточно прост: описанная ранее функция, призванная демонстрировать пример опасного стиля программирования, вызывается в бесконечном цикле, всякий раз с новым набором случайных входных данных, а конструкция принудительной обработки исключительных ситуаций «try-except» позволяет наблюдать, какой именно набор входных данных вызвал аварийное состояние Такой вот «фаззинг-на-коленке». Разумеется, в этом цикле мы можем создать и вызвать функцию, демонстрирующую пример «безопасного» программирования, но нам не нужно наблюдать миллионы циклов теста.
Ещё раз повторюсь: это – не настоящий фаззер, он не позволяет предопределять входной набор данных для теста, не позволяет производить его мутации по обратной связи. Он не делает много из того, что делает, например, AFL, но нам это и не нужно. Как мы видим, наш квази-фаззер наглядно демонстрирует, что тест упал, на какой именно комбинации входных данных он упал, и почему. И вот такой примитивный метод демонстрации, несмотря на всю свою простоту, дал реальный результат: буквально через два дня были написаны первые тесты, которые выявили как раз отсутствующую обработку исключений, и в весьма значительном количестве.
И фаззинг заработал
И все было бы хорошо: поверхность атаки была сформирована, функции с интерфейсами на ней успешно «фаззились», тесты более не падали, выполнялись миллионы и миллиарды циклов тестирования, покрытие не росло, и можно было ставить галочку в протокол, но это было лишь начало.
Фактически мои коллеги, как и многие тестировщики, «познавшие дзен» фаззинга в первом приближении, полагали, что вполне достаточно, сформировав тем или иным способом поверхность атаки, просто написать тесты для функций, лежащих на ее поверхности, запустить их, и, если тесты не упали, то все хорошо, фаззинг-тест пройден успешно, код хорош.
Но, к сожалению, это ошибочное мнение: для простых программ, подобных тем, что приведены были выше, это работает. Но, когда уровень вложенности функций, тем более, для заимствованного кода, достигает 3-4-5-уровней вложенности, это уже не работает почти никак: тестируя функцию верхнего уровня, невозможно точно установить место и причину возникновения ошибки (в родительском ли она коде, или в заимствованном, вызвано падение теста конкретным набором входных данных, или они были преобразованы в процессе обработки таким образом, что вызвали падение, и т.д.).
А ведь одна из основных наших задач, в процессе разработки эффективно функционирующего и безопасного кода – выявить и устранить проблемы максимально точно, а не высокоуровневой оберткой типа try-catch. Если нам потребуется, например, в ходе выполнения программы восстановить состояние системы на момент, предшествовавший возникновению ошибки, в большинстве случаев мы не сможем это сделать, не зная точных причин, характеристик и последствий сбоя.
А в случае высокоуровневой обработки ошибок, мы этого сделать точно не сможем. Например, используя ЯП Java с Spring, в большей части случаев мы будем тестировать глобальные вызовы Spring, и что нам это даст в итоге? Мы увидим только то, что тест упал, и на каком значении входных данных. Многое ли нам это дало для реального понимания ситуации? Нет, крайне немного, поскольку мы видим лишь верхушку айсберга нашей проблемы:
И вот для того, чтобы фаззинг-тестирование из хорошего метода обеспечения безопасности приложений не превратилось в профанацию, нам необходимо углубиться под поверхность атаки:
Очень важно выполнить подробный разбор анализируемого кода, углубляясь «под» внешнюю поверхность атаки, отслеживая фактические пути прохождения обрабатываемых данных (тем более, данных, исходящих от пользователя), определяя в коде реально критичные участки (функции) и тестируя на фактических наборах данных именно их, а не какую-то глобальную подсистему вообще. В чем-то этот процесс сходен с реверс-инжинирингом, на мой взгляд, и так же сложен, но необходим.
Для наглядности отмечу следующее: после того, как мы завершили определение именно фактической поверхности атаки для фаззинга, число функций, подлежащих тестированию, выросло от семнадцати, лежащих на внешней поверхности, до почти четырехсот.
Да, это сложно, это требует применения соответствующего инструментария, например, NATCH (от ИСП РАН), внимания и наличия хороших знаний в языках, применяемых при разработке, это требует времени (поди попробуй проанализируй код по дереву вызовов хотя бы на 2-3 уровня вглубь), но ведь наша цель – разработка безопасного, конкурентоспособного ПО, а не просто получение бумажки, формально подтверждающей формальное же соответствие требованиям?
Кстати, в таком погружении есть и дополнительная выгода: помимо реализации «настоящего» фаззинга, углубление под поверхность атаки обеспечивает и повышение уровня безопасности, позволяя нам проанализировать ПО и определить наличие устаревших, лишних, неэффективных, а иной раз и просто опасных элементов. Таких, как лишние или устаревшие функции, или, что еще хуже, отладочные интерфейсы или API и т.д.
Что хотелось бы сказать в завершение
Собственноручная демонстрация, подобная описанной выше, способствует как налаживанию контакта ИБ с командами разработки и тестирования («Он не динозавр, он почти такой же, как мы, он умеет в код!»), так и прямому доведению до сведения каждого участника команды того, как выглядят на практике последствия их ошибок: одно дело – читать или слушать некую «абстракцию» про уязвимости, а другое – своими глазами увидеть, что происходит при переполнении, при попытке записи в несуществующий файл, при неверном выборе параметра и т.д.: «Черт побери, я ведь такое могу пропустить тоже!! Надо проверить срочно функцию WriteDataToFile()!!». А уж видя последствия дела рук своих, понимая, как эти ошибки отражаются на доходах предприятия, а значит, и на их собственных – многие обретают живой интерес к качественной разработке и новым методам тестирования.
Автор: Егор Изотов, эксперт департамента архитектуры стратегических проектов центра противодействия кибератакам Solar JSOC, ГК "Солар"