package cli import ( "bufio" "flag" "fmt" "io" "os" "strconv" "strings" "time" "github.com/r3g1tpr0cs/burterm/internal/engine" "github.com/r3g1tpr0cs/burterm/internal/payload" ) // stringList — flag.Value, копящий повторяющиеся флаги в срез // в порядке их указания в командной строке. type stringList []string func (s *stringList) String() string { return strings.Join(*s, ",") } func (s *stringList) Set(v string) error { *s = append(*s, v); return nil } // Flags — разобранные аргументы командной строки для одного запуска атаки. type Flags struct { RequestFile string Mode string // "sniper" | "battering" | "clusterbomb" Payloads stringList // спецификации вида "wordlist:<путь>" или "range:::", по одной на точку вставки Base stringList // базовые значения для точек, не участвующих в переборе (режим sniper) Concurrency int RateLimit time.Duration Timeout time.Duration Insecure bool Scheme string // "" (авто, дефолт https) | "http" | "https" Project string // путь к файлу проекта (SQLite); пусто — не сохранять результаты } // newFlagSet регистрирует все флаги CLI-режима один раз — используется // и Parse (реальный разбор), и Usage (печать справки для -h/--help), // чтобы список флагов с их описаниями не дублировался в двух местах. func newFlagSet() (*flag.FlagSet, *Flags) { fs := flag.NewFlagSet("burterm", flag.ContinueOnError) f := &Flags{} fs.StringVar(&f.RequestFile, "request", "", "путь к файлу с сырым HTTP-запросом и §маркерами§ точек вставки (обязательно)") fs.StringVar(&f.Mode, "mode", "sniper", "режим атаки: sniper | battering | pitchfork | clusterbomb") fs.Var(&f.Payloads, "payload", "источник payload'ов; повторяй флаг в порядке точек вставки: wordlist:<путь> | range::: | preset:<имя> | brute:<алфавит>:: — к любому можно дописать |ext:.php,.html для перебора расширений") fs.Var(&f.Base, "base", "базовое значение для точки вставки в режиме sniper, по одному флагу на точку") fs.IntVar(&f.Concurrency, "concurrency", 10, "число параллельных воркеров") fs.DurationVar(&f.RateLimit, "rate-limit", 0, "пауза между запросами на воркер, напр. 100ms") fs.DurationVar(&f.Timeout, "timeout", 10*time.Second, "таймаут одного HTTP-запроса") fs.BoolVar(&f.Insecure, "insecure", false, "не проверять TLS-сертификат сервера (self-signed стенды)") fs.StringVar(&f.Scheme, "scheme", "", "принудительная схема: http | https (по умолчанию — как определит парсер запроса, обычно https)") fs.StringVar(&f.Project, "project", "", "путь к файлу проекта (SQLite) — если указан, результаты атаки сохраняются туда же, что видит TUI на вкладке Project") return fs, f } // Parse разбирает os.Args (без имени программы) в Flags и валидирует // обязательные поля. func Parse(args []string) (*Flags, error) { fs, f := newFlagSet() if err := fs.Parse(args); err != nil { return nil, err } if f.RequestFile == "" { return nil, fmt.Errorf("нужно указать -request <файл>") } if len(f.Payloads) == 0 { return nil, fmt.Errorf("нужен хотя бы один -payload") } if f.Scheme != "" && f.Scheme != "http" && f.Scheme != "https" { return nil, fmt.Errorf("-scheme должен быть http, https или пустым, получено %q", f.Scheme) } return f, nil } // Usage печатает список флагов headless CLI-режима в w — используется // main.go для -h/--help, чтобы не дублировать регистрацию флагов. func Usage(w io.Writer) { fs, _ := newFlagSet() fs.SetOutput(w) fs.Usage() } // AttackMode транслирует строковое имя режима в engine.AttackMode. func (f *Flags) AttackMode() (engine.AttackMode, error) { switch f.Mode { case "sniper": return engine.ModeSniper, nil case "battering": return engine.ModeBattering, nil case "pitchfork": return engine.ModePitchfork, nil case "clusterbomb": return engine.ModeClusterBomb, nil default: return 0, fmt.Errorf("неизвестный режим %q (ожидался sniper, battering, pitchfork или clusterbomb)", f.Mode) } } // BuildGenerators превращает спецификации --payload в конкретные // payload.Generator, в том же порядке, в каком флаги были указаны // (этот порядок соответствует порядку §маркеров§ в запросе). func (f *Flags) BuildGenerators() ([]payload.Generator, error) { gens := make([]payload.Generator, 0, len(f.Payloads)) for _, spec := range f.Payloads { g, err := parsePayloadSpec(spec) if err != nil { return nil, fmt.Errorf("payload %q: %w", spec, err) } gens = append(gens, g) } return gens, nil } // ParsePayloadSpec — экспортированная версия parsePayloadSpec. Используется // не только CLI-флагами, но и TUI, чтобы оба интерфейса разбирали строки // вида "wordlist:<путь>" / "range:::" одинаково. func ParsePayloadSpec(spec string) (payload.Generator, error) { return parsePayloadSpec(spec) } // parsePayloadSpec сначала снимает необязательный суффикс "|ext:..." // (аналог -e у ffuf — список расширений, дописываемых к каждому слову), // затем разбирает оставшуюся часть как обычную спецификацию через // parseBaseSpec и, если расширения были заданы, оборачивает результат // в payload.ExtensionGenerator. func parsePayloadSpec(spec string) (payload.Generator, error) { base, extensions, err := splitExtSuffix(spec) if err != nil { return nil, err } gen, err := parseBaseSpec(base) if err != nil { return nil, err } if len(extensions) > 0 { gen = payload.NewExtensionGenerator(gen, extensions) } return gen, nil } // splitExtSuffix отделяет "|ext:.php,.html,bak" от конца спецификации — // суффикс может стоять у любого типа spec (wordlist/preset/range/brute). // Ведущая точка у расширения необязательна во вводе ("bak" эквивалентно // ".bak") — добавляется автоматически, чтобы не спотыкаться на забытой точке. func splitExtSuffix(spec string) (base string, extensions []string, err error) { idx := strings.LastIndex(spec, "|ext:") if idx == -1 { return spec, nil, nil } base = spec[:idx] extPart := spec[idx+len("|ext:"):] if extPart == "" { return "", nil, fmt.Errorf("|ext: указан, но список расширений пуст") } for _, e := range strings.Split(extPart, ",") { e = strings.TrimSpace(e) if e == "" { continue } if !strings.HasPrefix(e, ".") { e = "." + e } extensions = append(extensions, e) } return base, extensions, nil } // parseBaseSpec разбирает одну спецификацию вида "тип:параметры" — // без учёта возможного "|ext:" суффикса, который снимается выше. func parseBaseSpec(spec string) (payload.Generator, error) { parts := strings.SplitN(spec, ":", 2) if len(parts) != 2 { return nil, fmt.Errorf("ожидался формат тип:параметры, напр. wordlist:/путь/к/файлу") } kind, arg := parts[0], parts[1] switch kind { case "wordlist": words, err := readLines(arg) if err != nil { return nil, err } if len(words) == 0 { return nil, fmt.Errorf("файл словаря %q пуст", arg) } return payload.NewWordlistGenerator(words), nil case "range": nums := strings.Split(arg, ":") if len(nums) != 3 { return nil, fmt.Errorf("ожидался формат range:from:to:step") } from, err := strconv.Atoi(nums[0]) if err != nil { return nil, fmt.Errorf("некорректный from: %w", err) } to, err := strconv.Atoi(nums[1]) if err != nil { return nil, fmt.Errorf("некорректный to: %w", err) } step, err := strconv.Atoi(nums[2]) if err != nil { return nil, fmt.Errorf("некорректный step: %w", err) } if step <= 0 { return nil, fmt.Errorf("step должен быть положительным, получено %d", step) } return payload.NewNumericRangeGenerator(from, to, step), nil case "preset": g, err := payload.NewFromPreset(arg) if err != nil { return nil, err } return g, nil case "brute": // Формат: brute:<алфавит>:: — прямой перебор без // готовой заготовки, для случаев, когда ни одна из встроенных // не подходит (свой алфавит, другая длина и т.п.). parts := strings.SplitN(arg, ":", 3) if len(parts) != 3 { return nil, fmt.Errorf("ожидался формат brute:<алфавит>::") } charset := parts[0] if charset == "" { return nil, fmt.Errorf("алфавит для brute не может быть пустым") } minLen, err := strconv.Atoi(parts[1]) if err != nil { return nil, fmt.Errorf("некорректный min: %w", err) } maxLen, err := strconv.Atoi(parts[2]) if err != nil { return nil, fmt.Errorf("некорректный max: %w", err) } if minLen < 1 || maxLen < minLen { return nil, fmt.Errorf("ожидалось 1 <= min <= max, получено min=%d max=%d", minLen, maxLen) } return payload.NewBruteForceGenerator(charset, minLen, maxLen), nil default: return nil, fmt.Errorf("неизвестный тип payload'а %q (ожидался wordlist, range, preset или brute)", kind) } } // readLines читает файл построчно, пропуская пустые строки // и обрезая \r на случай CRLF-словарей. func readLines(path string) ([]string, error) { file, err := os.Open(path) if err != nil { return nil, fmt.Errorf("не удалось открыть файл со словарём: %w", err) } defer file.Close() var lines []string scanner := bufio.NewScanner(file) for scanner.Scan() { line := strings.TrimRight(scanner.Text(), "\r") if line == "" { continue } lines = append(lines, line) } if err := scanner.Err(); err != nil { return nil, fmt.Errorf("ошибка чтения словаря: %w", err) } return lines, nil }