mirror of
https://github.com/lunchcat/sif.git
synced 2026-07-28 14:37:01 -07:00
* fix(tcp): stop the read-deadline re-arm from swallowing a cancelled ctx the cancel watchdog trips conn.SetDeadline(now) once. if that trip lands while the probe write is still in flight, readTCP's later SetReadDeadline(now+timeout) call silently overwrote it, so the read blocked for the full timeout instead of returning promptly. thread ctx into readTCP and check it before arming the deadline and on every read iteration so a cancellation already in flight aborts immediately. * test(tcp): make the cancel-during-write test exercise the actual race the old test cancelled ctx before calling ExecuteTCPModule, so the pre-write ctx.Err() guard returned immediately and the test never reached readTCP at all; reverting the fix and rerunning it still passed in ~0ms. cancel from a goroutine mid-write instead, so the watchdog trips the deadline while readTCP is the next call on the path, and assert the call returns promptly rather than blocking for the full timeout. also add a case proving a legitimately slow but uncancelled connection still completes and matches normally.
334 lines
10 KiB
Go
334 lines
10 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 != "" {
|
|
// 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
|
|
}
|