Дизайн fuzz-тестирования
Где fuzz даёт ROI: парсеры, валидаторы, сериализаторы. Corpus + мутации, что считать crash, tooling.
Спроектируй fuzz-тестирование для {{target}} на {{lang}}.
Базовая аксиома: fuzz не заменяет unit-тесты, а ищет то, чего ты не придумал. Если функция принимает байты / строки / JSON / число, у неё есть бесконечное входное пространство и человек его не охватит. Fuzz перебирает варианты, которые ломают инварианты — крэши, паники, утечки памяти, неправильный результат.
1. Где fuzz имеет ROI
| Хорошо | Плохо |
|---|---|
| Парсеры (URL, JSON, HTML, протоколы) | UI-формы (лучше property-based на бизнес-логике) |
| Валидаторы / sanitizers (input → bool/Result) | Чистый CRUD без преобразований |
| Сериализаторы / десериализаторы | Тонкая обёртка над библиотекой, которую и так фуззят |
| Декомпрессоры / декодеры (image, audio) | Внешние API-вызовы — fuzz не достанет |
| Криптография и encoding (base64, hex, varint) | UI-вёрстка, дизайн-системы |
| Любой код, обрабатывающий untrusted bytes | Чистая бизнес-логика без сериализации |
Round-trip пары (encode(decode(x)) == x) | Зависящее от часов / I/O / сетки |
Правило большого пальца: если функция чистая, имеет input от пользователя / сети / файла, и её сложно «прочитать в голове», — фуззь.
2. Property — что проверяем кроме «не упало»
«Не паникует» — слабая цель. Полные свойства:
| Свойство | Пример |
|---|---|
| No-panic / no-crash | parse(any_bytes) returns Result, не падает |
| No UB / no memory error | ASan + fuzz; нет use-after-free, out-of-bounds |
| Round-trip | decode(encode(x)) == x для любого x |
| Idempotency | f(f(x)) == f(x) (sanitize, normalize) |
| Equivalence to reference | my_parser(x) == serde_json::from_str(x) |
| Invariant preservation | После insert дерево остаётся сбалансированным |
| No hang | Работает за < N ms для любого input размера M |
| Bounded memory | RSS < N MB для любого input |
3. Что считать «crash»
| Симптом | Серьёзность | Что делать |
|---|---|---|
| Сегфолт / abort / panic | Critical | Min repro → fix → regression test |
| ASan/UBSan error | Critical | Часто security CVE-уровень |
| Hang > N сек на input < M байт | High | DoS-вектор, ограничить парсинг |
| OOM (быстрый рост памяти) | High | Bounded allocation, ограничение глубины |
| Wrong output (differs from reference) | High | Логическая бага |
| Slow path (×100 от среднего) | Medium | Algorithmic complexity attack |
| Большая утечка между итерациями | Medium | Reset state, проверить Drop |
4. Tooling по языкам
Rust
cargo install cargo-fuzz
cargo fuzz init
cargo fuzz add parse_target
# fuzz_targets/parse_target.rs
#![no_main]
use libfuzzer_sys::fuzz_target;
fuzz_target!(|data: &[u8]| {
let _ = my_crate::parse(data);
});
cargo fuzz run parse_target -- -max_len=4096. AFL++: cargo-afl. Property-based — proptest / quickcheck (мельче зерном).
Go
testing (Go 1.18+) — нативный fuzz:
func FuzzParse(f *testing.F) {
f.Add([]byte("{}")) // seed
f.Fuzz(func(t *testing.T, b []byte) {
_, _ = parse(b) // no panic
})
}
go test -fuzz=FuzzParse -fuzztime=10m.
Python
- Atheris (Google, libFuzzer-backed) — для C-расширений
- Hypothesis — property-based, легче в обиходе:
from hypothesis import given, strategies as st
@given(st.text())
def test_parse_no_crash(s):
parse(s)
JVM
- Jazzer (libFuzzer для JVM) — для Java/Kotlin/Scala
- jqwik — property-based
Node.js / TS
- fast-check — property-based
- jsfuzz / node-libfuzzer — coverage-guided
C / C++
- libFuzzer (внутри clang) — стандарт
- AFL++ — стенды, кластеры
- honggfuzz — альтернатива
Структурированный input
Arbitrary (Rust), f.Fuzz с struct (Go), @composite (Hypothesis) — фуззишь не байты, а типизированные значения: AST, конфиги, protobuf.
5. Corpus и мутации
Corpus = seed-inputs, на которых fuzzer начинает мутации. Хороший corpus = быстрый coverage:
- Положи реальные примеры (валидные JSON-доки, реальные URL'ы, реальные изображения)
- Положи граничные кейсы (пустой, в 1 символ, в 1 МБ)
- Положи то, что когда-то ломалось — каждый найденный bug превращается в новый corpus-файл (regression)
fuzz/corpus/parse_target/
empty.bin
minimal.json
with-unicode.json
deeply-nested.json
bug-2025-05-01.bin # был crash, теперь regression
Мутации (делает fuzzer сам):
- bit flip, byte swap, splice двух inputs
- insert / delete bytes
- mutation guided by coverage (libFuzzer/AFL)
Dictionary — слова/токены протокола (http.dict, json.dict) — резко ускоряет coverage на структурированных форматах.
6. Coverage-guided vs random
| Тип | Кто | Когда |
|---|---|---|
| Random | старые fuzzer'ы | Очень примитивно, медленно находит |
| Coverage-guided | libFuzzer, AFL, Jazzer, Go native | Стандарт сегодня |
| Structure-aware | LibProtobufMutator, Arbitrary trait | Сложные форматы |
| Differential | твой harness | Сравниваешь две реализации |
| Symbolic / concolic | KLEE, SAGE | Hardcore, для крит-секций |
7. Что делать с найденным крэшем
- Min repro — fuzzer сам минимизирует (
cargo fuzz tmin,go test -fuzzсохраняет в testdata) - Regression test — добавь min repro в seed corpus / unit-suite
- Root cause — не «обёрни в try/catch», пойми инвариант
- Triage — security implication? Если да — приватный канал, эмбарго, CVE
- CI — гонять corpus как regression suite в каждом PR, полный fuzz — еженочно
8. CI стратегия
| Когда | Что | Время |
|---|---|---|
| На каждый PR | regression: прогнать корпус как unit-test | секунды |
| Nightly | coverage-guided fuzz | 30-60 мин на target |
| Долгий run | OSS-Fuzz / ClusterFuzz | дни/недели, бесплатно для OSS |
| После big-change | fuzz по специфике изменения | 1-4 часа |
# GH Actions — пример
- run: cargo fuzz run parse_target -- -max_total_time=600
- run: cargo fuzz tmin parse_target artifacts/... # минимизация
9. Анти-паттерны
- ❌ Фуззить функцию с побочными эффектами (пишет в БД, шлёт сеть) — fuzzer не очистит state
- ❌ Фуззить с
assert(true)— никакого свойства не проверяешь, кроме «не упало» - ❌ Игнорировать hang/timeout — DoS-вектор
- ❌ Без sanitizer'ов (ASan/UBSan/MSan для C/Rust) — пропустишь memory issues
- ❌ Без corpus → cold start, fuzzer часами ищет валидный input
- ❌ Запустить раз и забыть — fuzz эффективен долго и регулярно
- ❌ Не сохранять найденные crashes в regression — повторишь тот же баг
- ❌
catch(...) { /* ignore */ }чтобы fuzz «не падал» — маскируешь баг, fuzz перестаёт находить новое - ❌ Фуззить через сеть / в живой системе — медленно (мс на input), бесполезно по сравнению с in-process
- ❌ Использовать random unit-test вместо coverage-guided fuzz — пропускаешь interesting paths
- ❌ Не диффить две реализации, когда есть reference (старый парсер vs новый) — упускаешь differential bugs
- ❌ Hardcode
Vec::with_capacity(input.len())без cap — fuzz найдёт OOM за минуты
10. Что отдать
- Список fuzz-target'ов с обоснованием ROI (что именно ищем)
- Harness-код для каждого (5-20 строк)
- Корпус: минимум 10 валидных + 5 граничных examples
- Свойства, которые проверяются (no-panic + что-то ещё, см. §2)
- CI-конфиг: regression на каждый PR + nightly fuzz job
- Лог first-run: что нашли, как пофиксили, какие regression-тесты добавили
- План on-going: кто смотрит nightly отчёты, SLA на найденные баги
Content Security Policy и security headers
CSP, HSTS, X-Frame, Permissions-Policy — закрыть основные классы атак за один проход.
Управление секретами
Где хранить, как ротировать, как обнаружить утечку.
Аутентификация и rate limiting
Защита логина, реги, восстановления пароля от brute force.