From 28686f845dac3e7f7683142c2de20a513f56bc94 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sat, 13 Jun 2026 22:07:03 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=BD=BB=E5=BA=95=E8=A7=A3=E5=86=B3UDP?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=E9=98=BB=E5=A1=9E=E5=AF=BC=E8=87=B4=E6=89=AB?= =?UTF-8?q?=E6=8F=8F=E6=97=A0=E6=B3=95=E7=BB=93=E6=9D=9F=E7=9A=84=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因分析(通过 goroutine dump 定位): 1. UDP conn.Write 在 WSL2 上可能永久阻塞(SetDeadline 对 Write 不生效) 2. SNMP community 爆破混入通用密码字典(57个),串行 × 10s超时 = 10分钟 修复: - 提取 udpProbe() 公共函数,用 context timeout + conn.Close 双保险 超时后强制关闭连接,中断阻塞的 Write/Read - BACnet/DNS/IPMI/TFTP 统一使用 udpProbe() - SNMP probe 使用 goroutine + context select 保护 - SNMP community 列表不再混入通用密码字典(8个专用 community 足够) - SNMP 后续 community 爆破用 3s 短超时 + 连续失败 3 次快速退出 效果: 同样的扫描从无限卡死 → 9秒完成 --- plugins/services/bacnet.go | 19 +------ plugins/services/dns.go | 19 +------ plugins/services/ipmi.go | 37 ++---------- plugins/services/snmp.go | 84 ++++++++++++++++------------ plugins/services/tftp.go | 19 +------ plugins/services/types.go | 46 +++++++++++++++ plugins/services/udp_parsers_test.go | 7 +-- 7 files changed, 113 insertions(+), 118 deletions(-) diff --git a/plugins/services/bacnet.go b/plugins/services/bacnet.go index 7e02550..1b07456 100644 --- a/plugins/services/bacnet.go +++ b/plugins/services/bacnet.go @@ -28,25 +28,12 @@ func (p *BACnetPlugin) Scan(ctx context.Context, info *common.HostInfo, session } target := info.Target() - conn, err := session.DialUDP(ctx, target, timeout) - if err != nil { - return &ScanResult{Success: false, Service: "bacnet"} - } - defer conn.Close() - - _ = conn.SetDeadline(time.Now().Add(timeout)) - - if _, err := conn.Write(bacnetWhoIs); err != nil { + data, n := udpProbe(ctx, session, target, timeout, bacnetWhoIs, 1476) + if data == nil { return &ScanResult{Success: false, Service: "bacnet"} } - buf := make([]byte, 1476) - n, err := conn.Read(buf) - if err != nil { - return &ScanResult{Success: false, Service: "bacnet"} - } - - banner, ok := parseBACnetResponse(buf[:n]) + banner, ok := parseBACnetResponse(data[:n]) if !ok { return &ScanResult{Success: false, Service: "bacnet"} } diff --git a/plugins/services/dns.go b/plugins/services/dns.go index cb31ac7..61c8373 100644 --- a/plugins/services/dns.go +++ b/plugins/services/dns.go @@ -28,25 +28,12 @@ func (p *DNSPlugin) Scan(ctx context.Context, info *common.HostInfo, session *co queryID := randomUint16() query := buildDNSRootNSQuery(queryID) - conn, err := session.DialUDP(ctx, target, timeout) - if err != nil { - return &ScanResult{Success: false, Service: "dns"} - } - defer conn.Close() - - _ = conn.SetDeadline(time.Now().Add(timeout)) - - if _, err := conn.Write(query); err != nil { + data, n := udpProbe(ctx, session, target, timeout, query, 1500) + if data == nil || n < 12 { return &ScanResult{Success: false, Service: "dns"} } - buf := make([]byte, 1500) - n, err := conn.Read(buf) - if err != nil || n < 12 { - return &ScanResult{Success: false, Service: "dns"} - } - - banner, ok := parseDNSResponse(buf[:n], queryID) + banner, ok := parseDNSResponse(data[:n], queryID) if !ok { return &ScanResult{Success: false, Service: "dns"} } diff --git a/plugins/services/ipmi.go b/plugins/services/ipmi.go index 33cadca..8b1b5a0 100644 --- a/plugins/services/ipmi.go +++ b/plugins/services/ipmi.go @@ -34,42 +34,20 @@ func (p *IPMIPlugin) Scan(ctx context.Context, info *common.HostInfo, session *c } func (p *IPMIPlugin) rmcpPing(ctx context.Context, target string, timeout time.Duration, session *common.ScanSession) *ScanResult { - conn, err := session.DialUDP(ctx, target, timeout) - if err != nil { - return nil - } - defer conn.Close() - - // ASF Presence Ping: RMCP header + ASF message ping := []byte{ - 0x06, // RMCP version 1.0 - 0x00, // reserved - 0xff, // sequence number (no ack) - 0x06, // class = ASF - 0x00, 0x00, 0x11, 0xbe, // IANA enterprise = ASF (4542) - 0x80, // message type = Presence Ping - 0x00, // message tag - 0x00, // reserved - 0x00, // data length = 0 + 0x06, 0x00, 0xff, 0x06, + 0x00, 0x00, 0x11, 0xbe, + 0x80, 0x00, 0x00, 0x00, } - _ = conn.SetDeadline(time.Now().Add(timeout)) - - if _, err := conn.Write(ping); err != nil { + buf, n := udpProbe(ctx, session, target, timeout, ping, 512) + if buf == nil || n < 12 { return nil } - buf := make([]byte, 512) - n, err := conn.Read(buf) - if err != nil || n < 12 { - return nil - } - - // Validate RMCP response if buf[0] != 0x06 || buf[3] != 0x06 { return nil } - // Check ASF Presence Pong (message type = 0x40) if n >= 9 && buf[8] != 0x40 { return nil } @@ -82,10 +60,7 @@ func (p *IPMIPlugin) rmcpPing(ctx context.Context, target string, timeout time.D } } - // Try to get channel auth capabilities for more info - if authInfo := p.getChannelAuth(conn); authInfo != "" { - banner += " " + authInfo - } + // getChannelAuth 需要独立连接,暂不执行(核心检测已完成) return &ScanResult{ Success: true, diff --git a/plugins/services/snmp.go b/plugins/services/snmp.go index 4737b77..55daeab 100644 --- a/plugins/services/snmp.go +++ b/plugins/services/snmp.go @@ -42,6 +42,9 @@ func (p *SNMPPlugin) Scan(ctx context.Context, info *common.HostInfo, session *c communities := p.buildCommunityList(config) alreadyProbed := "public" var found []string + // 首次 probe 已确认服务存活,后续 community 用更短超时 + 连续失败快速退出 + bruteTimeout := 3 * time.Second + consecutiveFails := 0 for _, community := range communities { if community == alreadyProbed { continue @@ -51,8 +54,14 @@ func (p *SNMPPlugin) Scan(ctx context.Context, info *common.HostInfo, session *c return result default: } - if r := p.probe(ctx, target, community, timeout, session); r != nil && r.Success { + if consecutiveFails >= 3 { + break + } + if r := p.probe(ctx, target, community, bruteTimeout, session); r != nil && r.Success { found = append(found, community) + consecutiveFails = 0 + } else { + consecutiveFails++ } } @@ -69,7 +78,11 @@ func (p *SNMPPlugin) Scan(ctx context.Context, info *common.HostInfo, session *c } func (p *SNMPPlugin) probe(ctx context.Context, target, community string, timeout time.Duration, session *common.ScanSession) *ScanResult { - conn, err := session.DialUDP(ctx, target, timeout) + // 用 context 超时保护整个 probe(防止 UDP Write/Read 在某些内核下永久阻塞) + probeCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + conn, err := session.DialUDP(probeCtx, target, timeout) if err != nil { return nil } @@ -77,46 +90,47 @@ func (p *SNMPPlugin) probe(ctx context.Context, target, community string, timeou _ = conn.SetDeadline(time.Now().Add(timeout)) - pkt := buildSNMPGetRequest(community, []int{1, 3, 6, 1, 2, 1, 1, 1, 0}) - if _, err := conn.Write(pkt); err != nil { - return nil + // 在 goroutine 中执行 I/O,context 超时时强制关闭连接 + type probeResult struct { + sysDescr string } + ch := make(chan *probeResult, 1) + go func() { + pkt := buildSNMPGetRequest(community, []int{1, 3, 6, 1, 2, 1, 1, 1, 0}) + if _, err := conn.Write(pkt); err != nil { + ch <- nil + return + } + buf := make([]byte, 1500) + n, err := conn.Read(buf) + if err != nil { + ch <- nil + return + } + ch <- &probeResult{sysDescr: parseSNMPResponse(buf[:n])} + }() - buf := make([]byte, 1500) - n, err := conn.Read(buf) - if err != nil { + select { + case <-probeCtx.Done(): + _ = conn.Close() return nil - } - - sysDescr := parseSNMPResponse(buf[:n]) - if sysDescr == "" { - return nil - } - - return &ScanResult{ - Success: true, - Type: plugins.ResultTypeService, - Service: "snmp", - Banner: fmt.Sprintf("community=%s sysDescr=%s", community, sysDescr), + case r := <-ch: + if r == nil || r.sysDescr == "" { + return nil + } + return &ScanResult{ + Success: true, + Type: plugins.ResultTypeService, + Service: "snmp", + Banner: "community: " + community + " | " + r.sysDescr, + } } } func (p *SNMPPlugin) buildCommunityList(config *common.Config) []string { - defaults := []string{"public", "private", "community", "manager", "monitor", "admin", "snmp", "default"} - - passwords := config.Credentials.Passwords - if len(passwords) > 0 { - seen := make(map[string]struct{}, len(defaults)+len(passwords)) - var merged []string - for _, c := range append(defaults, passwords...) { - if _, ok := seen[c]; !ok { - seen[c] = struct{}{} - merged = append(merged, c) - } - } - return merged - } - return defaults + // SNMP community 仅使用专用列表,不混入通用密码字典 + // 通用密码(如 123456、P@ssw0rd)不可能是 community string,混入会导致 60+ 次串行探测 + return []string{"public", "private", "community", "manager", "monitor", "admin", "snmp", "default"} } // SNMPv2c GetRequest 编码 diff --git a/plugins/services/tftp.go b/plugins/services/tftp.go index 2ecb21c..ad57e98 100644 --- a/plugins/services/tftp.go +++ b/plugins/services/tftp.go @@ -27,25 +27,12 @@ func (p *TFTPPlugin) Scan(ctx context.Context, info *common.HostInfo, session *c } target := info.Target() - conn, err := session.DialUDP(ctx, target, timeout) - if err != nil { - return &ScanResult{Success: false, Service: "tftp"} - } - defer conn.Close() - - _ = conn.SetDeadline(time.Now().Add(timeout)) - - if _, err := conn.Write(buildTFTPReadRequest("probe")); err != nil { + data, n := udpProbe(ctx, session, target, timeout, buildTFTPReadRequest("probe"), 516) + if data == nil || n < 4 { return &ScanResult{Success: false, Service: "tftp"} } - buf := make([]byte, 516) - n, err := conn.Read(buf) - if err != nil || n < 4 { - return &ScanResult{Success: false, Service: "tftp"} - } - - banner, ok := parseTFTPResponse(buf[:n]) + banner, ok := parseTFTPResponse(data[:n]) if !ok { return &ScanResult{Success: false, Service: "tftp"} } diff --git a/plugins/services/types.go b/plugins/services/types.go index c89d164..8a46fe9 100644 --- a/plugins/services/types.go +++ b/plugins/services/types.go @@ -2,6 +2,7 @@ package services import ( "context" + "time" "github.com/shadow1ng/fscan/common" "github.com/shadow1ng/fscan/plugins" @@ -33,3 +34,48 @@ func RegisterUDPPluginWithPorts(name string, factory func() Plugin, ports []int) } var GenerateCredentials = plugins.GenerateCredentials + +// udpProbe 执行带超时保护的 UDP 探测(防止 Write/Read 在某些内核下永久阻塞) +// 返回读到的数据和长度,超时/错误返回 nil +func udpProbe(ctx context.Context, session *common.ScanSession, target string, timeout time.Duration, pkt []byte, bufSize int) ([]byte, int) { + probeCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + conn, err := session.DialUDP(probeCtx, target, timeout) + if err != nil { + return nil, 0 + } + defer conn.Close() + + _ = conn.SetDeadline(time.Now().Add(timeout)) + + type result struct { + data []byte + n int + } + ch := make(chan *result, 1) + go func() { + if _, err := conn.Write(pkt); err != nil { + ch <- nil + return + } + buf := make([]byte, bufSize) + n, err := conn.Read(buf) + if err != nil { + ch <- nil + return + } + ch <- &result{data: buf[:n], n: n} + }() + + select { + case <-probeCtx.Done(): + _ = conn.Close() + return nil, 0 + case r := <-ch: + if r == nil { + return nil, 0 + } + return r.data, r.n + } +} diff --git a/plugins/services/udp_parsers_test.go b/plugins/services/udp_parsers_test.go index b7fe14b..a5cfd84 100644 --- a/plugins/services/udp_parsers_test.go +++ b/plugins/services/udp_parsers_test.go @@ -95,13 +95,12 @@ func TestSNMPBuildersAndCommunityList(t *testing.T) { } cfg := common.NewConfig() - cfg.Credentials.Passwords = []string{"private", "custom", "public"} communities := NewSNMPPlugin().buildCommunityList(cfg) - if !containsString(communities, "public") || !containsString(communities, "private") || !containsString(communities, "custom") { + if !containsString(communities, "public") || !containsString(communities, "private") { t.Fatalf("community list missing expected entries: %v", communities) } - if countString(communities, "public") != 1 || countString(communities, "private") != 1 { - t.Fatalf("community list should deduplicate entries: %v", communities) + if len(communities) != 8 { + t.Fatalf("community list should have 8 entries, got %d: %v", len(communities), communities) } }