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

553 lines
20 KiB
Go
Raw Permalink 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 engine
import (
"bytes"
"context"
"crypto/tls"
"fmt"
"io"
"net/http"
"sync"
"time"
"github.com/r3g1tpr0cs/burterm/internal/payload"
)
// AttackMode задаёт способ комбинирования генераторов и точек вставки,
// по аналогии с режимами Burp Intruder.
type AttackMode int
const (
// ModeSniper — один генератор, payload'ы по очереди проходят через
// каждую точку вставки (остальные точки остаются пустыми/базовыми).
ModeSniper AttackMode = iota
// ModeBattering — один генератор, payload вставляется во все точки
// одновременно (одно и то же значение везде).
ModeBattering
// ModePitchfork — по генератору на каждую точку вставки, но идут они
// в ногу по индексу (payloads[i] из каждого генератора вместе
// формируют один запрос), а не декартовым произведением. Останавливается
// на самом коротком генераторе — как в Burp: если один список короче
// остальных, лишние значения из более длинных просто не используются.
ModePitchfork
// ModeClusterBomb — разные генераторы на разные позиции,
// декартово произведение всех комбинаций.
ModeClusterBomb
)
// Result — результат одного запроса атаки.
type Result struct {
Payload []string
// URL и Method — куда именно ушёл этот конкретный запрос атаки.
// Заполняются в send() из уже собранного *http.Request — нужны,
// в частности, чтобы результаты Intruder-атаки можно было сохранить
// в project.Store (там для каждой записи нужны host/method/url,
// а Result сам по себе их не содержал до этого момента).
URL string
Method string
StatusCode int
// Length — фактическое количество прочитанных байт тела ответа,
// а не значение заголовка Content-Length (оно часто равно -1 при
// chunked encoding и вводило бы в заблуждение как база для фильтра
// по длине).
Length int64
// Body — тело ответа, обрезанное до maxBodyCapture байт. Полное
// тело не хранится намеренно: при тысячах запросов в атаке это
// быстро съело бы память; для grep-фильтра обрезанного фрагмента
// обычно достаточно, чтобы поймать характерную строку в ответе.
Body string
// Headers — заголовки ответа (используются, в частности, для
// цветовой маркировки по Content-Type в TUI).
Headers http.Header
Duration time.Duration
Err error
}
// maxBodyCapture — сколько байт тела ответа сохраняется в Result.Body.
const maxBodyCapture = 4096
// Config описывает параметры выполнения атаки.
type Config struct {
Concurrency int
RateLimit time.Duration // пауза между запросами на воркер
Timeout time.Duration
InsecureSkipVerify bool
// ForceScheme принудительно проставляет схему ("http" или "https")
// для каждого запроса атаки, перекрывая дефолт из BuildRequest
// (который всегда ставит https, если в запросе нет абсолютного URI).
// Пустая строка — оставить как есть (дефолтное поведение BuildRequest).
ForceScheme string
}
// Engine выполняет HTTP-атаку с заданной конкурентностью.
type Engine struct {
client *http.Client
cfg Config
}
// New создаёт Engine с настроенным http.Client (таймаут, TLS).
//
// MaxIdleConnsPerHost/MaxIdleConns заданы явно — у голого http.Transport
// дефолт MaxIdleConnsPerHost всего 2 (значение из net/http.DefaultTransport),
// а Intruder-атака почти всегда бьёт в один и тот же хост множеством
// конкурентных воркеров. С дефолтом это означает, что при Concurrency
// больше двух подавляющее большинство запросов не переиспользует
// соединение из пула — TCP- (а для HTTPS ещё и TLS-) хендшейк на каждый
// запрос вместо одного на воркер. Ставим пул размером с Concurrency —
// ровно столько одновременных соединений к одному хосту нам и нужно.
func New(cfg Config) *Engine {
transport := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: cfg.InsecureSkipVerify},
MaxIdleConnsPerHost: cfg.Concurrency,
MaxIdleConns: cfg.Concurrency * 2, // с запасом на случай редиректов на другой хост
IdleConnTimeout: 90 * time.Second,
}
return &Engine{
client: &http.Client{Timeout: cfg.Timeout, Transport: transport},
cfg: cfg,
}
}
// EstimateTotal вычисляет ожидаемое общее число запросов атаки заранее,
// без её запуска — используется в TUI для индикации прогресса (N/Total).
// Опирается только на payload.Generator.Len() каждого генератора, не
// потребляя их состояние (Len() не двигает Next()/Reset()), поэтому
// безопасно вызывать перед реальным Run с теми же генераторами.
func EstimateTotal(mode AttackMode, points []InsertionPoint, gens []payload.Generator) int {
switch mode {
case ModeSniper:
if len(gens) == 0 {
return 0
}
// RunSniper проходит генератор заново (Reset) на каждую точку —
// то есть суммарно len(points) полных проходов одного генератора.
return len(points) * gens[0].Len()
case ModeBattering:
if len(gens) == 0 {
return 0
}
return gens[0].Len()
case ModePitchfork:
if len(gens) == 0 {
return 0
}
min := -1
for _, g := range gens {
l := g.Len()
if min == -1 || l < min {
min = l
}
}
if min < 0 {
return 0
}
return min
case ModeClusterBomb:
if len(gens) == 0 {
return 0
}
total := 1
for _, g := range gens {
total *= g.Len()
}
return total
default:
return 0
}
}
// Run — единая точка входа для запуска атаки в любом из режимов.
// Сигнатура унифицирована под все три режима через gens []payload.Generator:
//
// - ModeSniper: используется только gens[0], остальные позиции на каждом
// запросе берутся из base (если base == nil, они остаются пустыми
// строками). Если позиций несколько, base обязателен и должен быть
// той же длины, что и points.
// - ModeBattering: используется только gens[0], его значение вставляется
// сразу во все точки.
// - ModePitchfork: используются все gens, len(gens) должен равняться
// len(points) — по генератору на каждую точку, но идут они в ногу
// по индексу, не декартовым произведением.
// - ModeClusterBomb: используются все gens, len(gens) должен равняться
// len(points) — по генератору на каждую точку.
//
// Так TUI/CLI могут работать с одной сигнатурой, не зная деталей
// каждого режима.
func (e *Engine) Run(ctx context.Context, mode AttackMode, clean string, points []InsertionPoint, gens []payload.Generator, base []string) (<-chan Result, error) {
if len(gens) == 0 {
return nil, fmt.Errorf("нужен хотя бы один генератор payload'ов")
}
switch mode {
case ModeSniper:
b := base
if b == nil {
b = make([]string, len(points))
}
if len(b) != len(points) {
return nil, fmt.Errorf("base должен быть длины %d (по числу точек вставки), получено %d", len(points), len(b))
}
return e.RunSniper(ctx, clean, points, gens[0], b), nil
case ModeBattering:
return e.RunBattering(ctx, clean, points, gens[0]), nil
case ModePitchfork:
return e.RunPitchfork(ctx, clean, points, gens)
case ModeClusterBomb:
return e.RunClusterBomb(ctx, clean, points, gens)
default:
return nil, fmt.Errorf("неизвестный режим атаки: %v", mode)
}
}
// readBodyCapped дочитывает r до конца (это важно само по себе — см.
// комментарий в send про keep-alive), считая полную длину total, но
// сохраняя в captured не больше cap байт. Ошибку чтения намеренно не
// прокидывает наверх как фатальную для результата атаки: то, что тело
// оборвалось на середине, не должно скрывать уже полученные статус-код
// и частично прочитанные данные.
func readBodyCapped(r io.Reader, cap int) (captured []byte, total int64, err error) {
buf := make([]byte, 32*1024)
var b bytes.Buffer
for {
n, rerr := r.Read(buf)
if n > 0 {
total += int64(n)
if b.Len() < cap {
remaining := cap - b.Len()
if n < remaining {
remaining = n
}
b.Write(buf[:remaining])
}
}
if rerr == io.EOF {
break
}
if rerr != nil {
return b.Bytes(), total, rerr
}
}
return b.Bytes(), total, nil
}
// RunSniper гоняет атаку в режиме Sniper: один генератор, одна точка
// вставки за проход. Результаты идут в возвращаемый канал.
func (e *Engine) RunSniper(ctx context.Context, clean string, points []InsertionPoint, gen payload.Generator, base []string) <-chan Result {
out := make(chan Result)
type job struct {
pointIdx int
value string
}
jobs := make(chan job)
var wg sync.WaitGroup
for i := 0; i < e.cfg.Concurrency; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := range jobs {
payloads := append([]string(nil), base...)
payloads[j.pointIdx] = j.value
e.send(ctx, clean, points, payloads, out)
}
}()
}
go func() {
defer close(jobs)
for pointIdx := range points {
gen.Reset()
for {
v, ok := gen.Next()
if !ok {
break
}
select {
case jobs <- job{pointIdx: pointIdx, value: v}:
case <-ctx.Done():
return
}
}
}
}()
go func() {
wg.Wait()
close(out)
}()
return out
}
// send выполняет один запрос с заданным набором payload'ов и пишет
// результат в out. Обе потенциально блокирующие операции — отправка в out
// и пауза rate limit'а — слушают ctx.Done(), поэтому отмена контекста
// гарантированно освобождает воркер, даже если читатель канала (TUI)
// уже перестал вычитывать результаты этой атаки.
func (e *Engine) send(ctx context.Context, clean string, points []InsertionPoint, payloads []string, out chan<- Result) {
start := time.Now()
req, err := BuildRequest(clean, points, payloads)
if err != nil {
select {
case out <- Result{Payload: payloads, Err: err}:
case <-ctx.Done():
}
return
}
if e.cfg.ForceScheme != "" {
req.URL.Scheme = e.cfg.ForceScheme
}
req = req.WithContext(ctx)
resp, err := e.client.Do(req)
res := Result{Payload: payloads, Duration: time.Since(start), Err: err, URL: req.URL.String(), Method: req.Method}
if err == nil {
res.StatusCode = resp.StatusCode
res.Headers = resp.Header
body, total, _ := readBodyCapped(resp.Body, maxBodyCapture)
resp.Body.Close()
res.Body = string(body)
res.Length = total
}
select {
case out <- res:
case <-ctx.Done():
return
}
if e.cfg.RateLimit > 0 {
select {
case <-time.After(e.cfg.RateLimit):
case <-ctx.Done():
}
}
}
// RunBattering гоняет атаку в режиме Battering Ram: один генератор,
// каждое его значение вставляется одновременно во все точки вставки
// (в отличие от Sniper, где точки перебираются по очереди, здесь на
// каждый запрос приходится ровно одно значение генератора, но во
// все §маркеры§ сразу — удобно, когда один и тот же токен/ID
// повторяется в нескольких местах запроса).
func (e *Engine) RunBattering(ctx context.Context, clean string, points []InsertionPoint, gen payload.Generator) <-chan Result {
out := make(chan Result)
jobs := make(chan string)
var wg sync.WaitGroup
for i := 0; i < e.cfg.Concurrency; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for v := range jobs {
payloads := make([]string, len(points))
for i := range payloads {
payloads[i] = v
}
e.send(ctx, clean, points, payloads, out)
}
}()
}
go func() {
defer close(jobs)
gen.Reset()
for {
v, ok := gen.Next()
if !ok {
return
}
select {
case jobs <- v:
case <-ctx.Done():
return
}
}
}()
go func() {
wg.Wait()
close(out)
}()
return out
}
// RunPitchfork гоняет атаку в режиме Pitchfork: у каждой точки вставки
// свой генератор (len(gens) должен равняться len(points)), но в отличие
// от Cluster Bomb они не перемножаются, а идут в ногу — i-й запрос берёт
// i-е значение из каждого генератора одновременно. Останавливается на
// самом коротком генераторе: если один список payload'ов короче
// остальных, лишние значения из более длинных просто не используются —
// то же поведение, что и в Burp Pitchfork.
func (e *Engine) RunPitchfork(ctx context.Context, clean string, points []InsertionPoint, gens []payload.Generator) (<-chan Result, error) {
if len(gens) != len(points) {
return nil, fmt.Errorf("ожидалось %d генераторов (по числу точек вставки), получено %d", len(points), len(gens))
}
values := make([][]string, len(gens))
minLen := -1
for i, g := range gens {
values[i] = drainGenerator(g)
if len(values[i]) == 0 {
return nil, fmt.Errorf("генератор для точки %q не выдал ни одного payload'а", points[i].Name)
}
if minLen == -1 || len(values[i]) < minLen {
minLen = len(values[i])
}
}
out := make(chan Result)
jobs := make(chan int) // общий индекс i в пределах minLen
var wg sync.WaitGroup
for i := 0; i < e.cfg.Concurrency; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for idx := range jobs {
payloads := make([]string, len(points))
for pos := range points {
payloads[pos] = values[pos][idx]
}
e.send(ctx, clean, points, payloads, out)
}
}()
}
go func() {
defer close(jobs)
for i := 0; i < minLen; i++ {
select {
case jobs <- i:
case <-ctx.Done():
return
}
}
}()
go func() {
wg.Wait()
close(out)
}()
return out, nil
}
// RunClusterBomb гоняет атаку в режиме Cluster Bomb: каждой точке вставки
// сопоставлен свой генератор (len(gens) должен равняться len(points)),
// и перебираются все комбинации значений (декартово произведение).
//
// Значения каждого генератора вычитываются один раз в память
// (drainGenerator), после чего перебор комбинаций идёт по индексам —
// это ограничивает память суммой длин словарей, а не числом комбинаций,
// которое при N позициях и M вариантах растёт как M^N.
func (e *Engine) RunClusterBomb(ctx context.Context, clean string, points []InsertionPoint, gens []payload.Generator) (<-chan Result, error) {
if len(gens) != len(points) {
return nil, fmt.Errorf("ожидалось %d генераторов (по числу точек вставки), получено %d", len(points), len(gens))
}
values := make([][]string, len(gens))
for i, g := range gens {
values[i] = drainGenerator(g)
if len(values[i]) == 0 {
return nil, fmt.Errorf("генератор для точки %q не выдал ни одного payload'а", points[i].Name)
}
}
out := make(chan Result)
jobs := make(chan []int)
var wg sync.WaitGroup
for i := 0; i < e.cfg.Concurrency; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for idx := range jobs {
payloads := make([]string, len(idx))
for pos, vi := range idx {
payloads[pos] = values[pos][vi]
}
e.send(ctx, clean, points, payloads, out)
}
}()
}
go func() {
defer close(jobs)
odometer(values, func(idx []int) bool {
cp := append([]int(nil), idx...)
select {
case jobs <- cp:
return true
case <-ctx.Done():
return false
}
})
}()
go func() {
wg.Wait()
close(out)
}()
return out, nil
}
// drainGenerator вычитывает генератор до конца в слайс значений.
func drainGenerator(g payload.Generator) []string {
g.Reset()
var out []string
for {
v, ok := g.Next()
if !ok {
break
}
out = append(out, v)
}
return out
}
// odometer перебирает все комбинации индексов по values (декартово
// произведение), вызывая emit для каждой комбинации. Работает как счётчик
// с разными основаниями по разрядам: последний разряд крутится быстрее
// всего, при переполнении перенос идёт влево — отсюда и название.
// Останавливается досрочно, если emit вернул false.
func odometer(values [][]string, emit func(idx []int) bool) {
n := len(values)
if n == 0 {
return
}
idx := make([]int, n)
for {
if !emit(idx) {
return
}
pos := n - 1
for pos >= 0 {
idx[pos]++
if idx[pos] < len(values[pos]) {
break
}
idx[pos] = 0
pos--
}
if pos < 0 {
return // все разряды переполнились — перебор завершён
}
}
}