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

186 lines
5.9 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 scanner
import (
"bytes"
"context"
"crypto/tls"
"fmt"
"io"
"net/http"
"time"
"github.com/r3g1tpr0cs/burterm/internal/engine"
)
// maxBodyCapture — сколько байт тела ответа читаем для анализа сигнатур.
// Больше, чем в Intruder (4КБ), потому что сигнатуры LFI/XXE/SQLi иногда
// прячутся дальше в длинных страницах с логами/трассировками ошибок.
const maxBodyCapture = 16 * 1024
// Config — параметры Scanner-клиента.
type Config struct {
Timeout time.Duration
InsecureSkipVerify bool
}
// Scanner выполняет активные проверки над одной точкой вставки запроса.
type Scanner struct {
client *http.Client
}
// New создаёт Scanner с настроенным http.Client.
func New(cfg Config) *Scanner {
return &Scanner{
client: &http.Client{
Timeout: cfg.Timeout,
Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: cfg.InsecureSkipVerify}},
},
}
}
// Run запускает выбранные проверки (по именам из checkNames, должны
// совпадать с AllCheckNames) против одной точки вставки pointIndex.
// base задаёт значения всех точек для baseline-запроса и для точек,
// не участвующих в сканировании (та же идея, что и sniper-режим
// в Intruder). Возвращает канал событий (прогресс + находки),
// закрывающийся по завершении всех проверок или по отмене ctx.
func (s *Scanner) Run(ctx context.Context, clean string, points []engine.InsertionPoint, pointIndex int, base []string, checkNames []string) (<-chan Event, error) {
if pointIndex < 0 || pointIndex >= len(points) {
return nil, fmt.Errorf("некорректный индекс точки вставки: %d (всего точек: %d)", pointIndex, len(points))
}
if len(base) != len(points) {
return nil, fmt.Errorf("base должен быть длины %d, получено %d", len(points), len(base))
}
selected := make([]Check, 0, len(checkNames))
for _, c := range activeChecks() {
if containsName(checkNames, c.Name()) {
selected = append(selected, c)
}
}
if len(selected) == 0 {
return nil, fmt.Errorf("не выбрано ни одной проверки")
}
// +1 за baseline-запрос — он тоже реальный HTTP-запрос и тоже
// должен учитываться в прогрессе, иначе N/Total разъедется на 1.
total := 1
for _, check := range selected {
total += len(check.Probes())
}
out := make(chan Event)
go func() {
defer close(out)
done := 0
emit := func(e Event) bool {
select {
case out <- e:
return true
case <-ctx.Done():
return false
}
}
baseline := s.probe(ctx, clean, points, pointIndex, base, base[pointIndex])
done++
if !emit(Event{Kind: EventProgress, Done: done, Total: total}) {
return
}
for _, check := range selected {
for _, p := range check.Probes() {
select {
case <-ctx.Done():
return
default:
}
resp := s.probe(ctx, clean, points, pointIndex, base, p)
done++
if !emit(Event{Kind: EventProgress, Done: done, Total: total}) {
return
}
if finding := check.Analyze(p, resp, baseline); finding != nil {
if !emit(Event{Kind: EventFinding, Finding: *finding}) {
return
}
}
}
}
}()
return out, nil
}
// probe собирает запрос с value в позиции pointIndex (остальные точки —
// из base), отправляет и возвращает ProbeResult. Ошибки сборки/сети не
// прерывают сканирование целиком — просто эта конкретная проверка не
// сработает на этом payload'е (Err передаётся дальше, каждый Check сам
// решает, игнорировать такой ProbeResult или нет — на практике все
// текущие проверки его игнорируют).
func (s *Scanner) probe(ctx context.Context, clean string, points []engine.InsertionPoint, pointIndex int, base []string, value string) ProbeResult {
payloads := append([]string(nil), base...)
payloads[pointIndex] = value
start := time.Now()
req, err := engine.BuildRequest(clean, points, payloads)
if err != nil {
return ProbeResult{Err: err}
}
req = req.WithContext(ctx)
resp, err := s.client.Do(req)
if err != nil {
return ProbeResult{Err: err, Duration: time.Since(start)}
}
defer resp.Body.Close()
body, _ := readBodyCapped(resp.Body, maxBodyCapture)
return ProbeResult{
StatusCode: resp.StatusCode,
Body: string(body),
Headers: resp.Header,
Duration: time.Since(start),
}
}
// readBodyCapped — тот же принцип, что и одноимённая функция в engine
// (см. её комментарий про keep-alive), продублирован здесь, а не
// экспортирован из engine, чтобы scanner не тянул за собой лишнюю
// связанность с внутренним устройством Intruder-движка ради одной
// небольшой функции.
func readBodyCapped(r io.Reader, cap int) ([]byte, error) {
buf := make([]byte, 32*1024)
var b bytes.Buffer
for {
n, rerr := r.Read(buf)
if n > 0 && 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(), rerr
}
}
return b.Bytes(), nil
}
func containsName(names []string, name string) bool {
for _, n := range names {
if n == name {
return true
}
}
return false
}