Фаззинг в Go: как я нашёл баг в JSON-парсере sonic

Мне всегда казалось, что фаззинг (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)
...

Что важно понять:

  1. Сид-корпус — стартовые точки. От них фаззер мутирует данные: меняет байты, удваивает фрагменты, переставляет, увеличивает длину. Чем разнообразнее сид-корпус, тем быстрее фаззер находит новые пути.
  2. -fuzz включает фаззинг конкретной функции. Без него go test прогонит fuzz-функцию только на сид-корпусе как обычный регрессионный тест.
  3. -fuzztime ограничивает время. Без него фаззер будет работать до завершения через Ctrl+C.
  4. Параллелизм по умолчанию — по умолчанию запускается максимально доступное количество воркеров, равное GOMAXPROCS. При желании можно задать -parallel=4.

Корпус и регрессии

Когда фаззер находит падающий вход, он сохраняет его в testdata/fuzz/<FuzzName>/<hash>:

testdata/
└── fuzz/
    └── FuzzNormalize/
        └── 6a77cebe466af20f

Этот файл нужно закоммитить в репозиторий. При следующем go test (уже без флага -fuzz) он прогонится как обычный тест: фаззер превращает находку в регрессию автоматически. Если вы исправили баг, тест становится зелёным; если регрессия вернётся, то тесты снова покажут красное.

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

Где фаззинг особенно полезен

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

Тип кодаПользаЧто проверять
Парсеры (JSON, YAML, протоколы)Очень высокаяОтсутствие паник, согласие с эталоном
Валидаторы и санитайзерыВысокаяИдемпотентность, отсутствие обхода
Кодеки и сериализацияВысокаяRoundtrip Decode(Encode(x)) == x
Логика обработки бинарных форматовОчень высокаяБезопасность, отсутствие OOB-чтений
Чистые алгоритмы (сортировка, поиск)СредняяИнварианты результата
CRUD без сложной логикиНизкаяТут хватает табличных тестов

Главный вопрос при написании fuzz-функции: «Как я узнаю, что результат неправильный, не зная заранее правильного ответа?» Если ответа на этот вопрос нет, то фаззинг превратится в поиск только паник, а это лишь самая верхушка возможных багов.

Два стандартных способа сформулировать правильные/эталонные ответы:

  1. Инварианты. Свойства, которые должны выполняться для любого результата: идемпотентность, симметрия, монотонность, согласованность в данных.
  2. 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. Сравниваем результаты по двум осям:

  1. Если одна вернула ошибку, другая тоже должна.
  2. Если обе успешны, декодированные значения должны быть равны.

Любое расхождение считаем багом.

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)
}

Что здесь стоит отметить:

Далее я написал ещё одну 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"}

На этом входе:

По RFC 8259 §7 валидных символов после \ всего восемь: ", \, /, b, f, n, r, t, плюс \uXXXX. Никаких \0, \v, \x, \1 стандарт не допускает. Это однозначное нарушение спецификации. Sonic в данном случае явно ошибается.

Особенность бага в том, что он проявляется только при определённых условиях. Сделав несколько прогонов с разной длиной паддинга, я выяснил что:

Длина строки после \sonicencoding/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.

Что важно вынести из этой истории

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

  1. Differential fuzzing — мощный недооценённый инструмент. Если у вас есть две независимых реализации одного API (или одного формата), вы получаете качественный инструмент для сверки. JSON-парсеры лишь один из примеров; так же можно проверять разные клиенты СУБД, разные реализации алгоритмов сжатия (например gzip и zstd), разные реализации HTTP клиентов/серверов.
  2. «Drop-in replacement» — обещание, которое стоит проверить. Когда библиотека позиционирует себя как замена стандартной, она обязана совпадать с эталоном на всех входах, а не только на типичных. Differential fuzzing предоставляет точный инструмент проверки таких обещаний.
  3. Парсеры — почти всегда содержат подобные ошибки. Любая оптимизация (SIMD, JIT, кеш) делит код на быстрый и медленный путь. Часто один из них валидирует строже другого. Или в целом работает иначе. Граница между путями (как «29 байт» в sonic) является типичным местом для багов в реализации.
  4. 26 секунд фаззинга стоят дня ручного дебага. Найти такой баг руками реально, но очень долго, монотонно и неэффективно. Фаззер делает десятки тысяч прогонов в секунду на типичной машине, что позволяет найти самые разнообразные и нетипичные входные данные.
  5. Минимизация — половина ценности. Минимизированный 37-байтовый вход представляет собой готовый отчёт об ошибке. Если бы фаззер не умел нарезать и выделять только необходимое нам, то на ручное сокращение примера ушли бы дни.
  6. Хороший баг-репорт повышает шанс на фикс. В issue я привёл RFC, минимальный воспроизводящий код, объяснил, почему это именно баг, а не «странное поведение», и указал внутреннее место в коде sonic, где проблема. Это экономит время мейнтейнеров и увеличивает вероятность того, что баг возьмут в работу.

Чего фаззинг не делает

Чтобы не было завышенных ожиданий:

Итог

  1. Фаззинг встроен в go test начиная с Go 1.18 и не требует ни внешних библиотек, ни специальной конфигурации.
  2. Фаззер направляемый через покрытие находит баги, до которых не доходят табличные тесты, особенно в парсерах и валидаторах.
  3. Differential fuzzing — самая эффективная техника для сравнения с эталонной реализацией. Вы можете сравнить быстрый код с медленным, но стабильным.
  4. Начальный набор данных (сид-корпус) должен покрывать разные ветви кода для ускорения поиска разных ветвей фаззером.
  5. Все падающие входы сохраняются в testdata/fuzz/ и превращаются в регрессии автоматически.
  6. Минимизация — встроенная функция фаззера, обычно сокращает найденные кейсы до пары десятков байт.
  7. Если вы пишете парсер, кодек, валидатор или принимаете произвольный бинарный ввод — фаззинг должен быть в вашем CI, а не в категории «когда-нибудь попробуем».

Подробнее про базовые приёмы тестирования в Go в статье «Тестирование в Go: от табличных тестов до интеграционных сценариев».


Теги: