Фаззинг(fuzzing) тестирование
Оглавление
Что такое фаззинг тестирование?
Немного о тестах
Для того чтобы убедиться в том, что программа работает корректно, и ее реальное поведение соответствует ожидаемому поведению создаются тесты[1]. Тестирование также может обеспечить объективный, независимый взгляд на программное обеспечение, чтобы позволить бизнесу оценить и понять риски его внедрения.
Существует большое количество видов и методов[1] тестирования, которые отличаются объектом тестирования, знанием строения системы, степенью автоматизации и прочими характеристиками.
Что такое фаззинг тестирование
Фаззинг это метод автоматического тестирования программного обеспечения, который заключается в предоставлении некорректных, неожиданных или случайных данных в программу и ее функции[2]. В процессе тестирования отслеживается поведение программы, например, чтобы она не выбрасывала исключений, не пыталась завершиться с ошибкой или не возникло утечки памяти.
История фаззинг тестирования
Методы тестирования похожие на современный фаззинг были придуманы еще в 1950-х годах. По словам[3] Джеральда Вайнберга это была стандартная практика брать перфокарты из мусорки или перемешивать их в случайном порядке и подавать на вход программе. Таким образом программисты находили странности в поведении программ. В то время таким способом свои программы тестировали все известные ему программисты.
Сам термин “fuzz” появился в 1988 году[5]. В 1991 создается первая программа для фаззинга - crashme, которая проверяла различные системные вызовы в UNIX-системах. В апреле 2012 Google анонсировал ClusterFuzz для фаззинга компонентов браузера Google Chrome, где каждый мог загрузить свой фаззер и попытаться найти ошибку. В 2014-2015 появляются фаззеры AFL, libFuzzer, go-fuzz и с их помощью было выявлено множество ошибок безопасности. Самые громкие это Shellshock в Bash и Heartbleed в OpenSSL. В 2016 году Microsoft выпускает Project Springfield, предназначенный для поиска критических уязвимостей. А Google выпускает OSS-Fuzz, который позволяет производить непрерывный фаззинг. В 2018 году появляется техника поиска уязвимостей с помощью фаззинга на процессорах с RISC архитектурой. В 2020 Microsoft выпускает OneFuzz. Это фаззер с открытым исходным кодом, который можно запустить на собственных мощностях.
Зачем нужно фаззинг тестирование?
Повышение покрытия кода тестами. Даже в полностью покрытым unit-тестами коде фаззер может найти кучу ошибок. Фаззер ищет ошибки без предубеждений везде, даже там, где код написал лучший программист и этот код в продакшене ни разу не показывал ошибок. А еще фаззер может подсунуть такой набор данных, про который программист мог и не подумать. Фаззер легко использовать, помогает выполнить тысячи unit-тестов, которые не надо писать.
Что можно тестировать с помощью фаззинга
Фаззинг можно применять к любым функциям, которые обрабатывают сложные данные. Например, библиотеки сжатия и распаковки, парсеры HTTPS и DNS, различные десериализаторы, мультимедиа кодеки, криптографические библиотеки, запросы к базам данных. А также для всего, что принимает данные из внешнего мира, то есть из недоверенных источников.
Типы фаззеров
Существует два основных вида фаззеров по принципу генерации данных:
- случайные (глупые, на основе мутации)
- с учетом покрытия (умные, на основе генерации)
Случайный фаззинг каждый раз генерирует полностью случайные значения никак не зависящие от прошлых тестов. Случайные фаззеры являются более простыми и более быстрыми. Они могут дать хороший результат за малую цену[14].
Фаззинг с учетом покрытия использует результаты прошлых тестов для отслеживания и последующего увеличения покрытия кода[15]. Такие фаззеры могут углубляться в тестируемые данные и для работы таких фаззеров нужны наборы данных подаваемых на вход. Эти данные называются корпус - это минимальный набор тестовых входных данные, которые могут сгенерировать максимальное покрытие кода.
Использование фаззеров в Go
Буду показывать примеры тестирования на простой функции, где есть явная ошибка. Ожидаем ввод длиной три байта, а в итоге делаем обращение и к четвертому байту, который будет за пределами переданного слайса.
package simple
func SimpleFunc(b []byte) bool {
if len(b) == 3 &&
b[0] == 'F' &&
b[1] == 'U' &&
b[2] == 'Z' &&
b[3] == 'Z' {
return true
}
return false
}
gofuzz
Есть официальный фаззер от Google gofuzz. Этот фаззер является случайным(глупым) и каждый раз генерирует случайные данные. Он может генерировать и простые и сложные типы данных, типа мап или структур. Более подробно о том как генерировать сложные типы данных можно найти в примерах в их репозитории.
Здесь же напишем простой тест, который будет пытаться найти проблему. Тест будет
в отдельном файле simple_test.go
.
package simple
import (
"testing"
fuzz "github.com/google/gofuzz"
)
func TestSimpleFunc(t *testing.T) {
f := fuzz.New()
var b []byte
i := 0
defer func() {
if msg := recover(); msg != nil {
t.Errorf("Catched panic after %d iterations with data '%v'.\nMessage: '%v'", i, b, msg)
}
}()
for {
i++
f.Fuzz(&b)
SimpleFunc(b)
}
}
Тест будет выполняться долго, пока не обнаружит проблемный набор данных. За три запуска я получил минимальное время выполнения 27 секунд, а максимальное 331 секунду. В среднем выполняется около 1 миллиона тестов в секунду.
$ go test
--- FAIL: TestSimpleFunc (331.16s)
main_test.go:16: Catched panic after 359250098 iterations with data '[70 85 90]'.
Message: 'runtime error: index out of range [3] with length 3'
FAIL
exit status 1
FAIL example/gofuzz 331.158s
go-fuzz
На данный момент самым популярным пакетом для фаззанга в Go является go-fuzz от Дмитрия Вьюкова из Google.
Этот фаззер работает с учетом покрытия кода, углубляясь в него и генерируя
подходящие данные, чтобы быстрее найти ошибки в функциях. Фаззер генерирует
только []byte
, поэтому надо изменять входные данные, чтобы протестировать
функции принимающие другие типы. Умеет генерировать архив для libFuzzer.
Чтобы протестировать ранее написанную функцию в код надо добавить функцию
func Fuzz(data []byte) int
. Внесем ее в файл fuzz.go
, а также добавим флаг
для сборки, чтобы файл с тестом не попал в продакшен сборку.
//go:build gofuzz
// +build gofuzz
package simple
func Fuzz(data []byte) int {
SimpleFunc(data)
return 0
}
После надо скачать последние версии утилит:
go get -u github.com/dvyukov/go-fuzz/go-fuzz@latest
go get -u github.com/dvyukov/go-fuzz/go-fuzz-build@latest
Дальше запустить go-fuzz-build
, который создаст специальный zip-архив для
тестируемой функции.
После этого запустим саму утилиту тестирования go-fuzz
. Начиная с этого
момента ваша машина начнет работать в качестве обогревателя. go-fuzz
мутирует
данные и как только находит данные, которые вызывали ошибку, то добавляет их в
корпус. Каждые 3 секунды программа показывает процесс выполнения тестирования.
И в нашем случае можно заметить, что ошибка была найдена в первые 6 секунд.
2022/01/08 16:33:47 workers: 8, corpus: 4 (3s ago), crashers: 1, restarts: 1/0,
execs: 0 (0/sec), cover: 0, uptime: 3s
2022/01/08 16:33:50 workers: 8, corpus: 4 (6s ago), crashers: 1, restarts: 1/0,
execs: 0 (0/sec), cover: 7, uptime: 6s
2022/01/08 16:33:53 workers: 8, corpus: 4 (9s ago), crashers: 1, restarts: 1/5246,
execs: 178367 (19817/sec), cover: 7, uptime: 9s
^C2022/01/08 16:33:55 shutting down...
Здесь мы видим:
workers: 8
, количество обработчиков, выполняющих тестирование.corpus: 4 (9s ago)
, количество элементов в корпусе, последний был добавлен 9 секунд назад.crashers: 1
, количество найденных ошибок в ходе тестирования.restarts: 1/5246
, скорость, с которой фаззер перезапускает процессы тестирования. 1 перезапуск на 5246 тестов.execs: 178367 (19817/sec)
, общее количество выполненных тестов и их примерное количество за секунду.cover: 7
, количество установленных бит в специальной мапе, которая считает покрытие кода. Если это число растет, значит фаззер находит новые непротестированные строки.uptime: 9s
, время выполнения теста.
Исследование результатов тестирования
В процессе работы go-fuzz
создает три директории. Две директории crashers
и
suppressions
содержат найденные ошибки. В директории crashers
содержатся
уникальные ошибки, а в suppressions
повторные ошибки, но приглушенные, чтобы
не отображать их несколько раз.
Посмотрим стектрейсы в crashers
, там находится информация об одной ошибке.
$ ls crashers
0eb8e4ed029b774d80f2b66408203801cb982a60
0eb8e4ed029b774d80f2b66408203801cb982a60.output
0eb8e4ed029b774d80f2b66408203801cb982a60.quoted
Файлы ошибок именуются с помощью sha1 от входных данных. Данные, вызвавшие
ошибку находятся в файле без расширения. Файл ‘.quoted’ содержит данные в виде
строки, чтобы их можно было легко добавить в тестовый файл. Файл ‘.output’
содержит вывод из panic(), когда go-fuzz
решил, что произошла ошибка.
Вот содержимое сохраненной там ошибки:
panic: runtime error: index out of range [3] with length 3
goroutine 1 [running]:
example/go-fuzz.SimpleFunc.func4(...)
/home/sattellite/projects/meetup/fuzzing/go-fuzz/main.go:7
example/go-fuzz.SimpleFunc({0x7f6385b77000, 0xc00009ae98, 0x458f77})
/home/sattellite/projects/meetup/fuzzing/go-fuzz/main.go:8 +0x152
example/go-fuzz.Fuzz({0x7f6385b77000, 0xc00009af10, 0x4c8520})
/home/sattellite/projects/meetup/fuzzing/go-fuzz/fuzz.go:6 +0x37
go-fuzz-dep.Main({0xc00009af68, 0x1, 0x45e0a0})
go-fuzz-dep/main.go:36 +0x15b
main.main()
example/go-fuzz/go.fuzz.main/main.go:15 +0x3b
exit status 2
native fuzzing
В предстоящем релизе go 1.18 будет добавлен нативный фаззер[17]. Теперь для написания фаззинг тестов не понадобится подключать сторонние библиотеки. Этот фаззер позволяет подавать на вход функциям данные любого типа, также как и gofuzz. А интеграция с системой тестирования позволит писать тесты более эффективно.
Обновимся до dev-версии golang
Если у вас уже установлена версия go1.18, то этот шаг надо пропустить.
$ go install golang.org/dl/gotip@latest $ gotip download
Теперь мы можем использовать последнюю версию golang из git с помощью команды
gotip
.
Фаззинг тесты должны содержаться в *_test.go файле и функция тестирования должна
начинаться с префикса Fuzz. Также надо учесть, что в такую функцию передается
аргумент типа *testing.F
, а не *testing.T
, который передается в функции вида
TextXxx.
Вот как будет выглядеть нативный тест для нашей функции:
//go:build go1.18
// +build go1.18
package simple
import "testing"
func FuzzSimpleFunc(f *testing.F) {
f.Fuzz(func(t *testing.T, data []byte) {
SimpleFunc(data)
})
}
Запустить тест можно с помощью команды gotip test -fuzz=Fuzz
. Фаззер
практически моментально сможет найти проблему в коде.
fuzz: elapsed: 0s, gathering baseline coverage: 0/4 completed
fuzz: elapsed: 0s, gathering baseline coverage: 4/4 completed, now fuzzing with 8 workers
fuzz: minimizing 30-byte failing input file
fuzz: elapsed: 1s, minimizing
--- FAIL: FuzzSimpleFunc (1.13s)
--- FAIL: FuzzSimpleFunc (0.00s)
testing.go:1350: panic: runtime error: index out of range [3] with length 3
goroutine 10810 [running]:
runtime/debug.Stack()
/home/sattellite/sdk/gotip/src/runtime/debug/stack.go:24 +0x90
testing.tRunner.func1()
/home/sattellite/sdk/gotip/src/testing/testing.go:1350 +0x1f2
panic({0x5afea0, 0xc0001d60d8})
/home/sattellite/sdk/gotip/src/runtime/panic.go:838 +0x207
example/native.SimpleFunc(...)
/home/sattellite/projects/meetup/fuzzing/native/main.go:8
example/native.FuzzSimpleFunc.func1(0x0?, {0xc007792308, 0x3, 0x462fb9?})
/home/sattellite/projects/meetup/fuzzing/native/main_test.go:7 +0x193
reflect.Value.call({0x5918a0?, 0x5cbe28?, 0x13?}, {0x5bdded, 0x4}, {0xc0077e06c0, 0x2, 0x2?})
/home/sattellite/sdk/gotip/src/reflect/value.go:556 +0x845
reflect.Value.Call({0x5918a0?, 0x5cbe28?, 0x4f8ba0?}, {0xc0077e06c0, 0x2, 0x2})
/home/sattellite/sdk/gotip/src/reflect/value.go:339 +0xbf
testing.(*F).Fuzz.func1.1(0x0?)
/home/sattellite/sdk/gotip/src/testing/fuzz.go:332 +0x20b
testing.tRunner(0xc0077e8680, 0xc0077d5a70)
/home/sattellite/sdk/gotip/src/testing/testing.go:1440 +0x102
created by testing.(*F).Fuzz.func1
/home/sattellite/sdk/gotip/src/testing/fuzz.go:321 +0x5b8
Failing input written to testdata/fuzz/FuzzSimpleFunc/416d2ae706dfc1700467409db4169c924a464b7bef24588fc7643c4e03424362
To re-run:
go test -run=FuzzSimpleFunc/416d2ae706dfc1700467409db4169c924a464b7bef24588fc7643c4e03424362
FAIL
exit status 1
FAIL example/native 1.135s
В результатах выполнения видим, что строится покрытие кода, запускается на 8 воркерах и где-то падает. Если углубиться в стектрейс, то можно найти файл, строку и функцию где произошла ошибка. А в самом конце есть команда для повторения конкретно этой ошибки.
В фаззинг тесты можно добавить изначальные данные для корпуса, которые можно
считать занчениями по умолчанию[18]. Добавить эти данные можно
с помощью функции (*testing.F).Add
. Добавляемые данные должны строго совпадать
с типами, которые передаются в тестируемую функцию. Фаззер поддерживает только
основные типы данных, если необходимо использовать сложные типы, то можно
вызывать фаззер с базовыми типами для сложных типов и каждый раз создавать
структуру, заполняя ее сгенерированными данными.
Во время выполнения фаззинг-тестов, если тест завершается ошибкой или паникой,
то его данные добавляются в корпус в директорию testdata
. Так же результат
показывает команду, с помощью которой можно вызывать конкретный набор данных
вызывавших ошибку в функции.
Мой пример тестов слишком упрощенный, но в блоге go есть полный пример нахождения ошибки и ее дальнейшего исправления - https://go.dev/doc/tutorial/fuzz
Заключение
Написание фаззинг тестов очень простое и не вызывает трудностей, достаточно вызывать тестируемую функцию внутри теста. А новая версия языка сделает использование таких тестов еще более простым.
Но не стоит забывать, что фаззинг тесты не заменят другие виды тестов, не заменят правильную архитектуру и корректную обработку входящих данных.
Источники информации
- https://ru.wikipedia.org/wiki/Тестирование_программного_обеспечения
- https://ru.wikipedia.org/wiki/Фаззинг
- http://secretsofconsulting.blogspot.com/2017/02/fuzz-testing-and-fuzz-history.html
- https://ru.wikipedia.org/wiki/Вайнберг,_Джеральд
- https://youtu.be/EJVp13f_aIs
- https://google.github.io/clusterfuzz/
- https://en.wikipedia.org/wiki/American_fuzzy_lop_(fuzzer)
- https://llvm.org/docs/LibFuzzer.html
- https://github.com/dvyukov/go-fuzz
- https://en.wikipedia.org/wiki/Shellshock_(software_bug)
- https://en.wikipedia.org/wiki/Heartbleed
- https://github.com/google/oss-fuzz
- https://github.com/microsoft/onefuzz
- https://www.f-secure.com/en/consulting/our-thinking/15-minute-guide-to-fuzzing
- https://google.github.io/clusterfuzz/reference/coverage-guided-vs-blackbox/
- https://github.com/google/gofuzz
- https://go.dev/blog/fuzz-beta
- https://pkg.go.dev/testing@master#hdr-Fuzzing