burterm/internal/fuzzer/fuzzer.go
2026-09-14 10:55:07 +03:00

294 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package fuzzer реализует blackbox-мутационный фаззер произвольного
// исполняемого файла — аналог AFL без инструментации (никакого coverage
// feedback: цель не пересобирается с санитайзерами/инструментацией, мы
// просто мутируем seed-байты и смотрим, не упала ли программа). Для CTF
// pwn-задач этого обычно достаточно: цель — найти любой крашащий ввод
// на заведомо уязвимом бинарнике, а не покрыть весь граф путей.
//
// Детект краша — по сигналу, которым завершился процесс (SIGSEGV,
// SIGABRT и т.п.), поэтому пакет завязан на syscall.WaitStatus и реально
// работает только на Unix-подобных системах — под Windows exec.ExitError.Sys()
// возвращает другой тип, и isCrashSignal никогда не сработает. Это
// осознанное ограничение: цель — ALT Linux, кросс-платформенность здесь
// не задача.
package fuzzer
import (
"bytes"
"context"
"errors"
"fmt"
"hash/fnv"
"math/rand"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
)
// Config — параметры одного прогона фаззера.
type Config struct {
Target string
// Args — аргументы цели. Один из них — "@@" — заменяется на путь
// к временному файлу с мутированным вводом. Игнорируется, если
// UseStdin true.
Args []string
UseStdin bool // подавать ввод через stdin вместо файла
Timeout time.Duration
Workers int
CrashDir string // куда сохранять крашащие вводы; пусто — не сохранять на диск
}
// EventKind различает тик прогресса и находку — тот же принцип, что
// и scanner.Event: один канал, а не два, чтобы не гонять отдельную
// синхронизацию между потоками прогресса и находок.
type EventKind int
const (
EventProgress EventKind = iota
EventCrash
)
// Crash — один крашащий ввод.
type Crash struct {
Input []byte
Signal string // "SIGSEGV" и т.п., либо "timeout"
Stderr string // обрезанный stderr цели — часто содержит зацепку (напр. "stack smashing detected")
Path string // куда сохранён ввод на диске, пусто — если CrashDir не задан
}
// Event — одно сообщение из канала Fuzzer.Run.
type Event struct {
Kind EventKind
Execs int64 // валидно при EventProgress — суммарный счётчик выполнений
Crash Crash // валидно при EventCrash
}
// Fuzzer гоняет мутации seed-корпуса против одной цели.
type Fuzzer struct {
cfg Config
corpus [][]byte
}
// New проверяет конфигурацию и создаёт Fuzzer. Не запускает ничего сам —
// см. Run.
func New(cfg Config, seeds [][]byte) (*Fuzzer, error) {
if len(seeds) == 0 {
return nil, fmt.Errorf("нужен хотя бы один seed-ввод")
}
if cfg.Workers <= 0 {
cfg.Workers = 4
}
if cfg.Timeout <= 0 {
cfg.Timeout = 2 * time.Second
}
if !cfg.UseStdin && !strings.Contains(strings.Join(cfg.Args, " "), "@@") {
return nil, fmt.Errorf("в args нет плейсхолдера @@ (или включи UseStdin, если цель читает ввод из stdin)")
}
if cfg.CrashDir != "" {
if err := os.MkdirAll(cfg.CrashDir, 0o700); err != nil {
return nil, fmt.Errorf("создание директории для крашей: %w", err)
}
}
return &Fuzzer{cfg: cfg, corpus: seeds}, nil
}
// LoadSeeds читает файлы по путям в срез сэмплов для New. Не разворачивает
// директории рекурсивно — сознательно просто, вызывающий код (TUI) сам
// решает, какие файлы передать.
func LoadSeeds(paths []string) ([][]byte, error) {
seeds := make([][]byte, 0, len(paths))
for _, p := range paths {
b, err := os.ReadFile(p)
if err != nil {
return nil, fmt.Errorf("чтение seed %s: %w", p, err)
}
seeds = append(seeds, b)
}
return seeds, nil
}
// Run запускает cfg.Workers параллельных воркеров, каждый в своём цикле:
// взял случайный сэмпл из корпуса → havoc-мутация → запуск цели → если
// краш — эмит события. Отдельная горутина шлёт тик прогресса каждые
// 500мс, чтобы UI видел execs/сек даже когда крашей долго нет. Канал
// закрывается, когда все воркеры и тикер прогресса завершатся (по
// отмене ctx) — оба вида горутин отслеживаются одним sync.WaitGroup,
// иначе закрытие канала могло бы обогнать ещё живого отправителя.
func (f *Fuzzer) Run(ctx context.Context) <-chan Event {
out := make(chan Event)
var execs int64
var wg sync.WaitGroup
for w := 0; w < f.cfg.Workers; w++ {
wg.Add(1)
go func(rngSeed int64) {
defer wg.Done()
rng := rand.New(rand.NewSource(rngSeed))
for {
select {
case <-ctx.Done():
return
default:
}
base := f.corpus[rng.Intn(len(f.corpus))]
input := havoc(rng, base, f.corpus)
crash, err := f.execute(ctx, input)
atomic.AddInt64(&execs, 1)
if err != nil {
continue // ошибка запуска (не найден бинарник и т.п.) — не находка, просто пропускаем итерацию
}
if crash == nil {
continue
}
crash.Path = f.saveCrash(input, *crash)
select {
case out <- Event{Kind: EventCrash, Crash: *crash}:
case <-ctx.Done():
return
}
}
}(time.Now().UnixNano() + int64(w))
}
wg.Add(1)
go func() {
defer wg.Done()
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
select {
case out <- Event{Kind: EventProgress, Execs: atomic.LoadInt64(&execs)}:
case <-ctx.Done():
return
}
}
}
}()
go func() {
wg.Wait()
close(out)
}()
return out
}
// execute запускает цель с input один раз. Возвращает (nil, nil), если
// краша не было; (*Crash, nil) — если был; (nil, err) — если сам запуск
// не удался технически (например, бинарник не найден или нет прав на
// выполнение) — это ошибка конфигурации, не находка фаззера.
func (f *Fuzzer) execute(ctx context.Context, input []byte) (*Crash, error) {
execCtx, cancel := context.WithTimeout(ctx, f.cfg.Timeout)
defer cancel()
args := append([]string(nil), f.cfg.Args...)
if !f.cfg.UseStdin {
tmp, err := os.CreateTemp("", "burterm-fuzz-*")
if err != nil {
return nil, fmt.Errorf("временный файл: %w", err)
}
tmpPath := tmp.Name()
defer os.Remove(tmpPath)
if _, err := tmp.Write(input); err != nil {
tmp.Close()
return nil, fmt.Errorf("запись временного файла: %w", err)
}
tmp.Close()
for i, a := range args {
if a == "@@" {
args[i] = tmpPath
}
}
}
cmd := exec.CommandContext(execCtx, f.cfg.Target, args...)
if f.cfg.UseStdin {
cmd.Stdin = bytes.NewReader(input)
}
var stderr limitedBuffer
stderr.max = 4096
cmd.Stderr = &stderr
runErr := cmd.Run()
if execCtx.Err() == context.DeadlineExceeded {
return &Crash{Input: input, Signal: "timeout", Stderr: stderr.buf.String()}, nil
}
if runErr == nil {
return nil, nil
}
var exitErr *exec.ExitError
if errors.As(runErr, &exitErr) {
if status, ok := exitErr.Sys().(syscall.WaitStatus); ok && status.Signaled() {
sig := status.Signal()
if isCrashSignal(sig) {
return &Crash{Input: input, Signal: sig.String(), Stderr: stderr.buf.String()}, nil
}
}
// Ненулевой код возврата без крашащего сигнала — почти наверняка
// программа сама распознала невалидный ввод и штатно завершилась
// с ошибкой. Это нормальное поведение большинства CLI-утилит,
// не считаем находкой, иначе UI завалило бы ложными "крашами".
return nil, nil
}
return nil, fmt.Errorf("запуск цели: %w", runErr)
}
func isCrashSignal(sig syscall.Signal) bool {
switch sig {
case syscall.SIGSEGV, syscall.SIGABRT, syscall.SIGFPE, syscall.SIGILL, syscall.SIGBUS:
return true
default:
return false
}
}
// saveCrash пишет крашащий ввод на диск с именем на основе сигнала и
// короткого хэша содержимого (fnv — не крипто-хэш, здесь и не нужен,
// просто чтобы разные краши не затирали друг друга одним именем файла).
func (f *Fuzzer) saveCrash(input []byte, crash Crash) string {
if f.cfg.CrashDir == "" {
return ""
}
h := fnv.New32a()
h.Write(input)
name := fmt.Sprintf("crash-%s-%08x", strings.ToLower(strings.TrimPrefix(crash.Signal, "SIG")), h.Sum32())
path := filepath.Join(f.cfg.CrashDir, name)
_ = os.WriteFile(path, input, 0o600)
return path
}
// limitedBuffer — тот же принцип, что и капированные буферы в engine/proxy:
// пишем не больше max байт, но Write всегда сообщает вызывающему "успех"
// (возвращает исходную длину p), чтобы cmd.Run() не считал это ошибкой
// ввода-вывода только из-за того, что нам не нужен весь stderr целиком.
type limitedBuffer struct {
buf bytes.Buffer
max int
}
func (b *limitedBuffer) Write(p []byte) (int, error) {
n := len(p)
if remaining := b.max - b.buf.Len(); remaining > 0 {
if len(p) > remaining {
p = p[:remaining]
}
b.buf.Write(p)
}
return n, nil
}