Привет, Хабр! Меня зовут Иван, я инженер по информационной безопасности в департаменте разработки общей платформы компании YADRO. Я занимаюсь фаззинг-тестированием уже два года, через мой фаззинг прошло много кода на языках C и Go.
В этой статье будет и теория, и практика. Сначала разберемся, как устроен фаззинг, его алгоритмы и при чем тут ГОСТ. Затем я расскажу, как написать инструмент для фаззинг-тестирования проектов на Go. В практической части подробно опишу процесс разработки и покажу примеры кода, так что используйте статью как инструкцию.
Недушная теория про фаззинг
Договоримся о терминологии
Фаззинг — техника автоматического тестирования, при которой на вход исследуемого объекта подаются случайные, неожиданные или специальные ошибочные данные, которые код, скорее всего, не ожидает.
Корпус — набор тестовых вводов, которые генерируют фаззер.
Цель для фаззинга — исследуемый код, приложение или сервис.
Фаззинг-тест — обертка, позволяющая передавать входные данные на вход цели и запускать процесс тестирования.
Покрытие — некоторое количественное измерение кода, который был вызван в результате запуска тест-кейсов из корпуса. Покрытие обычно измеряется в lines-of-code. Чем больше строчек кода покрыто, тем успешнее считается фаззинг.
Как устроен фаззинг
В основе процесса фаззинга лежат три сущности:
Фаззинг-движок (генератор, мутатор или все вместе), который генерирует входные данные для тестирования.
Фаззинг-тест, сама обертка для передачи данных в исследуемый код и вызова интересующих нас функций.
Сборщик покрытия, который отслеживает, какие части кода были выполнены во время тестирования.
Эти основные компоненты вместе составляют фаззер, который подключается к заранее инструментированному коду. После запуска и определенного времени работы мы получаем входные данные в виде отчета о падениях или зависаниях и информации о покрытии. Эти результаты можно объединить в общий отчет и передать специалистам по безопасности для анализа. Важно учитывать, что сбор покрытия осуществляется с помощью встроенных меток, которые функционируют как бинарные флаги: true или false. Когда какой-либо участок кода выполняется хотя бы один раз, соответствующий флаг автоматически переключается в состояние true, и этот участок считается покрытым.
Алгоритмы фаззинга
Алгоритмы можно классифицировать по реализации трех основных инструментов, взаимодействие которых рассмотрели выше. В зависимости от степени осведомленности о строении кода алгоритмы и сам фаззинг можно разделить на три вида:
Black-box-фаззинг — «режим черного ящика». Подразумевает, что мы не имеем представления о коде, который исследуем, и можем взаимодействовать с ним только через инструментацию при сборке и наблюдать за кодами возврата на определенных элементах корпуса.
White-box-фаззинг — «режимом белого ящика». Предполагает, что мы знаем все о структуре и путях исполнения исследуемого кода. Внутри фаззинг-теста можно настраивать сложные сценарии взаимодействия с исследуемым кодом, чтобы управлять поведением кода и достигать определенных точек, которые еще не покрыты.
Grey-box-фаззинг — усредненный подход. Он предполагает описание общего фаззинг-теста и использование мутатора с функцией обратной связи, чтобы достигать точки в еще не покрытом коде.
Еще фаззеры классифицируются по реализации:
Генерационный алгоритм — подразумевает, что фаззер просто генерирует поток данных по описанным в нем базовым правилам.
Мутационный алгоритм — требует наличия некоторых исходных данных в корпусе. Этот алгоритм будет не просто использовать имеющиеся данные, но и изменять их с помощью различных стратегий, описанных в реализации фаззера, чтобы получать новые данные корпуса.
Комбинированный алгоритм — объединяет стратегии первых двух алгоритмов и использует их попеременно или в зависимости от описанных в реализации фаззера правил
И по типу связи:
С обратной связью — поведение фаззера и его стратегия генерации новых данных корпуса будет меняться в зависимости от поведения кода и росте покрытия. Эту информацию он будет извлекать из инструментации и сборщика покрытия.
Без обратной связи — а такие фаззеры генерируют случайные тесты.
В современном мире используются в основном grey-box фаззеры с комбинированным алгоритмом и обратной связью. Промышленные стандарты в этой области — фаззеры Libfuzzer и AFL/AFL++.
Зачем нам (и вам) фаззинг-тестирование
Если не ради покрытия или увеселения?
ГОСТ
Заниматься фаззинг-тестированием нужно в рамках сертификации продукта. Согласно ГОСТу 16939, любой продукт, попадающий на российский рынок, должен пройти процедуру сертификации, которая как раз включает проведение фаззинг-тестирования. Фаззинг-тестирование там регулируется тремя критериями:
Не менее 80% строк кода в покрытии.
Фаззинг-цель должна отработать не менее полутора миллионов итераций генерации корпуса.
Приемлемое покрытие должно стабильно держаться в течение как минимум двух часов.
Добавлю и несколько неформальных требований:
Новые фаззинг-тесты пишутся каждый релиз.
Процесс фаззинг-тестирования можно добавить в существующие пайплайны тестирования, к написанными в вашей команде unit-тестами.
Дополнительное тестирование
Фаззинг можно использовать, чтобы повысить качества собственного кода. Очевидно, что даже команда из самых крутых тестировщиков и разработчиков физически не может покрыть все кейсы и предусмотреть все возможные сценарии тестирования. Тут фаззинг пригодится и как простой генератор тест-кейсов, и как инструмент для поиска скрытых дефектов, что вручную может занять много времени.
Автоматизация регулярного тестирования
Фаззинг может стать мощным инструментом в умелых руках. Но чтобы его эффективно использовать, нужно определиться с ожиданиями от процесса. Стоит учесть, что обычно на прохождение сертификации дается ограниченное время, — примерно месяц, иногда больше, иногда меньше. И тут встает вопрос автоматизации.
Когда мы говорим про постоянное автоматизированное проведение фаззинг-тестирования, формируются два подхода к процессу:
Формальный подход — фокус на выполнении требований ГОСТа и достижении целей по покрытию. Тут фаззинг работает в режиме черного ящика: качество кода не повышается, а тестирование запускается до тех пор, пока не получится нужный процент покрытия. Это становится вредной нагрузкой на умы несчастных инженеров и железо: мощный инструмент используют для ограниченного пула задач, а другие преимущества сознательно игнорируют.
Аналитический подход — фокус на качестве кода и анализе, что именно мы фаззим и что было нафажжено в процессе. В рамках этого подхода и создаем специальные инструменты, которые облегчают жизнь не только отдельным инженерам, но и всей команде в целом.
Прежде чем посмотреть, как фаззинг применяется на практике, хочу договориться еще об одном важном понятии.
Поверхность атаки — это список функций внутри исследуемого кода, которые можем эксплуатировать с помощью внешних воздействий. Например, в нее могут входить методы для взаимодействия с файловой системой, пользовательским вводом или сетью.
Поверхность атаки обычно строится исходя из доступности функций кода, вероятности эксплуатации типовых уязвимостей и уровня привилегий пользователя. В худшем сценарии все функции на поверхности атаки подлежат полному исследованию и, как следствие, становятся предметом фаззинг-тестирования.
В бой!
В качестве примера возьмем такую функцию из одного нашего сервиса:
func (s Server) Get(_ context.Context, request cyp_rbac.MatrixServiceGetRequest) (cyp_rbac.MatrixServiceGetResponse, error) {
record, err := s.RBACService.MatrixByID(rbac.PairKey{
RoleID: request.RoleId,
ObjectID: request.ObjectId,
})Эта функция обрабатывает get-запрос, который из матрицы ролей доступа получает ID объекта и ID роли, чтобы в дальнейшем, например, проверить, имеет ли пользователь с определенной ролью доступ к этому объекту. Казалось бы, все очень просто. Но если этот код писал не ваш фаззинг-инженер (скорее всего, это не так), то перед запуском фаззинга стоит задуматься над рядом вопросов. Уже на основе этой информации сформируем фаззинг-тест, однако стоит узнать следующее:
Что представляет собой
s.RBACService.MatrixByID?Что содержит эта структура?
Какой тип данных должен быть у
RoleIdиObjectId?Какие поля у запроса
request?
Ответ на эти вопросы можно найти легко с помощью переменной окружения env “GOSSAFUNC=(Server).Get”. Ее использование при вызове go build переключает нас в промежуточное представление кода. И тут почти сразу видим, что request roleid и request objectid на самом дел�� — просто поля с типом uint32.

Поскольку уже известно, какие типы данных используются в запросе, подстроим фаззинг-тест так, чтобы создавать только полезную нагрузку и генерировать, например, uint32-значения. Зачем? Потому что строки, массивы, бинарные данные, в исследовании ничем не помогут, зато подогреют систему миллиардами итераций.
Попробуем написать для этого простенький тест:
func doFuzzGet(~//~) { call(~//~)
}
func FuzzGet(f *testing.F) {
s := suite.New()
defer s.Close()
f.Fuzz(~//~){
doFuzzGet(~//~) }
}Это пример с минимальным набором возможностей:
Тест умеет вызывать исследуемый метод get, в который прокинуты все нужные параметры и типы данных с помощью функции
doFuzzGet.В тесте заранее происходит инициализация окружения. Поскольку мы работаем с микросервисной архитектурой, нам нужно инициализировать или подменять соседние сервисы, с которыми код будет взаимодействовать.
В тест добавлена функция для сбора покрытия.
И это все, конечно, хорошо. Но когда на поверхности атаки таких функций не пять, а пять десятков, начинает казаться, что было бы неплохо применить инструмент автоматизации. Тут в голову приходит одна модная молодежная языковая модель, но я покажу, почему это не лучший подход.
f.Fuzz(~//~) {
if req == nil{ t.Skip()
}
s := Server{}
resp, err := s.Get(~//~)
if err != nil {return}
if resp == nil{ t.Error("~//~")
} }Это тот же самый тест, который сгенерировала мне модель для того же самого метода get. И сколько бы я ни пытался менять параметры запроса, лучшего результата добиться не смог.
Обратите внимание: тест верный, но никакой содержательной информации он не дает. Во-первых, код абсолютно не читаем, что полностью убивает возможность его отладить, если в процессе фаззинга что-то пойдет не так. Во-вторых, мы теряем возможность настройки с помощью различных вспомогательных функций и переменных окружений, которые раньше были добавлены в сьют, который в тесте инициализировал окружение. Поэтому напрашивается очевидный вывод: проще будет писать эти тесты вручную и каким-то образом их размножать. Каким образом их научился размножать я, расскажу чуть позже.
Совместимы ли вообще языковые модели и фаззинг?
Подумаем, что можно оптимизировать уже на этом этапе. Сразу в голову приходит возможность генерировать начальный корпус до запуска самого процесса фаззинга. Если запускать процесс фаззинг-тестирования с уже имеющимся набором входных данных, то сэкономим время на генерацию новых вводов и поиск первых путей исполнения. Можно, конечно, использовать уже имеющиеся данные из unit-тестов, но как добиться разнообразия данных, чтобы старт был максимально эффективным?
И вот тут уже могут пригодиться языковые модели. Я взял уже написанные тестовые файлы от нашей команды и придумал небольшой промпт для модели:
Сгенерируй содержимое допустимого файла для '{args.prompt}’. Эта строка просит модель сгенерировать набор данных для аргумента (заранее предоставленного кода исследуемого метода). В качестве аргумента здесь подойдет любое формальное или неформальное описание метода, который уже исследовали.
Ниже приведены несколько примеров других файлов, которые нужно использовать как образцы, но твои данные не должны их повторять. Эта фраза указывает модели на референсы, которые я позаимствовал из написанных ранее кейсов.
Твои данные должны быть реалистичны и разнообразны, по возможности используй разные типы и форматы данных. С помощью этого указания просим модель делать данные максимально разнообразными, использовать разные типы и форматы данных. Это важно, поскольку обычно если просишь модель сгенерировать, например, квадрат, она генерирует квадрат, квадрат побольше и квадрат поменьше. И в этом она абсолютно права, но это не подходит, когда с помощью такой генерации хотим п��лучить разнообразные данные.
Предоставь только содержимое только что сгенерированных файлов и не предоставляй пояснений или сопроводительной информации. Этой строчкой обходим главную проблему использования языковых моделей. Обычно модели помимо четкого ответа на вопрос генерируют дополнительный «смысловой сахар», который при генерации корпуса будет только мешать. Нас это не устраивает, поэтому я просто отбрасываю эту шелуху и прошу минимизировать свои комментарии.
Таким образом, из небольшого набора в десять кейсов с простыми запросами для исследуемого метода можно получить около тысячи кейсов, которые содержат не только валидные запросы, но и какие-то случайные данные. Благодаря этому при запуске фаззинга мы начинаем не с пустого корпуса, а с минимально валидного набора данных. Это немного ускорит начальные итерации.
Мы написали свою тулу для фаззинга
Как она выглядит:

На схеме видим исследуемый сервис, в котором находится метод get, протоописание и сам код этого метода. Это все объединяется в фаззинг-тест, который я показывал ранее. Тут уже есть первый небольшой фокус: мы можем использовать proto для описания фаззинг-тестов с привязкой к нужным методам, после чего размножать их с помощью генератора.
Затем происходит некоторая магия. Мы не используем фаззер Go, потому что в реальности по общей классификации он все еще остается black-box-фаззером без обратной связи — случайно генерирует числа, которые когда-нибудь попадут в исследуемый метод. А нам хотелось бы это все ускорить, поэтому воспользуемся наиболее продвинутым инструментом — подключим к коду движок от Libfuzzer.
Чтобы присоединить туда движок, используем возможности External C-вставок. Мы не просто собираем код в Go-бинарник. С помощью магии вставок и опций go build собираем его как статический С-бинарник, к которому прилинковываем фаззинг-движок, и в результате получаем стандартный бинарник для Libfuzzer. Мы можем использовать полученный бинарник для классического запуска фаззинга со сбором отчета по покрытию.
Как я уже говорил ранее, мы можем размножать тесты через протогенерацию, потому что есть протоописание и код методов. В моем инструменте я просто дописал свой генератор, который будет писать тесты за меня. Единственное, над чем тут нужно сначала подумать, — как выглядит шаблон, из которого будем порождать полезные тесты.
Пишем шаблоны и сьют
func New() *Suite {
s := &Suite{
id: ulid.Make(),
}
infoManager, buildInfo, versionInfo := settings.InitVersionBuildInfo() cfg := settings.InitAppConfig(buildInfo)
s.dacSvc = dac.New(cfg.Dac.Address)
go s.dacSvc.Serve()
s.rbacSvc = app.NewApp(~//~)
go s.rbacSvc.Serve(context.Background())
return s
}Сначала нужно подумать про инициализацию окружения и написать какой-нибудь базовый сьют, который будет его поднимать. Начнем с того, что создадим стандартный пустой сьют. Затем добавим в него версионирование, потому что нам было бы полезно знать, какую конкретно версию сервиса исследовать. После этого добавляем вызовы для конфигурации, старта всех связанных сервисов и затем запуска самого исследуемого сервиса.
Пишем builder
Следующим шагом нам нужно научиться собирать Go-код в статическую C-библиотеку. Для этого воспользуемся не стандартным go build, а дополнительной оберткой над ним под названием Go-118-Fuzz-Build, которая как раз это все и провернет у себя под капотом.
func go118FuzzBuild(dir, iface, method string, log *slog.Logger) error { cmd := exec.Command(
go118FuzzBuildCommand,
"-tags=debug",
"-func=Fuzz"+iface+method,
"-o", filepath.Join(dir, "fuzzbuild", "Fuzz"+iface+method+".a"), dir,
)
log.Debug("building", "cmd", cmd.String())
if out, err := cmd.CombinedOutput(); err != nil {
log.Error("go-118-fuzz-build", "error", out)
return fmt.Errorf("go-118-fuzz-build: %w", err) }
return nil }Для этого воспользуемся функцией exec.Command для ее вызова, поскольку Go-118-Fuzz-Build работает как command-line-утилита. Пробрасываем все необходимые аргументы и пишем мощную обработку ошибок, чтобы, если что-то пойдет не так, потом было проще разбираться в коде теста.
Пишем linker
Пора к статическому нечто прилинковать движок Libfuzzer. Для этого используем Clang и через ту же конструкцию exec.Command передаем аргументы и обрабатываем ошибки.
func link(clang, dir, iface, method string, log *slog.Logger) error { cmd := exec.Command(
clang,
"-g",
"-O0",
"-fsanitize=fuzzer",
filepath.Join(dir, "fuzzbuild", "Fuzz"+iface+method+".a"),
"-o", filepath.Join(dir, "fuzzbuild", "Fuzz"+iface+method+".fuzz"),
)
return nil }Пишем runner
Теперь нужно научиться все это автоматически запускать, чтобы не дергать каждый получившийся бинарник по очереди. Все это также можно удобно сделать через exec.Command. В этом простом примере будем вызывать их по кругу, передавать необходимые аргументы, которые мы заранее подготовили, исходя из требований к процессу фаззинга. Например, из ГОСТа известно: тест должен работать два часа. Тогда мы выставляем опцию timeout на нужное время. Или нужно сделать тысячу пробегов. В таком случае выставим опцию runs на 1000. И после этого запускаем соответствующий бинарник.
Важно отметить, что все эти опции имеют тот же вид, что и опции самого Libfuzzer, поэтому они будут понятны используемому фаззинг-движку без дополнительных оберток.
cmd := exec.Command(
filepath.Join(dir, "fuzzbuild", "Fuzz"+iface+method+".fuzz"),
"-rss_limit_mb="+strconv.Itoa(rss), "-timeout="+strconv.Itoa(timeout), "-max_len="+strconv.Itoa(maxLen), "-max_total_time="+strconv.Itoa(maxTime), "-runs="+strconv.Itoa(runNum),
targetDir,
)Пишем Coverage Collector
Собираем покрытие. Тут стоит учитывать один нюанс: метки покрытия в С-коде и Go-коде несовместимы. Поэтому собрать итоговое покрытие с помощью стандартных средств Libfuzzer у нас не выйдет.
Поскольку у нас уже есть ранее сгенерированные тесты на Go и данные из корпуса, которые сгенерировал Libfuzzer, мы можем обойти это ограничение, воспользовавшись обычным вызовом go test-cover для сбора покрытия фаззинг-тестов. И после этого с помощью стандартного go tool cover превратить покрытие в читаемый HTML.
cmd := exec.Command(
"go",
"test",
"-cover",
"-v", "-coverprofile="+targetProfile+".out", "-coverpkg="+targetPkg,
targetDir, )
cmd = exec.Command(
"go",
"tool",
"cover", "-html="+targetProfile+".out", "-o="+targetProfile+".html",
)Каким вышло покрытие
По результатам работы для того самого метода get, который мы исследовали, получаем, что код полностью покрыт. Значит, фаззинг прошел успешно:

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

Проблема в том, что появилось значение -1 в поле ролей. Казалось бы, как у нас может быть отрицательная роль? Когда проектировали архитектуру, предполагали, что значения будут варьироваться от нуля до положительного n. Но, как выяснилось, в коде не было проверки на отрицательные значения как раз из-за подобного допущения. Это некорректное поведение фаззер благополучно обнаружил, а мы тут же исправили.
В результате мы смогли достичь всех целей, которые наметили изначально: у нас есть автоматический инструмент для фаззинга, мы можем в любой момент отдать хорошие отчеты на сертификацию. И даже улучшили качество собственного кода, закрыв неочевидный дефект.