186 lines
5.9 KiB
Go
186 lines
5.9 KiB
Go
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
|
||
}
|