/* ·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· : : : █▀ █ █▀▀ · 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, m.CaseInsensitive) 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 }