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

273 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 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:<from>:<to>:<step>", по одной на точку вставки
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:<from>:<to>:<step> | preset:<имя> | brute:<алфавит>:<min>:<max> — к любому можно дописать |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:<from>:<to>:<step>" одинаково.
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:<алфавит>:<min>:<max> — прямой перебор без
// готовой заготовки, для случаев, когда ни одна из встроенных
// не подходит (свой алфавит, другая длина и т.п.).
parts := strings.SplitN(arg, ":", 3)
if len(parts) != 3 {
return nil, fmt.Errorf("ожидался формат brute:<алфавит>:<min>:<max>")
}
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
}