553 lines
20 KiB
Go
553 lines
20 KiB
Go
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 // все разряды переполнились — перебор завершён
|
||
}
|
||
}
|
||
}
|