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(`'>
`, tag),
fmt.Sprintf(`javascript:%s(1)`, tag),
}
}
func (c *xssCheck) Analyze(probe string, resp, baseline ProbeResult) *Finding {
if resp.Err != nil {
return nil
}
// Ищем канарейку в СЫРОМ виде (с угловыми скобками/как есть) — если
// сервер её экранировал (<wrXXXX>), это не отражение, а
// корректное поведение, и мы не должны его засчитывать как находку.
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{
`]>&xxe;`,
`]>&xxe;`,
}
}
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
}