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