Fuzzing-тесты в Go после v1.18: знакомство и практика

Fuzzing-тесты в Go после v1.18: знакомство и практика

Владислав Ефименко Владислав Ефименко Главный редактор 29 января 2023

Рассказываем, как устроено fuzzing-тестирование в Go, и как проверить функцию на корректную валидацию данных.

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

Когда говорят о Go 1.18, обычно вспоминают про дженерики и незаслуженно забывают об остальных изменениях. Например, о fuzzing-тестировании, которое раньше можно было запустить только с помощью открытых библиотек. Пора это исправить.

По мотивам выступления Сергея Петрова, разработчика в Selectel, рассказываем, как устроено fuzzing-тестирование в Go. А также показываем, как проверить функцию на корректную валидацию данных.

Что такое fuzzing-тестирование?

В 1988 году профессор Бартон Миллер дал студентам задание: нужно было найти баги внутри утилит Unix. Один из вариантов — подавать на вход рандомные данные и наблюдать за поведением программы. На удивление, около 30% утилит удалось таким способом «сломать» — все из-за неправильной обработки входных данных. Вот такое небольшое открытие, задокументированное в 1990 году, послужило началом fuzzing-тестирования.

Сергей Петров, разработчик:

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

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

Как устроен Fuzzing в Go?

Немного ретроспективы. В стандартном фреймворке для тестирования есть специальные типы тестов — Test и Benchmark.

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


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


Вот ключевые моменты, которые важно запомнить для подготовки fuzzing-тестов:

  • Название метода нужно записывать через приставку Fuzz — например, FuzzTest, FuzzBug или FuzzFoo. Иначе Go инициализирует обычный unit-тест.
  • В качестве параметра необходимо передать указатель на testing.F. Этот параметр нужен, чтобы «связать» тест с кодом программы.

Теперь посмотрим, как реализовать fuzzing-тестирование на практике.

Запускаем свой первый fuzzing-тест

Представьте: вы написали функцию Reverse, которая возвращает строку в обратном порядке.

// Реализация функции Reverse
func Reverse(s string) string {
	b := []byte(s)
	for i, j := 0, len(b) - 1; i < len(b)/2; i, j = i+1, j-1 {
		b[i], b[j] = b[j], b[I]
	}
        return string(b)
}

Хотя простой unit-тест скажет, что с функцией Reverse все хорошо, есть много пограничных вариантов, которые не рассмотрены. Среди них — многобайтные символы вроде иероглифов, которые побайтово «развернуть» нельзя.

// Unit-тест функции Reverse
func TestReverse(t *testing.T) {
	if Reverse("test") != "tset" {
		t.Errorf("Reverse: %q, want %q", rev, "test")
	}
}

Вместо того, чтобы вручную придумывать различные тест-кейсы, поручим работу машине и напишем fuzzing-тест.

1. Говорим фреймворку, что нужно сгенерировать случайную строку и передать ее в функцию Reverse.

func FuzzReverse(f *testing.F) {
	f.Fuzz(func(t *testing.T, orig string) {
		rev := Reverse(orig)
	})
}

2. Для валидации результата проверяем, что при повторном вызове Reverse получается исходная строка.

func FuzzReverse(f *testing.F) {
	f.Fuzz(func(t *testing.T, orig string) {
		rev := Reverse(orig)
		doubleRev := Reverse(rev)

		if orig != doubleRev {
			t.Errorf("Before: %q, after: %q", rev, doubleRev)
		}
	})
}

3. Проверяем, что Reverse возвращает валидный unicode, как и исходная строка. 

func FuzzReverse(f *testing.F) {
	f.Fuzz(func(t *testing.T, orig string) {
		rev := Reverse(orig)
		doubleRev := Reverse(rev)

		if orig != doubleRev {
			t.Errorf("Before: %q, after: %q", rev, doubleRev)
		}

		if utf8.ValidString(orig) && !utf8.ValidString(rev) {
			t.Errorf("Reverse produces invalid UTF-8 string %q", rev)
		}
	})
}

4. Запускаем fuzzing-тест с помощью стандартной команды и аргумента -fuzz.

Супер — Fuzzing нашел строку с двухбайтным unicode-символом. И если развернуть такую строку побайтово, получится последовательность, которая не будет валидной.

Теперь, если мы запустим тест даже без параметра -fuzz, он будет проверять все ранее сгенерированные строки. Они сохраняются в папке testdata/fuzz/<название теста>, которую можно, например, закоммитить в свой репозиторий, чтобы использовать в тестировании других программ.

Особенности Fuzzing в Go

Давайте заглянем под капот и выделим особенности в работе с Fuzzing в Go.

Fuzzing работает только на популярных архитектурах

Разработчики Go не стали изобретать ничего нового и используют libfuzzer — библиотеку на C, в которой функции для Fuzzing уже давно реализованы. Но libfuzzer можно запустить не на всех операционных системах и процессорных архитектурах. Например, на ARM7 Fuzzing работать не будет.

Скриншот кода из platform/supported.go

Кстати, для работы с методами C разработчики используют не CGO, а чистый ассемблер — так, специфичный код, написанный под конкретную библиотеку, работает на порядок быстрее более общего подхода. Если вам интересно, как это реализовано, изучите следующие файлы:

  • src/runtime/libfuzzer_amd64.s
  • src/runtime/race_amd64.s

Fuzzing использует PCG-генератор

Стандартный math/rand похож на генератор случайных чисел лишь издалека: если посмотреть поближе, можно увидеть закономерности. То есть за пределами unit-тестов нельзя использовать math/rand — это чревато проблемами с безопасностью.

Для генерации «нормальных» случайных чисел можно использовать crypto/rand — модуль, который обращается за случайными байтами к операционной системе. Такой алгоритм работает гораздо медленней, чем math/rand. Поэтому crypto/rand тоже не подходит для fuzzing-тестирования.

Разработчики интегрировали PCG — быстрый и простой генератор псевдослучайных чисел, при этом менее предсказуемый по сравнению с math/rand. PCG лежит в основе модуля internal/full/mutator. Последний запрашивает у алгоритма случайные числа и преобразует их в нужные для fuzzing-теста данные. Рассмотрим пару примеров.

Генерация булевых значений. Здесь ничего сложного: скрипт генерирует булево значение случайным «подбрасыванием кубика».

// v — предыдущее значение параметра
case bool:
	if m.rand(2) == 1 {
        	vals[i] = !v // 50% chance of flipping the bool
  	}

Генерация чисел. Казалось бы, зачем преобразовывать случайные числа в случайные числа. На самом деле, случайность чисел PCG используют, чтобы генерировать другие случайные значения в окрестностях, на которых можно «словить» баги. Это могут быть как степени двойки, так и стандартные числа вроде 0 и -1 — неважно. Распределение случайных чисел выглядит примерно так:

Mutator, распределенные псевдослучайных чисел.

Под капотом это работает так: для целых чисел программа выбирает действие — прибавить или вычесть. После генерирует случайное число в окрестности локального максимума (maxValue) таким образом, чтобы результат не превысил максимальное значение (max). Таким образом, например, uint8 никогда не превысит максимальное значение данного типа — 255.

switch m.rand(2) {
case 0:
	// Add a random number
        if v >= maxValue {
        	continue
    	}
        if v > 0 && maxValue-v < max {
        	// Don’t let v exceed maxValue
       	       max = maxValue-v
    	}
        v += int64(1 + m.rand(int(max)))
        return v
case 1:
        // Substrack a random number
        if v <= maxValue {
        	continue
    	}
    	if v < 0 && maxValue+v < max {
        	// Don’t let v drop below -maxValue
        	max = maxValue + v
    	}
   	v -= int64(1 + m.rand(int(max)))
    	return v
}

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

Так программа генерирует строку с помощью цепочки из случайных операций.

Fuzzing не поддерживает работу со структурами

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

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

Fuzzing поддерживает минимизацию данных

Представьте, что функция, которую вы тестируете, падает при обработке больших текстов вроде «Войны и Мира». Нужно как-то минимизировать размер генерируемых данных, чтобы упростить процесс отладки. Для этого Fuzzing использует несколько операций:

  • cut the tail — удаляет «хвост» строки,
  • remove byte — удаляет случайный байт, 
  • remove subset — удаляет несколько случайных байтов,
  • replace with printable — заменяет все непечатаемые символы на печатаемые. 

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

Реальные примеры использования Fuzzing

Интересный факт: первыми пользователями Fuzzing стали сами разработчики. С помощью него они нашли и исправили баг с парсингом unicode внутри библиотеки Time.

Подробнее о реальных кейсах использования Fuzzing можно узнать из доклада.