diff --git a/docs/modules.md b/docs/modules.md index 52af7473..12508469 100644 --- a/docs/modules.md +++ b/docs/modules.md @@ -65,7 +65,7 @@ info: ### type (required) -module type. currently only `http` is supported. +module type. `http` and `tcp` are supported. ```yaml type: http @@ -168,6 +168,58 @@ http: threads: 5 ``` +### tcp + +raw tcp configuration. connects to a port, optionally sends a payload, and runs +matchers and extractors against the response banner. + +#### port + +the tcp port to connect to (required, 1-65535). the port selects the service, so +the target's own scheme and port are ignored. + +```yaml +tcp: + port: 6379 +``` + +#### data + +an optional payload sent after connecting. sif decodes C-style escapes in the +value, so `\r`, `\n`, `\t`, `\\` and `\xHH` reach the wire as the raw bytes no +matter how the yaml scalar is quoted; an unrecognized escape is left verbatim. a +server that only banners (ssh, smtp) needs no data at all. + +```yaml +tcp: + port: 6379 + data: "INFO\r\n" +``` + +#### matchers and extractors + +tcp runs `word`, `regex` and `size` matchers (no `status`/`favicon`, those are +http only) against the banner string, and `regex` extractors pull values out of +it. there is no `part` selector: the banner is the only stream. + +```yaml +tcp: + port: 6379 + data: "INFO\r\n" + matchers: + - type: word + words: + - "redis_version:" + extractors: + - type: regex + name: redis_version + regex: + - "redis_version:([0-9.]+)" + group: 1 +``` + +see `modules/recon/redis-unauth-exposure.yaml` for the full module. + ## matchers matchers determine if a response indicates a finding. @@ -277,6 +329,20 @@ matchers: this matches responses with status 200 AND containing "ref: refs/". +set `matchers-condition: or` to fire when any matcher hits instead of all. it +applies to `http` and `tcp` modules alike. + +```yaml +matchers-condition: or +matchers: + - type: word + words: + - "redis_version:" + - type: word + words: + - "+PONG" +``` + to require any matcher instead of all, set `matchers-condition: or` on the http block; the module then reports a finding when any one matcher matches. diff --git a/internal/modules/executor.go b/internal/modules/executor.go index f969581a..c809739a 100644 --- a/internal/modules/executor.go +++ b/internal/modules/executor.go @@ -531,10 +531,3 @@ func truncateEvidence(s string) string { func ExecuteDNSModule(_ context.Context, _ string, def *YAMLModule, _ Options) (*Result, error) { return nil, fmt.Errorf("dns module %q: %w", def.ID, ErrUnsupportedModuleType) } - -// ExecuteTCPModule runs a TCP-based module (not yet implemented). -// returns ErrUnsupportedModuleType so the caller logs a clear failure rather -// than reporting an empty (but successful-looking) result. -func ExecuteTCPModule(_ context.Context, _ string, def *YAMLModule, _ Options) (*Result, error) { - return nil, fmt.Errorf("tcp module %q: %w", def.ID, ErrUnsupportedModuleType) -} diff --git a/internal/modules/executor_test.go b/internal/modules/executor_test.go index dae23da3..6a88dd6f 100644 --- a/internal/modules/executor_test.go +++ b/internal/modules/executor_test.go @@ -277,17 +277,6 @@ func TestExecuteDNSModuleUnsupported(t *testing.T) { } } -func TestExecuteTCPModuleUnsupported(t *testing.T) { - def := &YAMLModule{ID: "tcp-mod", Type: TypeTCP, TCP: &TCPConfig{Port: 22}} - result, err := ExecuteTCPModule(context.Background(), "example.com", def, Options{}) - if result != nil { - t.Errorf("result = %v, want nil for unsupported type", result) - } - if !errors.Is(err, ErrUnsupportedModuleType) { - t.Fatalf("err = %v, want ErrUnsupportedModuleType", err) - } -} - // TestWrapperExecuteRoutesByType confirms the Module wrapper dispatches each // type to the right executor and propagates the unsupported-type sentinel. func TestWrapperExecuteRoutesByType(t *testing.T) { @@ -299,11 +288,19 @@ func TestWrapperExecuteRoutesByType(t *testing.T) { } }) - t.Run("tcp routes to unsupported", func(t *testing.T) { - def := &YAMLModule{ID: "t", Type: TypeTCP, TCP: &TCPConfig{}} + t.Run("tcp routes to executor", func(t *testing.T) { + withFakeTCP(t, "SSH-2.0-OpenSSH_9.6") + def := &YAMLModule{ID: "t", Type: TypeTCP, TCP: &TCPConfig{ + Port: 22, + Matchers: []Matcher{{Type: "word", Words: []string{"SSH-2.0"}}}, + }} w := newYAMLModuleWrapper(def, "t.yaml") - if _, err := w.Execute(context.Background(), "t", Options{}); !errors.Is(err, ErrUnsupportedModuleType) { - t.Fatalf("err = %v, want ErrUnsupportedModuleType", err) + res, err := w.Execute(context.Background(), "host", Options{}) + if err != nil { + t.Fatalf("execute: %v", err) + } + if len(res.Findings) != 1 { + t.Fatalf("findings = %d, want 1", len(res.Findings)) } }) diff --git a/internal/modules/matchers_condition_test.go b/internal/modules/matchers_condition_test.go index 005a70b5..31a63229 100644 --- a/internal/modules/matchers_condition_test.go +++ b/internal/modules/matchers_condition_test.go @@ -130,3 +130,22 @@ func TestExecuteHTTPModuleMatchersConditionOr(t *testing.T) { t.Fatalf("and: got %d findings, want 0 (status:500 fails)", len(res.Findings)) } } + +func TestExecuteTCPModuleMatchersConditionOr(t *testing.T) { + withFakeTCP(t, "+PONG\r\n") + def := tcpDef(&TCPConfig{ + Port: 6379, + Matchers: []Matcher{tcpWord("absent-token"), tcpWord("+PONG")}, + MatchersCondition: "or", + }) + + res, err := ExecuteTCPModule(context.Background(), "example.com", def, Options{}) + if err != nil { + t.Fatalf("or: %v", err) + } + // or: the second matcher hits, so the finding fires even though the first + // missed; default and would suppress it. + if len(res.Findings) != 1 { + t.Fatalf("or: got %d findings, want 1", len(res.Findings)) + } +} diff --git a/internal/modules/tcp.go b/internal/modules/tcp.go new file mode 100644 index 00000000..cba20448 --- /dev/null +++ b/internal/modules/tcp.go @@ -0,0 +1,333 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · Blazing-fast pentesting suite : +: ▄█ █ █▀ · BSD 3-Clause License : +: : +: (c) 2022-2026 vmfunc, xyzeva, : +: lunchcat alumni & contributors : +: : +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +*/ + +package modules + +import ( + "context" + "fmt" + "net" + "net/url" + "regexp" + "strconv" + "strings" + "time" +) + +// defaultTCPTimeout bounds the dial and the banner read when the caller passes +// no timeout, so a silent service cannot block the scan forever. +const defaultTCPTimeout = 10 * time.Second + +// tcpReadLimit caps how many bytes a banner read keeps, guarding against a +// chatty or hostile service exhausting memory. +const tcpReadLimit = 64 * 1024 + +// newTCPConn dials the address over TCP. It is a package var so tests can supply +// a fake connection (e.g. a net.Pipe end) without touching the network. +var newTCPConn = func(ctx context.Context, addr string, timeout time.Duration) (net.Conn, error) { + d := net.Dialer{Timeout: timeout} + return d.DialContext(ctx, "tcp", addr) +} + +// validateTCP rejects, at load time, a tcp config the executor cannot run: a +// port outside 1-65535, an unknown matchers-condition, or a matcher type other +// than word, regex, or size (status and favicon are http only). +func validateTCP(cfg *TCPConfig) error { + if cfg.Port < 1 || cfg.Port > 65535 { + return fmt.Errorf("tcp port %d out of range (use 1-65535)", cfg.Port) + } + if err := validateMatchersCondition(cfg.MatchersCondition); err != nil { + return err + } + for i := range cfg.Matchers { + switch cfg.Matchers[i].Type { + case "word", "regex", "size": + default: + return fmt.Errorf("tcp matcher type %q is not supported (use word, regex, or size)", cfg.Matchers[i].Type) + } + } + return nil +} + +// ExecuteTCPModule connects to the target host on the configured port, sends the +// optional data probe, then applies the module's matchers and extractors to the +// bytes the service returns. +func ExecuteTCPModule(ctx context.Context, target string, def *YAMLModule, opts Options) (*Result, error) { + if def.TCP == nil { + return nil, fmt.Errorf("no TCP configuration") + } + cfg := def.TCP + result := &Result{ + ModuleID: def.ID, + Target: target, + Findings: make([]Finding, 0), + } + + addr, err := tcpAddress(target, cfg.Port) + if err != nil { + return nil, err + } + + timeout := opts.Timeout + if timeout <= 0 { + timeout = defaultTCPTimeout + } + + conn, err := newTCPConn(ctx, addr, timeout) + if err != nil { + return nil, fmt.Errorf("tcp dial %q: %w", addr, err) + } + defer func() { _ = conn.Close() }() + + // retryabledns-style context handling: TCP I/O does not take a context, so + // trip the deadline when the caller cancels to unblock a pending read/write. + stop := make(chan struct{}) + defer close(stop) + go func() { + select { + case <-ctx.Done(): + _ = conn.SetDeadline(time.Now()) + case <-stop: + } + }() + + if cfg.Data != "" { + // same re-arm hazard readTCP guards against: if the cancel watchdog's + // SetDeadline(now) trip lands before this SetWriteDeadline call, the + // call below would silently push the deadline back out to the full + // timeout. Check ctx first so an already-cancelled run never arms it, + // and prefer ctx.Err() over the raw write error so a write the + // watchdog does trip is reported as a cancellation, not an i/o + // timeout. + if err := ctx.Err(); err != nil { + return result, err + } + payload := decodeTCPData(cfg.Data) + _ = conn.SetWriteDeadline(time.Now().Add(timeout)) + if _, err := conn.Write([]byte(payload)); err != nil { + if ctxErr := ctx.Err(); ctxErr != nil { + return result, ctxErr + } + return nil, fmt.Errorf("tcp write %q: %w", addr, err) + } + } + + data := readTCP(ctx, conn, timeout) + if err := ctx.Err(); err != nil { + return result, err + } + + if !checkTCPMatchers(cfg.Matchers, cfg.MatchersCondition, data) { + return result, nil + } + + result.Findings = append(result.Findings, Finding{ + Severity: def.Info.Severity, + Evidence: truncateEvidence(data), + Extracted: runTCPExtractors(cfg.Extractors, data), + }) + return result, nil +} + +// readTCP reads from the connection until tcpReadLimit bytes accumulate, the +// timeout elapses, or the service closes, and returns what arrived. The byte +// cap bounds memory to roughly the limit plus one buffer. A timeout or EOF ends +// the read normally: a silent or half-open service yields the bytes seen so far +// rather than an error, leaving the verdict to the matchers. +// +// ctx is checked before arming the read deadline and again on every loop +// iteration. The cancel watchdog in ExecuteTCPModule trips the deadline with a +// single SetDeadline(now) call, which arming a later deadline here would +// otherwise silently overwrite (e.g. if the cancel lands while the probe +// Write is still in flight): a cancelled ctx must abort the read immediately +// rather than re-arm and block for the full timeout. +func readTCP(ctx context.Context, conn net.Conn, timeout time.Duration) string { + if ctx.Err() != nil { + return "" + } + _ = conn.SetReadDeadline(time.Now().Add(timeout)) + var out []byte + buf := make([]byte, 4096) + for len(out) < tcpReadLimit { + if ctx.Err() != nil { + break + } + n, err := conn.Read(buf) + if n > 0 { + out = append(out, buf[:n]...) + } + if err != nil { + break + } + } + return string(out) +} + +// decodeTCPData interprets C-style escape sequences in a tcp data payload so a +// module can put control bytes on the wire regardless of how the yaml scalar is +// quoted. A double-quoted yaml string already turns \r\n into real bytes before +// sif sees it, leaving no backslash for this to act on; a single-quoted or plain +// scalar keeps the backslashes, and this decode gives both forms the same bytes. +// Recognized escapes are \\ \a \b \f \n \r \t \v and \xHH; an unrecognized escape +// is kept verbatim (the backslash plus its character) so nothing is silently lost. +func decodeTCPData(s string) string { + if !strings.Contains(s, `\`) { + return s + } + var b strings.Builder + b.Grow(len(s)) + for i := 0; i < len(s); i++ { + if s[i] != '\\' || i+1 >= len(s) { + b.WriteByte(s[i]) + continue + } + i++ + switch s[i] { + case '\\': + b.WriteByte('\\') + case 'a': + b.WriteByte('\a') + case 'b': + b.WriteByte('\b') + case 'f': + b.WriteByte('\f') + case 'n': + b.WriteByte('\n') + case 'r': + b.WriteByte('\r') + case 't': + b.WriteByte('\t') + case 'v': + b.WriteByte('\v') + case 'x': + if i+2 < len(s) { + if v, err := strconv.ParseUint(s[i+1:i+3], 16, 8); err == nil { + b.WriteByte(byte(v)) + i += 2 + continue + } + } + // malformed \xHH: keep it literal rather than drop bytes. + b.WriteByte('\\') + b.WriteByte('x') + default: + // unknown escape: preserve both bytes so data is never lost. + b.WriteByte('\\') + b.WriteByte(s[i]) + } + } + return b.String() +} + +// checkTCPMatchers evaluates all matchers against the response, combining them +// with AND (default) or OR per the matchers-condition. +func checkTCPMatchers(matchers []Matcher, condition string, data string) bool { + if len(matchers) == 0 { + return false + } + + or := strings.EqualFold(condition, "or") + for i := range matchers { + matched := checkTCPMatcher(&matchers[i], data) + if matchers[i].Negative { + matched = !matched + } + if or && matched { + return true + } + if !or && !matched { + return false + } + } + + // and: all matched; or: none matched. + return !or +} + +// checkTCPMatcher evaluates a single matcher against the response bytes. TCP +// exposes one response stream, so there is no part selection; status and favicon +// are http only and validateTCP rejects them at load. +func checkTCPMatcher(m *Matcher, data string) bool { + switch m.Type { + case "word": + return checkWords(data, m.Words, m.Condition) + case "regex": + return checkRegex(data, m.Regex, m.Condition) + case "size": + for _, n := range m.Size { + if len(data) == n { + return true + } + } + return false + default: + return false + } +} + +// runTCPExtractors pulls regex captures from the response bytes. A banner is raw +// text, so regex is the available extractor; other types are skipped. +func runTCPExtractors(extractors []Extractor, data string) map[string]string { + if len(extractors) == 0 { + return nil + } + + result := make(map[string]string) + for _, e := range extractors { + if e.Type != "regex" { + continue + } + for _, pattern := range e.Regex { + re, err := regexp.Compile(pattern) + if err != nil { + continue + } + matches := re.FindStringSubmatch(data) + if len(matches) > e.Group { + result[e.Name] = matches[e.Group] + break + } + } + } + + return result +} + +// tcpAddress reduces target to its hostname and joins it with the configured +// port. Any scheme, port, path, or userinfo on the target is stripped: the +// module's port selects the service, not the target string. +func tcpAddress(target string, port int) (string, error) { + host := tcpHost(target) + if host == "" { + return "", fmt.Errorf("tcp target %q has no host", target) + } + return net.JoinHostPort(host, strconv.Itoa(port)), nil +} + +// tcpHost extracts the hostname from a target that may be a bare host, host:port, +// or a full URL. +func tcpHost(target string) string { + target = strings.TrimSpace(target) + if target == "" { + return target + } + // url.Parse only populates Host when a scheme is present; add one for a bare + // host or host:port so the same parse handles every form. + parse := target + if !strings.Contains(parse, "://") { + parse = "//" + parse + } + if u, err := url.Parse(parse); err == nil && u.Hostname() != "" { + return u.Hostname() + } + return target +} diff --git a/internal/modules/tcp_test.go b/internal/modules/tcp_test.go new file mode 100644 index 00000000..a1d355e9 --- /dev/null +++ b/internal/modules/tcp_test.go @@ -0,0 +1,709 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · Blazing-fast pentesting suite : +: ▄█ █ █▀ · BSD 3-Clause License : +: : +: (c) 2022-2026 vmfunc, xyzeva, : +: lunchcat alumni & contributors : +: : +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +*/ + +package modules + +import ( + "context" + "errors" + "fmt" + "net" + "os" + "path/filepath" + "sync" + "testing" + "time" +) + +// fakeTCPServer answers from a fixture over an in-memory pipe and records the +// probe the executor wrote and the address it dialed. +type fakeTCPServer struct { + reply string + addr string + sent chan []byte +} + +// serve drains any probe the client sends (so a synchronous pipe write does not +// deadlock) and replies with the fixture. +func (s *fakeTCPServer) serve(conn net.Conn) { + defer conn.Close() + go func() { + buf := make([]byte, 4096) + n, _ := conn.Read(buf) + s.sent <- append([]byte(nil), buf[:n]...) + }() + if s.reply != "" { + _, _ = conn.Write([]byte(s.reply)) + } +} + +// withFakeTCP installs a fake dialer that hands the executor one pipe end and +// streams the reply over the other, then returns the fake so a test can read +// back the dialed address and the probe bytes. +func withFakeTCP(t *testing.T, reply string) *fakeTCPServer { + t.Helper() + s := &fakeTCPServer{reply: reply, sent: make(chan []byte, 1)} + orig := newTCPConn + newTCPConn = func(_ context.Context, addr string, _ time.Duration) (net.Conn, error) { + s.addr = addr + client, server := net.Pipe() + go s.serve(server) + return client, nil + } + t.Cleanup(func() { newTCPConn = orig }) + return s +} + +func tcpWord(words ...string) Matcher { + return Matcher{Type: "word", Words: words} +} + +func tcpDef(cfg *TCPConfig) *YAMLModule { + return &YAMLModule{ID: "tcp-test", Type: TypeTCP, Info: YAMLModuleInfo{Severity: "info"}, TCP: cfg} +} + +func TestExecuteTCPModuleMatchAndExtract(t *testing.T) { + withFakeTCP(t, "+OK Redis 7.2.4 ready\r\n") + + def := tcpDef(&TCPConfig{ + Port: 6379, + Data: "PING\r\n", + Matchers: []Matcher{tcpWord("+OK", "Redis")}, + Extractors: []Extractor{ + {Type: "regex", Name: "version", Regex: []string{`Redis (\d+\.\d+\.\d+)`}, Group: 1}, + }, + }) + + res, err := ExecuteTCPModule(context.Background(), "example.com", def, Options{}) + if err != nil { + t.Fatalf("ExecuteTCPModule: %v", err) + } + if len(res.Findings) != 1 { + t.Fatalf("got %d findings, want 1", len(res.Findings)) + } + if got := res.Findings[0].Extracted["version"]; got != "7.2.4" { + t.Errorf("extracted version = %q, want 7.2.4", got) + } + if res.Findings[0].Evidence == "" { + t.Error("evidence is empty") + } +} + +func TestExecuteTCPModuleNoMatch(t *testing.T) { + withFakeTCP(t, "+OK ready\r\n") + def := tcpDef(&TCPConfig{Port: 6379, Matchers: []Matcher{tcpWord("absent-token")}}) + + res, err := ExecuteTCPModule(context.Background(), "example.com", def, Options{}) + if err != nil { + t.Fatalf("ExecuteTCPModule: %v", err) + } + if len(res.Findings) != 0 { + t.Fatalf("got %d findings, want 0", len(res.Findings)) + } +} + +func TestExecuteTCPModuleSendsProbe(t *testing.T) { + s := withFakeTCP(t, "PONG\r\n") + def := tcpDef(&TCPConfig{Port: 6379, Data: "PING\r\n", Matchers: []Matcher{tcpWord("PONG")}}) + + if _, err := ExecuteTCPModule(context.Background(), "example.com", def, Options{}); err != nil { + t.Fatalf("ExecuteTCPModule: %v", err) + } + if got := string(<-s.sent); got != "PING\r\n" { + t.Errorf("server received probe %q, want %q", got, "PING\r\n") + } + if s.addr != "example.com:6379" { + t.Errorf("dialed %q, want example.com:6379", s.addr) + } +} + +func TestExecuteTCPModuleDecodesProbeEscapes(t *testing.T) { + // data written with literal backslashes (a single-quoted or plain yaml scalar) + // is decoded to real control bytes before it goes on the wire. + s := withFakeTCP(t, "PONG\r\n") + def := tcpDef(&TCPConfig{Port: 6379, Data: `PING\r\n`, Matchers: []Matcher{tcpWord("PONG")}}) + + if _, err := ExecuteTCPModule(context.Background(), "example.com", def, Options{}); err != nil { + t.Fatalf("ExecuteTCPModule: %v", err) + } + if got := string(<-s.sent); got != "PING\r\n" { + t.Errorf("server received probe %q, want PING followed by CRLF", got) + } +} + +func TestDecodeTCPData(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + {"no backslash is unchanged", "PING", "PING"}, + {"crlf", `PING\r\n`, "PING\r\n"}, + {"tab then null via hex", `a\tb\x00`, "a\tb\x00"}, + {"hex pair", `\x41\x42`, "AB"}, + {"escaped backslash", `a\\b`, `a\b`}, + {"unknown escape kept verbatim", `a\zb`, `a\zb`}, + {"trailing backslash kept", `abc\`, `abc\`}, + {"malformed hex kept", `\xZZ`, `\xZZ`}, + {"short hex kept", `\x4`, `\x4`}, + {"every simple escape", `\a\b\f\n\r\t\v`, "\a\b\f\n\r\t\v"}, + } + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + if got := decodeTCPData(tt.in); got != tt.want { + t.Errorf("decodeTCPData(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} + +func TestExecuteTCPModulePassiveBanner(t *testing.T) { + // no data probe: the service speaks first (ssh, ftp, smtp). + withFakeTCP(t, "SSH-2.0-OpenSSH_9.6\r\n") + def := tcpDef(&TCPConfig{Port: 22, Matchers: []Matcher{tcpWord("SSH-2.0")}}) + + res, err := ExecuteTCPModule(context.Background(), "example.com", def, Options{}) + if err != nil { + t.Fatalf("ExecuteTCPModule: %v", err) + } + if len(res.Findings) != 1 { + t.Fatalf("got %d findings, want 1", len(res.Findings)) + } +} + +func TestCheckTCPMatchers(t *testing.T) { + data := "SSH-2.0-OpenSSH_9.6 Ubuntu" + + tests := []struct { + name string + matchers []Matcher + want bool + }{ + {"no matchers is false", nil, false}, + {"single word hit", []Matcher{tcpWord("OpenSSH")}, true}, + {"single word miss", []Matcher{tcpWord("Dropbear")}, false}, + {"and across matchers all hit", []Matcher{tcpWord("SSH-2.0"), tcpWord("Ubuntu")}, true}, + {"and across matchers one miss", []Matcher{tcpWord("SSH-2.0"), tcpWord("Debian")}, false}, + {"negative inverts a miss to a hit", []Matcher{{Type: "word", Words: []string{"Dropbear"}, Negative: true}}, true}, + {"negative inverts a hit to a miss", []Matcher{{Type: "word", Words: []string{"OpenSSH"}, Negative: true}}, false}, + {"regex hit", []Matcher{{Type: "regex", Regex: []string{`OpenSSH_\d+\.\d+`}}}, true}, + {"size hit", []Matcher{{Type: "size", Size: []int{len(data)}}}, true}, + {"size miss", []Matcher{{Type: "size", Size: []int{1}}}, false}, + {"status type never matches in tcp", []Matcher{{Type: "status", Status: []int{0}}}, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := checkTCPMatchers(tt.matchers, "", data); got != tt.want { + t.Errorf("checkTCPMatchers = %v, want %v", got, tt.want) + } + }) + } +} + +func TestCheckTCPMatchersOr(t *testing.T) { + data := "SSH-2.0-OpenSSH_9.6 Ubuntu" + + tests := []struct { + name string + matchers []Matcher + want bool + }{ + {"one of two hits", []Matcher{tcpWord("Dropbear"), tcpWord("OpenSSH")}, true}, + {"none hit", []Matcher{tcpWord("Dropbear"), tcpWord("Debian")}, false}, + {"first hit short-circuits", []Matcher{tcpWord("OpenSSH"), tcpWord("Dropbear")}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := checkTCPMatchers(tt.matchers, "or", data); got != tt.want { + t.Errorf("checkTCPMatchers(or) = %v, want %v", got, tt.want) + } + }) + } +} + +func TestRunTCPExtractors(t *testing.T) { + data := "220 mail.example.com ESMTP Postfix 3.7.2" + + t.Run("regex group 1", func(t *testing.T) { + ex := []Extractor{{Type: "regex", Name: "mta", Regex: []string{`ESMTP (\w+)`}, Group: 1}} + if got := runTCPExtractors(ex, data)["mta"]; got != "Postfix" { + t.Errorf("group 1 = %q, want Postfix", got) + } + }) + t.Run("group 0 full match", func(t *testing.T) { + ex := []Extractor{{Type: "regex", Name: "ver", Regex: []string{`Postfix [\d.]+`}, Group: 0}} + if got := runTCPExtractors(ex, data)["ver"]; got != "Postfix 3.7.2" { + t.Errorf("group 0 = %q", got) + } + }) + t.Run("miss sets nothing", func(t *testing.T) { + ex := []Extractor{{Type: "regex", Name: "x", Regex: []string{`nope(\d+)`}, Group: 1}} + if _, ok := runTCPExtractors(ex, data)["x"]; ok { + t.Error("a non-matching extractor set a value") + } + }) + t.Run("non-regex type skipped", func(t *testing.T) { + ex := []Extractor{{Type: "kv", Name: "k"}} + if _, ok := runTCPExtractors(ex, data)["k"]; ok { + t.Error("a non-regex extractor produced a value") + } + }) + t.Run("uncompilable pattern skipped", func(t *testing.T) { + // the bad pattern is skipped, the next one still matches. + ex := []Extractor{{Type: "regex", Name: "x", Regex: []string{"[", `(ESMTP)`}, Group: 1}} + if got := runTCPExtractors(ex, data)["x"]; got != "ESMTP" { + t.Errorf("after skipping an invalid regex, got %q, want ESMTP", got) + } + }) + t.Run("no extractors is nil", func(t *testing.T) { + if runTCPExtractors(nil, data) != nil { + t.Error("want nil for no extractors") + } + }) +} + +func TestExecuteTCPModuleContextCancel(t *testing.T) { + // a server that never replies: the read blocks until the context cancels and + // the deadline-trip unblocks it. + orig := newTCPConn + newTCPConn = func(_ context.Context, _ string, _ time.Duration) (net.Conn, error) { + client, server := net.Pipe() + t.Cleanup(func() { server.Close() }) + return client, nil + } + t.Cleanup(func() { newTCPConn = orig }) + + ctx, cancel := context.WithCancel(context.Background()) + go func() { + time.Sleep(20 * time.Millisecond) + cancel() + }() + + def := tcpDef(&TCPConfig{Port: 22, Matchers: []Matcher{tcpWord("x")}}) + start := time.Now() + res, err := ExecuteTCPModule(ctx, "example.com", def, Options{Timeout: 5 * time.Second}) + // the cancel must trip the deadline and unblock the read promptly, well + // before the 5s read budget would have expired on its own. + if elapsed := time.Since(start); elapsed > 2*time.Second { + t.Errorf("returned after %v, want a prompt cancel (the deadline trip did not fire)", elapsed) + } + if !errors.Is(err, context.Canceled) { + t.Fatalf("err = %v, want context.Canceled", err) + } + if len(res.Findings) != 0 { + t.Errorf("got %d findings on cancel, want 0", len(res.Findings)) + } +} + +// fakeSlowConn is a net.Conn stand-in whose Write yields for a fixed duration +// before returning success regardless of any deadline, and whose Read blocks +// until whatever deadline was last armed via SetReadDeadline/SetDeadline, then +// returns a timeout error. It reproduces a real socket closely enough to prove +// the cancellation race: a cancel landing while the slow Write is in flight +// trips the watchdog's SetDeadline(now) before readTCP arms its own (later) +// read deadline. +type fakeSlowConn struct { + net.Conn + mu sync.Mutex + readDeadline time.Time + writeYield time.Duration +} + +func (c *fakeSlowConn) Write(b []byte) (int, error) { + time.Sleep(c.writeYield) + return len(b), nil +} + +func (c *fakeSlowConn) Read([]byte) (int, error) { + c.mu.Lock() + dl := c.readDeadline + c.mu.Unlock() + if dl.IsZero() { + dl = time.Now().Add(time.Hour) + } + if wait := time.Until(dl); wait > 0 { + time.Sleep(wait) + } + return 0, os.ErrDeadlineExceeded +} + +func (c *fakeSlowConn) SetDeadline(t time.Time) error { + c.mu.Lock() + c.readDeadline = t + c.mu.Unlock() + return nil +} + +func (c *fakeSlowConn) SetReadDeadline(t time.Time) error { return c.SetDeadline(t) } +func (c *fakeSlowConn) SetWriteDeadline(time.Time) error { return nil } +func (c *fakeSlowConn) Close() error { return nil } + +// TestExecuteTCPModuleContextCancelDuringWrite reproduces the deadline re-arm +// race: the ctx is cancelled from another goroutine while the probe Write is +// still yielding, so the watchdog's conn.SetDeadline(now) trip lands mid-write +// rather than before ExecuteTCPModule is even entered. The Write then returns +// its normal success (a fake real socket does not itself enforce the deadline +// on a write already in flight), so execution reaches readTCP with the ctx +// already done but no error yet surfaced. Before the fix, readTCP's own +// SetReadDeadline(now+timeout) call overwrote the watchdog's trip and the read +// blocked for the full timeout instead of returning promptly. +func TestExecuteTCPModuleContextCancelDuringWrite(t *testing.T) { + conn := &fakeSlowConn{writeYield: 200 * time.Millisecond} + orig := newTCPConn + newTCPConn = func(context.Context, string, time.Duration) (net.Conn, error) { return conn, nil } + t.Cleanup(func() { newTCPConn = orig }) + + ctx, cancel := context.WithCancel(context.Background()) + go func() { + time.Sleep(50 * time.Millisecond) + cancel() + }() + + def := tcpDef(&TCPConfig{Port: 22, Data: "PING\r\n", Matchers: []Matcher{tcpWord("x")}}) + start := time.Now() + res, err := ExecuteTCPModule(ctx, "example.com", def, Options{Timeout: 2 * time.Second}) + elapsed := time.Since(start) + + if elapsed > time.Second { + t.Errorf("returned after %v, want prompt (a cancel landing mid-write must not be swallowed by the read-deadline re-arm)", elapsed) + } + if !errors.Is(err, context.Canceled) { + t.Fatalf("err = %v, want context.Canceled", err) + } + if len(res.Findings) != 0 { + t.Errorf("got %d findings on cancel, want 0", len(res.Findings)) + } +} + +// TestExecuteTCPModuleContextCancelBeforeWrite proves an already-cancelled ctx +// is caught before the probe write arms its deadline, rather than falling +// through to SetWriteDeadline and Write regardless. Without the guard this +// would only fail if the write itself then blocked past the deadline; here it +// is asserted directly so the guard cannot regress silently. +func TestExecuteTCPModuleContextCancelBeforeWrite(t *testing.T) { + client, server := net.Pipe() + t.Cleanup(func() { server.Close() }) + orig := newTCPConn + newTCPConn = func(context.Context, string, time.Duration) (net.Conn, error) { return client, nil } + t.Cleanup(func() { newTCPConn = orig }) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + def := tcpDef(&TCPConfig{Port: 22, Data: "PING\r\n", Matchers: []Matcher{tcpWord("x")}}) + res, err := ExecuteTCPModule(ctx, "example.com", def, Options{Timeout: 2 * time.Second}) + if !errors.Is(err, context.Canceled) { + t.Fatalf("err = %v, want context.Canceled", err) + } + if len(res.Findings) != 0 { + t.Errorf("got %d findings on cancel, want 0", len(res.Findings)) + } +} + +// TestExecuteTCPModuleContextCancelBlocksInWrite reproduces the write-side +// twin of the read-deadline re-arm race: the probe write blocks on a real +// synchronous conn (net.Pipe with no reader), and the cancel watchdog's +// SetDeadline(now) is what has to unblock it. Before the ctx.Err() guard, the +// watchdog trip could land between goroutine start and the SetWriteDeadline +// call and be silently overwritten by it, since the watchdog only fires once +// and never gets a second chance to re-trip; the write would then block for +// the full timeout instead of returning promptly, and a write that the +// watchdog did manage to trip surfaced as a raw i/o timeout rather than the +// cancellation it actually was. +func TestExecuteTCPModuleContextCancelBlocksInWrite(t *testing.T) { + client, server := net.Pipe() + t.Cleanup(func() { server.Close() }) // no reader: the probe write blocks until the deadline trips + + orig := newTCPConn + newTCPConn = func(context.Context, string, time.Duration) (net.Conn, error) { return client, nil } + t.Cleanup(func() { newTCPConn = orig }) + + ctx, cancel := context.WithCancel(context.Background()) + go func() { + time.Sleep(20 * time.Millisecond) + cancel() + }() + + def := tcpDef(&TCPConfig{Port: 22, Data: "PING\r\n", Matchers: []Matcher{tcpWord("x")}}) + start := time.Now() + res, err := ExecuteTCPModule(ctx, "example.com", def, Options{Timeout: 5 * time.Second}) + if elapsed := time.Since(start); elapsed > 2*time.Second { + t.Errorf("returned after %v, want the watchdog to trip the blocked write promptly", elapsed) + } + if !errors.Is(err, context.Canceled) { + t.Fatalf("err = %v, want context.Canceled", err) + } + if len(res.Findings) != 0 { + t.Errorf("got %d findings on cancel, want 0", len(res.Findings)) + } +} + +// TestExecuteTCPModuleSlowConnCompletesWithoutCancel proves the ctx.Err() +// guards added for the read-deadline re-arm fix do not clip a legitimately +// slow but healthy connection: with no cancellation, a banner that trickles +// in well under the timeout still completes and matches normally. +func TestExecuteTCPModuleSlowConnCompletesWithoutCancel(t *testing.T) { + client, server := net.Pipe() + orig := newTCPConn + newTCPConn = func(context.Context, string, time.Duration) (net.Conn, error) { return client, nil } + t.Cleanup(func() { newTCPConn = orig }) + + go func() { + buf := make([]byte, 4096) + _, _ = server.Read(buf) + time.Sleep(200 * time.Millisecond) + _, _ = server.Write([]byte("+OK ready\r\n")) + server.Close() + }() + + def := tcpDef(&TCPConfig{Port: 22, Data: "PING\r\n", Matchers: []Matcher{tcpWord("+OK")}}) + start := time.Now() + res, err := ExecuteTCPModule(context.Background(), "example.com", def, Options{Timeout: 2 * time.Second}) + elapsed := time.Since(start) + + if err != nil { + t.Fatalf("ExecuteTCPModule: %v", err) + } + if elapsed < 200*time.Millisecond { + t.Errorf("returned after %v, want it to wait out the slow banner (~200ms)", elapsed) + } + if len(res.Findings) != 1 { + t.Fatalf("got %d findings, want 1 (a healthy slow connection must not be treated as cancelled)", len(res.Findings)) + } +} + +func TestExecuteTCPModuleDialError(t *testing.T) { + orig := newTCPConn + newTCPConn = func(context.Context, string, time.Duration) (net.Conn, error) { + return nil, fmt.Errorf("connection refused") + } + t.Cleanup(func() { newTCPConn = orig }) + + def := tcpDef(&TCPConfig{Port: 22, Matchers: []Matcher{tcpWord("x")}}) + if _, err := ExecuteTCPModule(context.Background(), "example.com", def, Options{}); err == nil { + t.Fatal("expected error when the dial fails") + } +} + +func TestExecuteTCPModuleWriteError(t *testing.T) { + // a pipe whose far end is already closed fails the probe write. + orig := newTCPConn + newTCPConn = func(context.Context, string, time.Duration) (net.Conn, error) { + client, server := net.Pipe() + server.Close() + return client, nil + } + t.Cleanup(func() { newTCPConn = orig }) + + def := tcpDef(&TCPConfig{Port: 6379, Data: "PING\r\n", Matchers: []Matcher{tcpWord("x")}}) + if _, err := ExecuteTCPModule(context.Background(), "example.com", def, Options{Timeout: time.Second}); err == nil { + t.Fatal("expected error when the probe write fails") + } +} + +// TestExecuteTCPModuleRealListener drives the executor over the real net.Dialer +// against a live 127.0.0.1 listener (no newTCPConn stub), so the actual dial, +// probe write, and banner read path is exercised end to end. A matching banner +// yields a finding with the extracted version; a different banner yields none. +func TestExecuteTCPModuleRealListener(t *testing.T) { + serve := func(banner string) (port int, stop func()) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + done := make(chan struct{}) + go func() { + defer close(done) + for { + conn, err := ln.Accept() + if err != nil { + return + } + go func(c net.Conn) { + defer c.Close() + buf := make([]byte, 4096) + _ = c.SetReadDeadline(time.Now().Add(time.Second)) + _, _ = c.Read(buf) + _, _ = c.Write([]byte(banner)) + }(conn) + } + }() + return ln.Addr().(*net.TCPAddr).Port, func() { ln.Close(); <-done } + } + + t.Run("positive", func(t *testing.T) { + port, stop := serve("+OK Redis 7.2.4 ready\r\n") + defer stop() + def := tcpDef(&TCPConfig{ + Port: port, + Data: "INFO\r\n", + Matchers: []Matcher{tcpWord("+OK", "Redis")}, + Extractors: []Extractor{ + {Type: "regex", Name: "version", Regex: []string{`Redis (\d+\.\d+\.\d+)`}, Group: 1}, + }, + }) + res, err := ExecuteTCPModule(context.Background(), "127.0.0.1", def, Options{Timeout: 2 * time.Second}) + if err != nil { + t.Fatalf("ExecuteTCPModule: %v", err) + } + if len(res.Findings) != 1 { + t.Fatalf("got %d findings, want 1", len(res.Findings)) + } + if got := res.Findings[0].Extracted["version"]; got != "7.2.4" { + t.Errorf("extracted version = %q, want 7.2.4", got) + } + }) + + t.Run("negative", func(t *testing.T) { + port, stop := serve("-NOAUTH Authentication required\r\n") + defer stop() + def := tcpDef(&TCPConfig{ + Port: port, + Data: "INFO\r\n", + Matchers: []Matcher{tcpWord("redis_version:")}, + }) + res, err := ExecuteTCPModule(context.Background(), "127.0.0.1", def, Options{Timeout: 2 * time.Second}) + if err != nil { + t.Fatalf("ExecuteTCPModule: %v", err) + } + if len(res.Findings) != 0 { + t.Fatalf("got %d findings, want 0", len(res.Findings)) + } + }) +} + +func TestExecuteTCPModuleNoConfig(t *testing.T) { + def := &YAMLModule{ID: "x", Type: TypeTCP} + if _, err := ExecuteTCPModule(context.Background(), "example.com", def, Options{}); err == nil { + t.Fatal("expected error when TCP config is nil") + } +} + +func TestExecuteTCPModuleNoHost(t *testing.T) { + def := tcpDef(&TCPConfig{Port: 22, Matchers: []Matcher{tcpWord("x")}}) + if _, err := ExecuteTCPModule(context.Background(), "", def, Options{}); err == nil { + t.Fatal("expected error when the target has no host") + } +} + +func TestReadTCPCapsAtLimit(t *testing.T) { + client, server := net.Pipe() + go func() { + defer server.Close() + chunk := make([]byte, 8192) + for i := 0; i < 12; i++ { // 96 KiB offered, past the 64 KiB cap + if _, err := server.Write(chunk); err != nil { + return + } + } + }() + defer client.Close() + + got := readTCP(context.Background(), client, time.Second) + if len(got) < tcpReadLimit { + t.Fatalf("read %d bytes, want at least the %d cap", len(got), tcpReadLimit) + } + if len(got) > tcpReadLimit+4096 { + t.Fatalf("read %d bytes, want no more than the cap plus one buffer", len(got)) + } +} + +func TestTCPHost(t *testing.T) { + cases := map[string]string{ + "example.com": "example.com", + "https://example.com:8443/path?q=1": "example.com", + "redis://user:pass@host.tld:6379": "host.tld", + "1.2.3.4:6379": "1.2.3.4", + "[2606:4700::1111]:6379": "2606:4700::1111", + "/justpath": "/justpath", + "": "", + } + for in, want := range cases { + if got := tcpHost(in); got != want { + t.Errorf("tcpHost(%q) = %q, want %q", in, got, want) + } + } +} + +func TestTCPAddress(t *testing.T) { + t.Run("strips target port for the configured one", func(t *testing.T) { + got, err := tcpAddress("example.com:80", 6379) + if err != nil { + t.Fatalf("tcpAddress: %v", err) + } + if got != "example.com:6379" { + t.Errorf("address = %q, want example.com:6379", got) + } + }) + t.Run("empty host errors", func(t *testing.T) { + if _, err := tcpAddress("", 6379); err == nil { + t.Fatal("expected error for an empty host") + } + }) +} + +func TestValidateTCP(t *testing.T) { + tests := []struct { + name string + cfg *TCPConfig + ok bool + }{ + {"valid port and matchers", &TCPConfig{Port: 6379, Matchers: []Matcher{{Type: "word"}, {Type: "regex"}, {Type: "size"}}}, true}, + {"no matchers is allowed", &TCPConfig{Port: 22}, true}, + {"port zero rejected", &TCPConfig{Port: 0}, false}, + {"port too high rejected", &TCPConfig{Port: 70000}, false}, + {"status matcher rejected", &TCPConfig{Port: 22, Matchers: []Matcher{{Type: "status"}}}, false}, + {"favicon matcher rejected", &TCPConfig{Port: 22, Matchers: []Matcher{{Type: "favicon"}}}, false}, + {"or condition allowed", &TCPConfig{Port: 6379, MatchersCondition: "or"}, true}, + {"and condition allowed", &TCPConfig{Port: 6379, MatchersCondition: "and"}, true}, + {"unknown condition rejected", &TCPConfig{Port: 6379, MatchersCondition: "xor"}, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateTCP(tt.cfg) + if (err == nil) != tt.ok { + t.Errorf("validateTCP = %v, want ok=%v", err, tt.ok) + } + }) + } +} + +func TestParseTCPValidation(t *testing.T) { + dir := t.TempDir() + write := func(name, body string) string { + p := filepath.Join(dir, name) + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + return p + } + + good := write("good.yaml", "id: ok\ntype: tcp\ntcp:\n port: 6379\n matchers:\n - type: word\n words: [PONG]\n") + if _, err := ParseYAMLModule(good); err != nil { + t.Fatalf("valid tcp module rejected: %v", err) + } + + badPort := write("badport.yaml", "id: bp\ntype: tcp\ntcp:\n port: 0\n") + if _, err := ParseYAMLModule(badPort); err == nil { + t.Fatal("port zero accepted") + } + + badMatcher := write("badmatcher.yaml", "id: bm\ntype: tcp\ntcp:\n port: 22\n matchers:\n - type: status\n status: [0]\n") + if _, err := ParseYAMLModule(badMatcher); err == nil { + t.Fatal("status matcher on tcp accepted") + } + + badCond := write("badcond.yaml", "id: bc\ntype: tcp\ntcp:\n port: 6379\n matchers-condition: xor\n matchers:\n - type: word\n words: [PONG]\n") + if _, err := ParseYAMLModule(badCond); err == nil { + t.Fatal("invalid matchers-condition on tcp accepted") + } +} diff --git a/internal/modules/yaml.go b/internal/modules/yaml.go index d4042392..bbdde3b4 100644 --- a/internal/modules/yaml.go +++ b/internal/modules/yaml.go @@ -76,10 +76,11 @@ type DNSConfig struct { // TCPConfig defines TCP module settings type TCPConfig struct { - Port int `yaml:"port"` - Data string `yaml:"data,omitempty"` - Matchers []Matcher `yaml:"matchers"` - Extractors []Extractor `yaml:"extractors,omitempty"` + Port int `yaml:"port"` + Data string `yaml:"data,omitempty"` + Matchers []Matcher `yaml:"matchers"` + MatchersCondition string `yaml:"matchers-condition,omitempty"` // and (default), or + Extractors []Extractor `yaml:"extractors,omitempty"` } // ParseYAMLModule parses a YAML file into a module definition @@ -110,6 +111,11 @@ func ParseYAMLModule(path string) (*YAMLModule, error) { return nil, fmt.Errorf("module %q: %w", ym.ID, err) } } + if ym.TCP != nil { + if err := validateTCP(ym.TCP); err != nil { + return nil, fmt.Errorf("module %q: %w", ym.ID, err) + } + } var matchers []Matcher switch { case ym.HTTP != nil: diff --git a/modules/recon/ftp-banner-exposure.yaml b/modules/recon/ftp-banner-exposure.yaml new file mode 100644 index 00000000..1fe071df --- /dev/null +++ b/modules/recon/ftp-banner-exposure.yaml @@ -0,0 +1,26 @@ +# FTP Service Banner Detection Module + +id: ftp-banner-exposure +info: + name: FTP Service Banner Disclosure + author: sif + severity: info + description: Reads the 220 greeting an FTP server sends on connect, disclosing the daemon and version (often vsFTPd, ProFTPD, or Pure-FTPd) that an attacker can fingerprint for known issues + tags: [ftp, banner, version, tcp, exposure, recon] + +type: tcp + +tcp: + port: 21 + + matchers: + - type: regex + regex: + - "^220[ -]" + + extractors: + - type: regex + name: ftp_banner + regex: + - "^220[ -]([^\\r\\n]+)" + group: 1 diff --git a/modules/recon/imap-banner-exposure.yaml b/modules/recon/imap-banner-exposure.yaml new file mode 100644 index 00000000..03d2f0e4 --- /dev/null +++ b/modules/recon/imap-banner-exposure.yaml @@ -0,0 +1,26 @@ +# IMAP Service Banner Detection Module + +id: imap-banner-exposure +info: + name: IMAP Service Banner Disclosure + author: sif + severity: info + description: Reads the untagged * OK greeting an IMAP server sends on connect (RFC 3501), confirming a mail-access service is reachable and disclosing the daemon banner and capabilities before authentication + tags: [imap, mail, banner, tcp, exposure, recon] + +type: tcp + +tcp: + port: 143 + + matchers: + - type: regex + regex: + - "^\\* OK" + + extractors: + - type: regex + name: imap_banner + regex: + - "^\\* OK ([^\\r\\n]+)" + group: 1 diff --git a/modules/recon/memcached-unauth-exposure.yaml b/modules/recon/memcached-unauth-exposure.yaml new file mode 100644 index 00000000..d4208eb4 --- /dev/null +++ b/modules/recon/memcached-unauth-exposure.yaml @@ -0,0 +1,27 @@ +# Memcached Unauthenticated Access Detection Module + +id: memcached-unauth-exposure +info: + name: Memcached Unauthenticated Access + author: sif + severity: high + description: Detects a Memcached server that answers the version command without authentication, confirming unauthenticated access to the cache (a reachable memcached is also a known UDP/TCP reflection-amplification vector) + tags: [memcached, cache, database, tcp, unauth, exposure, recon] + +type: tcp + +tcp: + port: 11211 + data: "version\r\n" + + matchers: + - type: word + words: + - "VERSION " + + extractors: + - type: regex + name: memcached_version + regex: + - "VERSION ([0-9.]+)" + group: 1 diff --git a/modules/recon/mysql-exposure.yaml b/modules/recon/mysql-exposure.yaml new file mode 100644 index 00000000..62a3c451 --- /dev/null +++ b/modules/recon/mysql-exposure.yaml @@ -0,0 +1,30 @@ +# MySQL/MariaDB Network Exposure Detection Module + +id: mysql-exposure +info: + name: MySQL/MariaDB Network Exposure + author: sif + severity: medium + description: Detects a network-reachable MySQL or MariaDB server by its initial handshake packet, which advertises an authentication plugin and the server version in cleartext before any login (a database port open to the network is an attack surface regardless of credentials) + tags: [mysql, mariadb, database, tcp, exposure, recon] + +type: tcp + +tcp: + port: 3306 + + matchers-condition: or + matchers: + - type: word + words: + - "mysql_native_password" + - type: word + words: + - "caching_sha2_password" + + extractors: + - type: regex + name: mysql_version + regex: + - "([0-9]+\\.[0-9]+\\.[0-9]+[0-9A-Za-z.\\-]*)" + group: 1 diff --git a/modules/recon/pop3-banner-exposure.yaml b/modules/recon/pop3-banner-exposure.yaml new file mode 100644 index 00000000..f2a0debe --- /dev/null +++ b/modules/recon/pop3-banner-exposure.yaml @@ -0,0 +1,26 @@ +# POP3 Service Banner Detection Module + +id: pop3-banner-exposure +info: + name: POP3 Service Banner Disclosure + author: sif + severity: info + description: Reads the +OK greeting a POP3 server sends on connect (RFC 1939), confirming a cleartext mail-retrieval service is reachable and disclosing the daemon banner before authentication + tags: [pop3, mail, banner, tcp, exposure, recon] + +type: tcp + +tcp: + port: 110 + + matchers: + - type: regex + regex: + - "^\\+OK" + + extractors: + - type: regex + name: pop3_banner + regex: + - "^\\+OK ?([^\\r\\n]*)" + group: 1 diff --git a/modules/recon/redis-unauth-exposure.yaml b/modules/recon/redis-unauth-exposure.yaml new file mode 100644 index 00000000..ba6aec02 --- /dev/null +++ b/modules/recon/redis-unauth-exposure.yaml @@ -0,0 +1,27 @@ +# Redis Unauthenticated Access Detection Module + +id: redis-unauth-exposure +info: + name: Redis Unauthenticated Access + author: sif + severity: high + description: Detects a Redis server that answers INFO without authentication, leaking server details and confirming unauthenticated data access (an auth-required server replies -NOAUTH and never reaches redis_version) + tags: [redis, database, tcp, unauth, exposure, recon] + +type: tcp + +tcp: + port: 6379 + data: "INFO\r\n" + + matchers: + - type: word + words: + - "redis_version:" + + extractors: + - type: regex + name: redis_version + regex: + - "redis_version:([0-9.]+)" + group: 1 diff --git a/modules/recon/rsync-exposure.yaml b/modules/recon/rsync-exposure.yaml new file mode 100644 index 00000000..57fecbcd --- /dev/null +++ b/modules/recon/rsync-exposure.yaml @@ -0,0 +1,26 @@ +# Rsync Daemon Exposure Detection Module + +id: rsync-exposure +info: + name: Rsync Daemon Exposure + author: sif + severity: medium + description: Detects an exposed rsync daemon by the @RSYNCD greeting it sends on connect, confirming a network-reachable rsync service that often permits anonymous module listing and unauthenticated file transfer + tags: [rsync, file-transfer, tcp, exposure, recon] + +type: tcp + +tcp: + port: 873 + + matchers: + - type: word + words: + - "@RSYNCD:" + + extractors: + - type: regex + name: rsync_protocol + regex: + - "@RSYNCD: ([0-9.]+)" + group: 1 diff --git a/modules/recon/smtp-banner-exposure.yaml b/modules/recon/smtp-banner-exposure.yaml new file mode 100644 index 00000000..56f7b656 --- /dev/null +++ b/modules/recon/smtp-banner-exposure.yaml @@ -0,0 +1,26 @@ +# SMTP Service Banner Detection Module + +id: smtp-banner-exposure +info: + name: SMTP Service Banner Disclosure + author: sif + severity: info + description: Reads the 220 ESMTP greeting a mail server sends on connect (RFC 5321), disclosing the MTA and version (often Postfix, Exim, Sendmail, or Exchange) that an attacker can fingerprint for known issues + tags: [smtp, mail, banner, version, tcp, exposure, recon] + +type: tcp + +tcp: + port: 25 + + matchers: + - type: regex + regex: + - "(?i)^220[ -].*smtp" + + extractors: + - type: regex + name: smtp_banner + regex: + - "^220[ -]([^\\r\\n]+)" + group: 1 diff --git a/modules/recon/ssh-version-exposure.yaml b/modules/recon/ssh-version-exposure.yaml new file mode 100644 index 00000000..f12df328 --- /dev/null +++ b/modules/recon/ssh-version-exposure.yaml @@ -0,0 +1,26 @@ +# SSH Service Banner Detection Module + +id: ssh-version-exposure +info: + name: SSH Service Version Disclosure + author: sif + severity: info + description: Reads the SSH identification string the server sends on connect (RFC 4253), disclosing the implementation and version that an attacker can match against known CVEs + tags: [ssh, banner, version, tcp, exposure, recon] + +type: tcp + +tcp: + port: 22 + + matchers: + - type: regex + regex: + - "^SSH-[0-9]+\\.[0-9]+-" + + extractors: + - type: regex + name: ssh_software + regex: + - "^SSH-[0-9]+\\.[0-9]+-(\\S+)" + group: 1 diff --git a/modules/recon/vnc-exposure.yaml b/modules/recon/vnc-exposure.yaml new file mode 100644 index 00000000..f44d90da --- /dev/null +++ b/modules/recon/vnc-exposure.yaml @@ -0,0 +1,26 @@ +# VNC Service Exposure Detection Module + +id: vnc-exposure +info: + name: VNC Service Exposure + author: sif + severity: medium + description: Detects a network-reachable VNC server by the RFB ProtocolVersion handshake it sends on connect (RFC 6143), confirming a remote-desktop service is exposed and disclosing the protocol version before authentication + tags: [vnc, rfb, remote-access, tcp, exposure, recon] + +type: tcp + +tcp: + port: 5900 + + matchers: + - type: regex + regex: + - "^RFB [0-9]{3}\\.[0-9]{3}" + + extractors: + - type: regex + name: rfb_version + regex: + - "^RFB ([0-9]{3}\\.[0-9]{3})" + group: 1