Мне всегда казалось, что фаззинг (fuzzing) один из самых недооценённых инструментов в Go-тестировании. Юнит-тесты и табличные тесты проверяют случаи, которые вы придумали и подтверждают заложенное поведение функций. Фаззер же генерирует входные данные. И благодаря этому находит те кейсы, до которых вы бы сами не дошли, а обычные тесты пропустили бы. В этой статье я разберу, как устроен фаззер встроенный в Go, и покажу на реальном примере, как с помощью фаззинга я нашёл баг в популярной библиотеке sonic от ByteDance и завёл по нему issue #938.
Почему обычные тесты пропускают такие баги
Когда вы пишете тест, вы выбираете входные данные сами. Логика проста: «вот граничные случаи, вот типичные значения, вот пара очевидных вариантов из описания задачи». Этим способом можно проверить ожидаемое поведение. Но нельзя обнаружить баги и уязвимости, для которых нужны входные данные, не пришедшие вам в голову.
Простой пример: функция, которая нормализует строку перед сравнением. Вы тестируете её на пустой строке, на строке с пробелами, на UTF-8, на разном регистре. Тесты зелёные. А в проде функция падает на строке, в которой есть невидимый символ U+202E (Right-to-Left Override). Вы про него не подумали т.к. это довольно редкий кейс, а поэтому и теста на него не было.
Фаззер закрывает этот пробел. Он не следует интуиции и здравому смыслу. Он генерирует псевдослучайные входы и смотрит, какие пути в коде они задействуют. В каком-то смысле мы проверяем работу функции или модуля методом «грубой силы» пропуская через тестируемый участок огромные объёмы разнообразных входных данных. Если какой-то путь исполнения кода по строкам встречается впервые, то фаззер сохраняет вход в корпус и пробует его модифицировать дальше. Когда находятся вводные, которые вызывают панику или нарушают заданный инвариант, то фаззер останавливается и показывает их вам.
Это coverage-guided фаззинг (то есть направляемый покрытием). В Go он встроен в go test с версии 1.18.
Анатомия Fuzz-функции
Fuzz-функция начинается с Fuzz и принимает *testing.F:
package validate
import "testing"
func FuzzNormalize(f *testing.F) {
// Сид-корпус — стартовые входы, от которых фаззер начинает мутировать
f.Add("")
f.Add(" ")
f.Add("hello")
f.Add("HELLO")
f.Add("café")
// f.Fuzz запускает функцию на каждом входе.
// Сигнатура второго аргумента задаёт типы, которыми фаззер будет
// генерировать данные: тут — string.
f.Fuzz(func(t *testing.T, s string) {
a := Normalize(s)
b := Normalize(a) // нормализация должна быть идемпотентной
if a != b {
t.Errorf("не идемпотентно: Normalize(%q) = %q, Normalize(%q) = %q", s, a, a, b)
}
})
}
Здесь проверяется инвариант: Normalize(Normalize(s)) == Normalize(s). Если фаззер найдёт строку, для которой это неверно, он зафиксирует её как падающий тест.
Запуск:
$ go test -fuzz=FuzzNormalize -fuzztime=30s
fuzz: elapsed: 0s, gathering baseline coverage: 0/5 completed
fuzz: elapsed: 1s, gathering baseline coverage: 5/5 completed, now fuzzing with 8 workers
fuzz: elapsed: 3s, execs: 352781 (117573/sec), new interesting: 12 (total: 17)
...
Что важно понять:
- Сид-корпус — стартовые точки. От них фаззер мутирует данные: меняет байты, удваивает фрагменты, переставляет, увеличивает длину. Чем разнообразнее сид-корпус, тем быстрее фаззер находит новые пути.
-fuzzвключает фаззинг конкретной функции. Без негоgo testпрогонит fuzz-функцию только на сид-корпусе как обычный регрессионный тест.-fuzztimeограничивает время. Без него фаззер будет работать до завершения через Ctrl+C.- Параллелизм по умолчанию — по умолчанию запускается максимально доступное количество воркеров, равное
GOMAXPROCS. При желании можно задать-parallel=4.
Корпус и регрессии
Когда фаззер находит падающий вход, он сохраняет его в testdata/fuzz/<FuzzName>/<hash>:
testdata/
└── fuzz/
└── FuzzNormalize/
└── 6a77cebe466af20f
Этот файл нужно закоммитить в репозиторий. При следующем go test (уже без флага -fuzz) он прогонится как обычный тест: фаззер превращает находку в регрессию автоматически. Если вы исправили баг, тест становится зелёным; если регрессия вернётся, то тесты снова покажут красное.
Перед сохранением фаззер минимизирует вход. Отрезает байты и упрощает структуру пока не получит минимальный воспроизводящий пример.
Где фаззинг особенно полезен
Не любой код стоит фаззить. Фаззинг приносит максимальную пользу там, где функция принимает произвольный внешний ввод и где у вас есть способ автоматически проверить корректность ответа.
| Тип кода | Польза | Что проверять |
|---|---|---|
| Парсеры (JSON, YAML, протоколы) | Очень высокая | Отсутствие паник, согласие с эталоном |
| Валидаторы и санитайзеры | Высокая | Идемпотентность, отсутствие обхода |
| Кодеки и сериализация | Высокая | Roundtrip Decode(Encode(x)) == x |
| Логика обработки бинарных форматов | Очень высокая | Безопасность, отсутствие OOB-чтений |
| Чистые алгоритмы (сортировка, поиск) | Средняя | Инварианты результата |
| CRUD без сложной логики | Низкая | Тут хватает табличных тестов |
Главный вопрос при написании fuzz-функции: «Как я узнаю, что результат неправильный, не зная заранее правильного ответа?» Если ответа на этот вопрос нет, то фаззинг превратится в поиск только паник, а это лишь самая верхушка возможных багов.
Два стандартных способа сформулировать правильные/эталонные ответы:
- Инварианты. Свойства, которые должны выполняться для любого результата: идемпотентность, симметрия, монотонность, согласованность в данных.
- Differential testing. Сравнение двух независимых реализаций одного API. Если они расходятся, ошибка в одной из них или в спецификации.
Я использовал второй подход при поиске бага в sonic, о чём подробнее и поговорим.
Differential fuzzing на практике: sonic против encoding/json
Краткая вводная. sonic это JSON-парсер от ByteDance, написанный с использованием JIT и SIMD. Заявлен как самый быстрый и при этом как условно-бесшовная замена для encoding/json из стандартной библиотеки. У него есть конфиг sonic.ConfigStd, который задокументирован как точное совпадение с поведением стандартного пакета.
Это идеальный кандидат для differential fuzzing. У нас случай, когда одна реализация быстрая, а вторая медленная, но эталонная и проверенная временем. Если они расходятся хоть на одном входе, то считаем это багом.
Постановка задачи
Идея простая: на каждом сгенерированном входе вызываем json.Unmarshal и sonic.ConfigStd.Unmarshal. Сравниваем результаты по двум осям:
- Если одна вернула ошибку, другая тоже должна.
- Если обе успешны, декодированные значения должны быть равны.
Любое расхождение считаем багом.
Fuzz-функция
package fuzz
import (
stdjson "encoding/json"
"math"
"reflect"
"testing"
"github.com/bytedance/sonic"
)
var sonicStd = sonic.ConfigStd
func FuzzUnmarshal(f *testing.F) {
// Сид-корпус: валидные и невалидные примеры, покрывающие разные пути парсера.
seeds := []string{
`null`, `true`, `false`,
`0`, `-1`, `1.5`, `1e100`,
`"hello"`, `"line\nbreak"`,
`[]`, `{}`,
`[1,2,3]`, `{"a":1,"b":null}`,
`{"nested":{"x":true}}`,
`"` + "\x80" + `"`, // невалидный UTF-8 — обе должны ошибиться
`[1,2,`, // обрезанный ввод
`{a:1}`, // ключ без кавычек
}
for _, s := range seeds {
f.Add([]byte(s))
}
f.Fuzz(func(t *testing.T, data []byte) {
var stdVal, sonicVal interface{}
stdErr := stdjson.Unmarshal(data, &stdVal)
sonicErr := sonicStd.Unmarshal(data, &sonicVal)
stdOk := stdErr == nil
sonicOk := sonicErr == nil
if stdOk != sonicOk {
t.Fatalf("error mismatch for %q:\n std=%v\n sonic=%v", data, stdErr, sonicErr)
}
if stdOk && !deepEqualJSON(stdVal, sonicVal) {
t.Fatalf("value mismatch for %q:\n std=%#v\n sonic=%#v", data, stdVal, sonicVal)
}
})
}
func deepEqualJSON(a, b interface{}) bool {
if a == nil && b == nil {
return true
}
// JSON numbers — float64; NaN сравниваются по специальному правилу.
af, aIsFloat := a.(float64)
bf, bIsFloat := b.(float64)
if aIsFloat && bIsFloat {
if math.IsNaN(af) && math.IsNaN(bf) {
return true
}
return af == bf
}
return reflect.DeepEqual(a, b)
}
Что здесь стоит отметить:
- Сид-корпус подобран так, чтобы покрыть как успешные, так и проваленные ветви обоих парсеров. Это позволит нам сэкономить время на первых итерациях и дать фаззеру увидеть покрытие базовых сценариев.
deepEqualJSON— обёртка надreflect.DeepEqual, которая корректно обрабатывает NaN (по стандартуNaN != NaN, что ломает прямое сравнение).- Цель
interface{}— самый гибкий приёмник для JSON. Любой валидный JSON десериализуется вinterface{}.
Далее я написал ещё одну fuzz-функцию с конкретной структурой как целью десериализации. Это важный момент: парсеры часто идут разными внутренними путями для interface{} и для конкретных типов. Один путь может быть корректен, а другой нет.
type Sample struct {
Name *string `json:"name"`
Age *int `json:"age"`
Tags []string `json:"tags"`
}
func FuzzUnmarshalStruct(f *testing.F) {
// сид-корпус с известными граничными случаями
for _, s := range [][]byte{
[]byte(`{}`),
[]byte(`{"name":"Alice","age":30,"tags":["a","b"]}`),
[]byte(`{"name":null,"age":null,"tags":null}`),
} {
f.Add(s)
}
f.Fuzz(func(t *testing.T, data []byte) {
var stdVal, sonicVal Sample
stdErr := stdjson.Unmarshal(data, &stdVal)
sonicErr := sonicStd.Unmarshal(data, &sonicVal)
if (stdErr == nil) != (sonicErr == nil) {
t.Fatalf("error mismatch for %q:\n std=%v\n sonic=%v", data, stdErr, sonicErr)
}
if stdErr == nil && !reflect.DeepEqual(stdVal, sonicVal) {
t.Fatalf("value mismatch for %q:\n std=%+v\n sonic=%+v", data, stdVal, sonicVal)
}
})
}
Запуск
$ go test -fuzz=^FuzzUnmarshalStruct$ -fuzztime=60s ./fuzz/
fuzz: elapsed: 0s, gathering baseline coverage: 0/602 completed
fuzz: elapsed: 1s, gathering baseline coverage: 602/602 completed, now fuzzing with 12 workers
fuzz: elapsed: 3s, execs: 91846 (30606/sec), new interesting: 7 (total: 609)
fuzz: elapsed: 6s, execs: 275878 (61351/sec), new interesting: 11 (total: 613)
fuzz: elapsed: 9s, execs: 420733 (48287/sec), new interesting: 15 (total: 617)
...
fuzz: elapsed: 24s, execs: 1207978 (56107/sec), new interesting: 29 (total: 631)
fuzz: minimizing 86-byte failing input file
fuzz: elapsed: 26s, minimizing
--- FAIL: FuzzUnmarshalStruct (26.44s)
--- FAIL: FuzzUnmarshalStruct (0.00s)
fuzz_test.go:140: struct error mismatch for "{\"\":\"\\00000000000000000000000000000\"}":
std=invalid character '0' in string escape code
sonic=<nil>
Failing input written to testdata/fuzz/FuzzUnmarshalStruct/6a77cebe466af20f
To re-run:
go test -run=FuzzUnmarshalStruct/6a77cebe466af20f
FAIL
Расхождение нашлось за 26 секунд, фаззер сделал около 1.2 миллиона прогонов и автоматически минимизировал 86-байтовый вход до 37 байт.
Что фаззер нашёл
Минимальный воспроизводящий вход:
{"":"\00000000000000000000000000000"}
На этом входе:
encoding/jsonкорректно возвращает ошибку:invalid character '0' in string escape code.sonic.ConfigStd.Unmarshalвозвращаетnilи пропускает невалидный escape.
По RFC 8259 §7 валидных символов после \ всего восемь: ", \, /, b, f, n, r, t, плюс \uXXXX. Никаких \0, \v, \x, \1 стандарт не допускает. Это однозначное нарушение спецификации. Sonic в данном случае явно ошибается.
Особенность бага в том, что он проявляется только при определённых условиях. Сделав несколько прогонов с разной длиной паддинга, я выяснил что:
Длина строки после \ | sonic | encoding/json |
|---|---|---|
| Короткая (< 29 байт) | ошибка (корректно) | ошибка |
| Длинная (≥ 29 байт) | nil (некорректно) | ошибка |
Внутри sonic есть две ветви парсинга строк: SIMD-ускоренная (для длинных строк) и скалярная (для коротких). Скалярная корректно валидирует escape-последовательности, а SIMD-ветка ищет только закрывающую кавычку из-за чего содержимое escape не проверяется корректно. Конкретное место с проблемой я нашёл позже. Внутри функции advance_string_validate в native/scanning.h.
Дальнейший фаззинг и тестирование показали, что баг проявляется только когда JSON-ключ не совпадает ни с одним полем структуры. В этом случае sonic вызывает быстрый skip-value путь, который как раз и идёт по SIMD-ветке без полной валидации. Если поле есть или цель десериализации interface{}, то парсер идёт другим маршрутом и корректно ловит ошибку.
Воспроизведение
Финальный пример для issue:
package main
import (
stdjson "encoding/json"
"fmt"
"strings"
"github.com/bytedance/sonic"
)
// S — структура без полей. Sonic пойдёт по skip-value пути.
type S struct{}
// Паддинг из 28 байт нужен, чтобы парсер sonic ушёл в SIMD-ветку.
var inputs = [][]byte{
[]byte(`{"":"\0` + strings.Repeat("p", 28) + `"}`),
[]byte(`{"":"\1` + strings.Repeat("p", 28) + `"}`),
[]byte(`{"":"\v` + strings.Repeat("p", 28) + `"}`),
}
func main() {
for _, input := range inputs {
var std, son S
fmt.Printf("input=%s\n", input)
fmt.Printf(" encoding/json: %v\n", stdjson.Unmarshal(input, &std))
fmt.Printf(" sonic.ConfigStd: %v\n", sonic.ConfigStd.Unmarshal(input, &son))
}
}
Вывод:
input={"":"\0pppppppppppppppppppppppppppp"}
encoding/json: invalid character '0' in string escape code
sonic.ConfigStd: <nil>
input={"":"\1pppppppppppppppppppppppppppp"}
encoding/json: invalid character '1' in string escape code
sonic.ConfigStd: <nil>
input={"":"\vpppppppppppppppppppppppppppp"}
encoding/json: invalid character 'v' in string escape code
sonic.ConfigStd: <nil>
С этим воспроизведением я и завёл issue #938. Версия sonic — v1.15.1, Go — 1.25.5.
Что важно вынести из этой истории
Несколько практических уроков, которые я для себя сформулировал по итогам:
- Differential fuzzing — мощный недооценённый инструмент. Если у вас есть две независимых реализации одного API (или одного формата), вы получаете качественный инструмент для сверки. JSON-парсеры лишь один из примеров; так же можно проверять разные клиенты СУБД, разные реализации алгоритмов сжатия (например gzip и zstd), разные реализации HTTP клиентов/серверов.
- «Drop-in replacement» — обещание, которое стоит проверить. Когда библиотека позиционирует себя как замена стандартной, она обязана совпадать с эталоном на всех входах, а не только на типичных. Differential fuzzing предоставляет точный инструмент проверки таких обещаний.
- Парсеры — почти всегда содержат подобные ошибки. Любая оптимизация (SIMD, JIT, кеш) делит код на быстрый и медленный путь. Часто один из них валидирует строже другого. Или в целом работает иначе. Граница между путями (как «29 байт» в sonic) является типичным местом для багов в реализации.
- 26 секунд фаззинга стоят дня ручного дебага. Найти такой баг руками реально, но очень долго, монотонно и неэффективно. Фаззер делает десятки тысяч прогонов в секунду на типичной машине, что позволяет найти самые разнообразные и нетипичные входные данные.
- Минимизация — половина ценности. Минимизированный 37-байтовый вход представляет собой готовый отчёт об ошибке. Если бы фаззер не умел нарезать и выделять только необходимое нам, то на ручное сокращение примера ушли бы дни.
- Хороший баг-репорт повышает шанс на фикс. В issue я привёл RFC, минимальный воспроизводящий код, объяснил, почему это именно баг, а не «странное поведение», и указал внутреннее место в коде sonic, где проблема. Это экономит время мейнтейнеров и увеличивает вероятность того, что баг возьмут в работу.
Чего фаззинг не делает
Чтобы не было завышенных ожиданий:
- Фаззинг не найдёт логические баги, для которых нужен сложный сценарий с состоянием. Он работает на уровне «вход → выход» одной функции.
- Фаззинг не находит проблемы производительности. Для этого есть бенчмарки и профайлер; об этом я писал в статье про бенчмарки и оптимизацию.
- Фаззинг не заменяет интеграционные тесты. Он дополняет юнит-тесты для узких мест, где код принимает произвольный ввод.
- Сама по себе fuzz-функция бесполезна без хорошей точки сравнения. Если вы умеете проверить только то, что код не падает, то фаззер найдёт только паники, но не более тонкие нарушения семантики.
Итог
- Фаззинг встроен в
go testначиная с Go 1.18 и не требует ни внешних библиотек, ни специальной конфигурации. - Фаззер направляемый через покрытие находит баги, до которых не доходят табличные тесты, особенно в парсерах и валидаторах.
- Differential fuzzing — самая эффективная техника для сравнения с эталонной реализацией. Вы можете сравнить быстрый код с медленным, но стабильным.
- Начальный набор данных (сид-корпус) должен покрывать разные ветви кода для ускорения поиска разных ветвей фаззером.
- Все падающие входы сохраняются в
testdata/fuzz/и превращаются в регрессии автоматически. - Минимизация — встроенная функция фаззера, обычно сокращает найденные кейсы до пары десятков байт.
- Если вы пишете парсер, кодек, валидатор или принимаете произвольный бинарный ввод — фаззинг должен быть в вашем CI, а не в категории «когда-нибудь попробуем».
Подробнее про базовые приёмы тестирования в Go в статье «Тестирование в Go: от табличных тестов до интеграционных сценариев».
Теги: