294 lines
11 KiB
Go
294 lines
11 KiB
Go
// 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
|
||
}
|