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)`, tag, tag), fmt.Sprintf(`"><%s>alert(1)`, 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 }