Files
sif/internal/modules/tcp.go
T
TigahandGitHub bf8c6a8643 feat(modules): add tcp module executor and recon modules (#308)
* feat(modules): tcp module executor

dial+probe+banner with word/regex/size matchers, ctx-honest watcher,
port required.

* feat(modules): add redis unauthenticated access tcp module

first shipped tcp module: sends INFO to redis (6379) and matches
redis_version in the reply, which an auth-required server never returns
(it answers -NOAUTH). extracts the leaked version.

* docs(modules): document the tcp module type

flip the http-only note and add a tcp config section (port, data,
word/regex/size matchers and regex extractors against the banner),
pointing at the redis demo module.

* feat(modules): support matchers-condition in tcp modules

tcp matchers were and-only; add the matchers-condition key (and default,
or) mirroring the http executor's checkMatchers, validated at load via the
shared validateMatchersCondition. documents the override in the combining
matchers section.

* feat(modules): decode escape sequences in tcp data payloads

decode C-style escapes (\\ \a \b \f \n \r \t \v \xHH) in a tcp module's
data value before it goes on the wire, so a single-quoted or plain yaml
scalar reaches the service with the same control bytes a double-quoted
one already would. an unrecognized escape is kept verbatim so no bytes
are silently lost.

* feat(modules): add tcp recon modules for common services

add five built-in tcp modules alongside the redis one so the executor
ships a real service set: memcached (unauth version probe), ssh and ftp
(passive banner version disclosure), mysql/mariadb (handshake exposure,
matched with matchers-condition: or across both auth plugin names), and
vnc (rfb handshake exposure). each fingerprint tracks the documented
on-wire protocol and pulls the version into an extractor.

* feat(modules): add mail and rsync tcp recon modules

add four more passive-banner tcp modules: smtp, pop3, and imap read the
greeting each mail service sends on connect (the smtp matcher requires
an esmtp/smtp marker so it does not fire on a bare 220 ftp greeting),
and rsync detects an exposed daemon by its @RSYNCD handshake. each pulls
the banner or protocol version into an extractor.
2026-07-22 12:49:22 -07:00

308 lines
9.0 KiB
Go

/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · 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 != "" {
payload := decodeTCPData(cfg.Data)
_ = conn.SetWriteDeadline(time.Now().Add(timeout))
if _, err := conn.Write([]byte(payload)); err != nil {
return nil, fmt.Errorf("tcp write %q: %w", addr, err)
}
}
data := readTCP(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.
func readTCP(conn net.Conn, timeout time.Duration) string {
_ = conn.SetReadDeadline(time.Now().Add(timeout))
var out []byte
buf := make([]byte, 4096)
for len(out) < tcpReadLimit {
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
}