Skip to content
Опубликовано: 2022-01-30
Теги: go fuzz fuzzing test

Фаззинг(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...

Здесь мы видим:

Исследование результатов тестирования

В процессе работы 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

Заключение

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

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

Источники информации