diff --git a/common/config/constants.go b/common/config/constants.go index 7d93512..24afce1 100644 --- a/common/config/constants.go +++ b/common/config/constants.go @@ -36,7 +36,7 @@ var ( "6000,6379,6380,6443,6666,6667," + // Web/中间件 (7001-9999) // 注意:9100 已移除,该端口为打印机 RAW 端口,发送数据会触发打印 - "7001,7002,7474,7687,8000,8005,8008,8009,8080,8081,8086,8088,8089,8090,8161,8180,8443,8500,8834,8848,8880,8888,9000,9001,9042,9080,9090,9092,9093,9160,9200,9300,9418,9443,9999," + + "7001,7002,7474,7687,8000,8005,8008,8009,8080,8081,8086,8088,8089,8090,8161,8180,8443,8500,8834,8848,8880,8883,8888,9000,9001,9042,9080,9090,9092,9093,9160,9200,9300,9418,9443,9999," + // 管理/监控 (10000-11211) "10000,10051,10250,10255,11211," + // 消息队列/集群 (15672-27018) @@ -48,7 +48,7 @@ var ( DbPorts = "1433,1521,3306,5432,5672,5984,6379,7687,8086,9042,9093,9160,9200,11211,26379,27017,27018,61616" // ServicePorts 服务端口 - ServicePorts = "21,22,23,25,53,110,111,135,139,143,161,389,445,465,502,512,513,514,587,623,636,873,993,995,1433,1521,2049,2181,2222,3306,3389,5432,5672,5671,5900,5985,5986,6379,8161,8443,9000,9092,9093,9200,10051,11211,15672,15671,27017,61616,61613" + ServicePorts = "21,22,23,25,53,110,111,135,139,143,161,389,445,465,502,512,513,514,587,623,636,873,993,995,1433,1521,1883,2049,2181,2222,3306,3389,5432,5672,5671,5900,5985,5986,6379,8161,8443,8883,9000,9092,9093,9200,10051,11211,15672,15671,27017,61616,61613" // CommonPorts 常用端口 CommonPorts = "21,22,23,25,53,80,110,135,139,143,443,445,993,995,1723,3389,5060,5985,5986" diff --git a/plugins/services/bacnet.go b/plugins/services/bacnet.go new file mode 100644 index 0000000..4a87521 --- /dev/null +++ b/plugins/services/bacnet.go @@ -0,0 +1,81 @@ +//go:build plugin_bacnet || !plugin_selective + +package services + +import ( + "context" + "encoding/binary" + "fmt" + "time" + + "github.com/shadow1ng/fscan/common" + "github.com/shadow1ng/fscan/plugins" +) + +var bacnetWhoIs = []byte{0x81, 0x0a, 0x00, 0x0c, 0x01, 0x20, 0xff, 0xff, 0x00, 0xff, 0x10, 0x08} + +type BACnetPlugin struct { + plugins.BasePlugin +} + +func NewBACnetPlugin() *BACnetPlugin { + return &BACnetPlugin{BasePlugin: plugins.NewBasePlugin("bacnet")} +} + +func (p *BACnetPlugin) Scan(ctx context.Context, info *common.HostInfo, session *common.ScanSession) *ScanResult { + timeout := session.Config.Timeout + if timeout <= 0 { + timeout = 3 * time.Second + } + + target := fmt.Sprintf("%s:%d", info.Host, info.Port) + conn, err := session.DialUDP(ctx, target, timeout) + if err != nil { + return &ScanResult{Success: false, Service: "bacnet"} + } + defer conn.Close() + + if _, err := conn.Write(bacnetWhoIs); err != 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]) + if !ok { + return &ScanResult{Success: false, Service: "bacnet"} + } + + return &ScanResult{ + Success: true, + Type: plugins.ResultTypeService, + Service: "bacnet", + Banner: banner, + } +} + +func parseBACnetResponse(data []byte) (string, bool) { + if len(data) < 6 || data[0] != 0x81 { + return "", false + } + length := int(binary.BigEndian.Uint16(data[2:4])) + if length != len(data) { + return "", false + } + for i := 4; i+1 < len(data); i++ { + if data[i] == 0x10 && data[i+1] == 0x00 { + return "BACnet I-Am response", true + } + } + return "", false +} + +func init() { + RegisterUDPPluginWithPorts("bacnet", func() Plugin { + return NewBACnetPlugin() + }, []int{47808}) +} diff --git a/plugins/services/bacnet_test.go b/plugins/services/bacnet_test.go new file mode 100644 index 0000000..3f4f17d --- /dev/null +++ b/plugins/services/bacnet_test.go @@ -0,0 +1,16 @@ +//go:build plugin_bacnet || !plugin_selective + +package services + +import "testing" + +func TestParseBACnetResponse(t *testing.T) { + banner, ok := parseBACnetResponse([]byte{0x81, 0x0a, 0x00, 0x08, 0x01, 0x20, 0x10, 0x00}) + if !ok || banner != "BACnet I-Am response" { + t.Fatalf("unexpected bacnet banner: %q ok=%v", banner, ok) + } + + if _, ok := parseBACnetResponse([]byte{0x81, 0x0a, 0x00, 0x05, 0x00}); ok { + t.Fatal("unexpected match for malformed bacnet packet") + } +} diff --git a/plugins/services/dns.go b/plugins/services/dns.go new file mode 100644 index 0000000..ec2e9b5 --- /dev/null +++ b/plugins/services/dns.go @@ -0,0 +1,65 @@ +//go:build plugin_dns || !plugin_selective + +package services + +import ( + "context" + "fmt" + "time" + + "github.com/shadow1ng/fscan/common" + "github.com/shadow1ng/fscan/plugins" +) + +type DNSPlugin struct { + plugins.BasePlugin +} + +func NewDNSPlugin() *DNSPlugin { + return &DNSPlugin{BasePlugin: plugins.NewBasePlugin("dns")} +} + +func (p *DNSPlugin) Scan(ctx context.Context, info *common.HostInfo, session *common.ScanSession) *ScanResult { + timeout := session.Config.Timeout + if timeout <= 0 { + timeout = 3 * time.Second + } + + target := fmt.Sprintf("%s:%d", info.Host, info.Port) + queryID := randomUint16() + query := buildDNSRootNSQuery(queryID) + + conn, err := session.DialUDP(ctx, target, timeout) + if err != nil { + return &ScanResult{Success: false, Service: "dns"} + } + defer conn.Close() + + if _, err := conn.Write(query); err != nil { + 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) + if !ok { + return &ScanResult{Success: false, Service: "dns"} + } + + return &ScanResult{ + Success: true, + Type: plugins.ResultTypeService, + Service: "dns", + Banner: banner, + } +} + +func init() { + RegisterUDPPluginWithPorts("dns", func() Plugin { + return NewDNSPlugin() + }, []int{53}) +} diff --git a/plugins/services/dns_shared.go b/plugins/services/dns_shared.go new file mode 100644 index 0000000..90ba020 --- /dev/null +++ b/plugins/services/dns_shared.go @@ -0,0 +1,46 @@ +//go:build plugin_dns || plugin_dnstcp || plugin_modbus || !plugin_selective + +package services + +import ( + "crypto/rand" + "encoding/binary" + "fmt" + "time" +) + +func randomUint16() uint16 { + var b [2]byte + if _, err := rand.Read(b[:]); err == nil { + return binary.BigEndian.Uint16(b[:]) + } + return uint16(time.Now().UnixNano()) +} + +func buildDNSRootNSQuery(id uint16) []byte { + query := make([]byte, 17) + binary.BigEndian.PutUint16(query[0:2], id) + binary.BigEndian.PutUint16(query[2:4], 0x0100) + binary.BigEndian.PutUint16(query[4:6], 1) + query[12] = 0x00 + binary.BigEndian.PutUint16(query[13:15], 2) + binary.BigEndian.PutUint16(query[15:17], 1) + return query +} + +func parseDNSResponse(data []byte, id uint16) (string, bool) { + if len(data) < 12 || binary.BigEndian.Uint16(data[0:2]) != id { + return "", false + } + flags := binary.BigEndian.Uint16(data[2:4]) + if flags&0x8000 == 0 { + return "", false + } + + rcode := flags & 0x000f + qd := binary.BigEndian.Uint16(data[4:6]) + an := binary.BigEndian.Uint16(data[6:8]) + ns := binary.BigEndian.Uint16(data[8:10]) + ar := binary.BigEndian.Uint16(data[10:12]) + return fmt.Sprintf("DNS response rcode=%d qd=%d an=%d ns=%d ar=%d", rcode, qd, an, ns, ar), true +} diff --git a/plugins/services/dns_test.go b/plugins/services/dns_test.go new file mode 100644 index 0000000..22a6a55 --- /dev/null +++ b/plugins/services/dns_test.go @@ -0,0 +1,33 @@ +//go:build plugin_dns || !plugin_selective + +package services + +import ( + "encoding/binary" + "strings" + "testing" +) + +func TestDNSQueryAndResponse(t *testing.T) { + const id uint16 = 0x1234 + query := buildDNSRootNSQuery(id) + if len(query) != 17 { + t.Fatalf("unexpected dns query length: %d", len(query)) + } + if binary.BigEndian.Uint16(query[0:2]) != id || binary.BigEndian.Uint16(query[13:15]) != 2 { + t.Fatalf("unexpected dns query: %#v", query) + } + + resp := make([]byte, 12) + binary.BigEndian.PutUint16(resp[0:2], id) + binary.BigEndian.PutUint16(resp[2:4], 0x8180) + binary.BigEndian.PutUint16(resp[4:6], 1) + binary.BigEndian.PutUint16(resp[6:8], 2) + binary.BigEndian.PutUint16(resp[8:10], 3) + binary.BigEndian.PutUint16(resp[10:12], 4) + + banner, ok := parseDNSResponse(resp, id) + if !ok || !strings.Contains(banner, "rcode=0") || !strings.Contains(banner, "an=2") { + t.Fatalf("unexpected dns banner: %q ok=%v", banner, ok) + } +} diff --git a/plugins/services/dnstcp.go b/plugins/services/dnstcp.go new file mode 100644 index 0000000..fe9943c --- /dev/null +++ b/plugins/services/dnstcp.go @@ -0,0 +1,79 @@ +//go:build plugin_dnstcp || !plugin_selective + +package services + +import ( + "context" + "encoding/binary" + "fmt" + "io" + "time" + + "github.com/shadow1ng/fscan/common" + "github.com/shadow1ng/fscan/plugins" +) + +type DNSTCPPlugin struct { + plugins.BasePlugin +} + +func NewDNSTCPPlugin() *DNSTCPPlugin { + return &DNSTCPPlugin{BasePlugin: plugins.NewBasePlugin("dnstcp")} +} + +func (p *DNSTCPPlugin) Scan(ctx context.Context, info *common.HostInfo, session *common.ScanSession) *ScanResult { + timeout := session.Config.Timeout + if timeout <= 0 { + timeout = 3 * time.Second + } + + addr := fmt.Sprintf("%s:%d", info.Host, info.Port) + conn, err := session.DialTCP(ctx, "tcp", addr, timeout) + if err != nil { + return &ScanResult{Success: false, Service: "dns"} + } + defer conn.Close() + + queryID := randomUint16() + query := buildDNSRootNSQuery(queryID) + frame := make([]byte, 2, len(query)+2) + binary.BigEndian.PutUint16(frame, uint16(len(query))) + frame = append(frame, query...) + + _ = conn.SetDeadline(time.Now().Add(timeout)) + if _, err := conn.Write(frame); err != nil { + return &ScanResult{Success: false, Service: "dns"} + } + + var lenBuf [2]byte + if _, err := io.ReadFull(conn, lenBuf[:]); err != nil { + return &ScanResult{Success: false, Service: "dns"} + } + respLen := int(binary.BigEndian.Uint16(lenBuf[:])) + if respLen < 12 || respLen > 4096 { + return &ScanResult{Success: false, Service: "dns"} + } + + resp := make([]byte, respLen) + if _, err := io.ReadFull(conn, resp); err != nil { + return &ScanResult{Success: false, Service: "dns"} + } + + banner, ok := parseDNSResponse(resp, queryID) + if !ok { + return &ScanResult{Success: false, Service: "dns"} + } + + return &ScanResult{ + Success: true, + Type: plugins.ResultTypeService, + Service: "dns", + Banner: banner, + } +} + +func init() { + RegisterPluginWithPorts("dnstcp", func() Plugin { + return NewDNSTCPPlugin() + }, []int{53}) +} diff --git a/plugins/services/dnstcp_test.go b/plugins/services/dnstcp_test.go new file mode 100644 index 0000000..e23395a --- /dev/null +++ b/plugins/services/dnstcp_test.go @@ -0,0 +1,19 @@ +//go:build plugin_dnstcp || !plugin_selective + +package services + +import ( + "encoding/binary" + "testing" +) + +func TestDNSTCPFrame(t *testing.T) { + query := buildDNSRootNSQuery(0x4321) + frame := make([]byte, 2, len(query)+2) + binary.BigEndian.PutUint16(frame, uint16(len(query))) + frame = append(frame, query...) + + if binary.BigEndian.Uint16(frame[:2]) != uint16(len(query)) { + t.Fatalf("unexpected dns tcp length prefix: %#v", frame[:2]) + } +} diff --git a/plugins/services/modbus.go b/plugins/services/modbus.go new file mode 100644 index 0000000..fada001 --- /dev/null +++ b/plugins/services/modbus.go @@ -0,0 +1,104 @@ +//go:build plugin_modbus || !plugin_selective + +package services + +import ( + "context" + "encoding/binary" + "fmt" + "io" + "time" + + "github.com/shadow1ng/fscan/common" + "github.com/shadow1ng/fscan/plugins" +) + +type ModbusPlugin struct { + plugins.BasePlugin +} + +func NewModbusPlugin() *ModbusPlugin { + return &ModbusPlugin{BasePlugin: plugins.NewBasePlugin("modbus")} +} + +func (p *ModbusPlugin) Scan(ctx context.Context, info *common.HostInfo, session *common.ScanSession) *ScanResult { + timeout := session.Config.Timeout + if timeout <= 0 { + timeout = 3 * time.Second + } + + addr := fmt.Sprintf("%s:%d", info.Host, info.Port) + conn, err := session.DialTCP(ctx, "tcp", addr, timeout) + if err != nil { + return &ScanResult{Success: false, Service: "modbus"} + } + defer conn.Close() + + txID := randomUint16() + req := buildModbusDeviceIDRequest(txID) + _ = conn.SetDeadline(time.Now().Add(timeout)) + if _, err := conn.Write(req); err != nil { + return &ScanResult{Success: false, Service: "modbus"} + } + + header := make([]byte, 7) + if _, err := io.ReadFull(conn, header); err != nil { + return &ScanResult{Success: false, Service: "modbus"} + } + length := int(binary.BigEndian.Uint16(header[4:6])) + if length < 2 || length > 260 { + return &ScanResult{Success: false, Service: "modbus"} + } + body := make([]byte, length-1) + if _, err := io.ReadFull(conn, body); err != nil { + return &ScanResult{Success: false, Service: "modbus"} + } + + banner, ok := parseModbusResponse(header, body, txID) + if !ok { + return &ScanResult{Success: false, Service: "modbus"} + } + + return &ScanResult{ + Success: true, + Type: plugins.ResultTypeService, + Service: "modbus", + Banner: banner, + } +} + +func buildModbusDeviceIDRequest(txID uint16) []byte { + req := make([]byte, 11) + binary.BigEndian.PutUint16(req[0:2], txID) + binary.BigEndian.PutUint16(req[2:4], 0) + binary.BigEndian.PutUint16(req[4:6], 5) + req[6] = 0xff + req[7] = 0x2b + req[8] = 0x0e + req[9] = 0x01 + req[10] = 0x00 + return req +} + +func parseModbusResponse(header, body []byte, txID uint16) (string, bool) { + if len(header) < 7 || len(body) < 1 { + return "", false + } + if binary.BigEndian.Uint16(header[0:2]) != txID || binary.BigEndian.Uint16(header[2:4]) != 0 { + return "", false + } + switch body[0] { + case 0x2b: + return "Modbus TCP device identification response", true + case 0xab: + return "Modbus TCP exception response", true + default: + return "", false + } +} + +func init() { + RegisterPluginWithPorts("modbus", func() Plugin { + return NewModbusPlugin() + }, []int{502}) +} diff --git a/plugins/services/modbus_test.go b/plugins/services/modbus_test.go new file mode 100644 index 0000000..36cec50 --- /dev/null +++ b/plugins/services/modbus_test.go @@ -0,0 +1,22 @@ +//go:build plugin_modbus || !plugin_selective + +package services + +import ( + "encoding/binary" + "strings" + "testing" +) + +func TestModbusDeviceIDRequestAndResponse(t *testing.T) { + req := buildModbusDeviceIDRequest(0x1001) + if len(req) != 11 || binary.BigEndian.Uint16(req[0:2]) != 0x1001 || req[7] != 0x2b { + t.Fatalf("unexpected modbus request: %#v", req) + } + + header := []byte{0x10, 0x01, 0x00, 0x00, 0x00, 0x03, 0xff} + banner, ok := parseModbusResponse(header, []byte{0x2b, 0x0e}, 0x1001) + if !ok || !strings.Contains(banner, "Modbus TCP") { + t.Fatalf("unexpected modbus banner: %q ok=%v", banner, ok) + } +} diff --git a/plugins/services/mqtt.go b/plugins/services/mqtt.go new file mode 100644 index 0000000..137b8c1 --- /dev/null +++ b/plugins/services/mqtt.go @@ -0,0 +1,114 @@ +//go:build plugin_mqtt || !plugin_selective + +package services + +import ( + "context" + "crypto/tls" + "fmt" + "io" + "net" + "time" + + "github.com/shadow1ng/fscan/common" + "github.com/shadow1ng/fscan/plugins" +) + +var mqttConnectPacket = []byte{ + 0x10, 0x0c, + 0x00, 0x04, 'M', 'Q', 'T', 'T', + 0x04, + 0x02, + 0x00, 0x00, + 0x00, 0x00, +} + +type MQTTPlugin struct { + plugins.BasePlugin +} + +func NewMQTTPlugin() *MQTTPlugin { + return &MQTTPlugin{BasePlugin: plugins.NewBasePlugin("mqtt")} +} + +func (p *MQTTPlugin) Scan(ctx context.Context, info *common.HostInfo, session *common.ScanSession) *ScanResult { + timeout := session.Config.Timeout + if timeout <= 0 { + timeout = 3 * time.Second + } + + addr := fmt.Sprintf("%s:%d", info.Host, info.Port) + conn, err := session.DialTCP(ctx, "tcp", addr, timeout) + if err != nil { + return &ScanResult{Success: false, Service: "mqtt"} + } + defer conn.Close() + + if info.Port == 8883 { + _ = conn.SetDeadline(time.Now().Add(timeout)) + conn, err = p.wrapTLS(ctx, conn) + if err != nil { + return &ScanResult{Success: false, Service: "mqtt"} + } + defer conn.Close() + } + + _ = conn.SetDeadline(time.Now().Add(timeout)) + if _, err := conn.Write(mqttConnectPacket); err != nil { + return &ScanResult{Success: false, Service: "mqtt"} + } + + header := make([]byte, 4) + if _, err := io.ReadFull(conn, header); err != nil { + return &ScanResult{Success: false, Service: "mqtt"} + } + + banner, ok := parseMQTTConnack(header) + if !ok { + return &ScanResult{Success: false, Service: "mqtt"} + } + + return &ScanResult{ + Success: true, + Type: plugins.ResultTypeService, + Service: "mqtt", + Banner: banner, + } +} + +func (p *MQTTPlugin) wrapTLS(ctx context.Context, conn net.Conn) (net.Conn, error) { + tlsConn := tls.Client(conn, &tls.Config{InsecureSkipVerify: true}) + if err := tlsConn.HandshakeContext(ctx); err != nil { + return nil, err + } + return tlsConn, nil +} + +func parseMQTTConnack(data []byte) (string, bool) { + if len(data) < 4 || data[0] != 0x20 || data[1] != 0x02 { + return "", false + } + + switch data[3] { + case 0x00: + return "MQTT CONNACK accepted", true + case 0x01: + return "MQTT CONNACK unacceptable protocol version", true + case 0x02: + return "MQTT CONNACK identifier rejected", true + case 0x03: + return "MQTT CONNACK server unavailable", true + case 0x04: + return "MQTT CONNACK bad username or password", true + case 0x05: + return "MQTT CONNACK not authorized", true + default: + return fmt.Sprintf("MQTT CONNACK return_code=%d", data[3]), true + } +} + +func init() { + RegisterPluginWithPorts("mqtt", func() Plugin { + return NewMQTTPlugin() + }, []int{1883, 8883}) +} diff --git a/plugins/services/mqtt_test.go b/plugins/services/mqtt_test.go new file mode 100644 index 0000000..e6fc3e8 --- /dev/null +++ b/plugins/services/mqtt_test.go @@ -0,0 +1,19 @@ +//go:build plugin_mqtt || !plugin_selective + +package services + +import ( + "strings" + "testing" +) + +func TestParseMQTTConnack(t *testing.T) { + banner, ok := parseMQTTConnack([]byte{0x20, 0x02, 0x00, 0x05}) + if !ok || !strings.Contains(banner, "not authorized") { + t.Fatalf("unexpected mqtt banner: %q ok=%v", banner, ok) + } + + if _, ok := parseMQTTConnack([]byte{0x10, 0x02, 0x00, 0x00}); ok { + t.Fatal("unexpected match for non-connack packet") + } +} diff --git a/plugins/services/tftp.go b/plugins/services/tftp.go new file mode 100644 index 0000000..1ddc79e --- /dev/null +++ b/plugins/services/tftp.go @@ -0,0 +1,95 @@ +//go:build plugin_tftp || !plugin_selective + +package services + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/shadow1ng/fscan/common" + "github.com/shadow1ng/fscan/plugins" +) + +type TFTPPlugin struct { + plugins.BasePlugin +} + +func NewTFTPPlugin() *TFTPPlugin { + return &TFTPPlugin{BasePlugin: plugins.NewBasePlugin("tftp")} +} + +func (p *TFTPPlugin) Scan(ctx context.Context, info *common.HostInfo, session *common.ScanSession) *ScanResult { + timeout := session.Config.Timeout + if timeout <= 0 { + timeout = 3 * time.Second + } + + target := fmt.Sprintf("%s:%d", info.Host, info.Port) + conn, err := session.DialUDP(ctx, target, timeout) + if err != nil { + return &ScanResult{Success: false, Service: "tftp"} + } + defer conn.Close() + + if _, err := conn.Write(buildTFTPReadRequest("probe")); err != nil { + 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]) + if !ok { + return &ScanResult{Success: false, Service: "tftp"} + } + + return &ScanResult{ + Success: true, + Type: plugins.ResultTypeService, + Service: "tftp", + Banner: banner, + } +} + +func buildTFTPReadRequest(filename string) []byte { + req := []byte{0x00, 0x01} + req = append(req, filename...) + req = append(req, 0x00) + req = append(req, "octet"...) + req = append(req, 0x00) + return req +} + +func parseTFTPResponse(data []byte) (string, bool) { + if len(data) < 4 || data[0] != 0x00 { + return "", false + } + + opcode := data[1] + switch opcode { + case 0x03: + return "TFTP DATA response", true + case 0x05: + msg := strings.TrimRight(string(data[4:]), "\x00") + if len(msg) > 160 { + msg = msg[:160] + } + if msg == "" { + msg = "error response" + } + return fmt.Sprintf("TFTP %s", msg), true + default: + return "", false + } +} + +func init() { + RegisterUDPPluginWithPorts("tftp", func() Plugin { + return NewTFTPPlugin() + }, []int{69}) +} diff --git a/plugins/services/tftp_test.go b/plugins/services/tftp_test.go new file mode 100644 index 0000000..f50faa5 --- /dev/null +++ b/plugins/services/tftp_test.go @@ -0,0 +1,21 @@ +//go:build plugin_tftp || !plugin_selective + +package services + +import ( + "strings" + "testing" +) + +func TestTFTPReadRequestAndResponse(t *testing.T) { + req := buildTFTPReadRequest("probe") + want := []byte{0x00, 0x01, 'p', 'r', 'o', 'b', 'e', 0x00, 'o', 'c', 't', 'e', 't', 0x00} + if string(req) != string(want) { + t.Fatalf("unexpected tftp request: %#v", req) + } + + banner, ok := parseTFTPResponse([]byte{0x00, 0x05, 0x00, 0x01, 'n', 'o', 't', ' ', 'f', 'o', 'u', 'n', 'd', 0x00}) + if !ok || !strings.Contains(banner, "not found") { + t.Fatalf("unexpected tftp banner: %q ok=%v", banner, ok) + } +} diff --git a/plugins/services/zookeeper.go b/plugins/services/zookeeper.go new file mode 100644 index 0000000..54000e5 --- /dev/null +++ b/plugins/services/zookeeper.go @@ -0,0 +1,80 @@ +//go:build plugin_zookeeper || !plugin_selective + +package services + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/shadow1ng/fscan/common" + "github.com/shadow1ng/fscan/plugins" +) + +type ZooKeeperPlugin struct { + plugins.BasePlugin +} + +func NewZooKeeperPlugin() *ZooKeeperPlugin { + return &ZooKeeperPlugin{BasePlugin: plugins.NewBasePlugin("zookeeper")} +} + +func (p *ZooKeeperPlugin) Scan(ctx context.Context, info *common.HostInfo, session *common.ScanSession) *ScanResult { + timeout := session.Config.Timeout + if timeout <= 0 { + timeout = 3 * time.Second + } + + addr := fmt.Sprintf("%s:%d", info.Host, info.Port) + conn, err := session.DialTCP(ctx, "tcp", addr, timeout) + if err != nil { + return &ScanResult{Success: false, Service: "zookeeper"} + } + defer conn.Close() + + _ = conn.SetDeadline(time.Now().Add(timeout)) + if _, err := conn.Write([]byte("ruok")); err != nil { + return &ScanResult{Success: false, Service: "zookeeper"} + } + + buf := make([]byte, 512) + n, err := conn.Read(buf) + if err != nil || n == 0 { + return &ScanResult{Success: false, Service: "zookeeper"} + } + + banner, ok := parseZooKeeperResponse(buf[:n]) + if !ok { + return &ScanResult{Success: false, Service: "zookeeper"} + } + + return &ScanResult{ + Success: true, + Type: plugins.ResultTypeService, + Service: "zookeeper", + Banner: banner, + } +} + +func parseZooKeeperResponse(data []byte) (string, bool) { + resp := strings.TrimSpace(string(data)) + if resp == "imok" { + return "ZooKeeper ruok=imok", true + } + lower := strings.ToLower(resp) + if strings.Contains(lower, "zookeeper") || strings.Contains(lower, "zk_version") || + strings.Contains(lower, "mode:") || strings.Contains(lower, "not in the whitelist") { + if len(resp) > 200 { + resp = resp[:200] + } + return resp, true + } + return "", false +} + +func init() { + RegisterPluginWithPorts("zookeeper", func() Plugin { + return NewZooKeeperPlugin() + }, []int{2181}) +} diff --git a/plugins/services/zookeeper_test.go b/plugins/services/zookeeper_test.go new file mode 100644 index 0000000..a8cd9c2 --- /dev/null +++ b/plugins/services/zookeeper_test.go @@ -0,0 +1,16 @@ +//go:build plugin_zookeeper || !plugin_selective + +package services + +import "testing" + +func TestParseZooKeeperResponse(t *testing.T) { + banner, ok := parseZooKeeperResponse([]byte("imok")) + if !ok || banner != "ZooKeeper ruok=imok" { + t.Fatalf("unexpected zookeeper banner: %q ok=%v", banner, ok) + } + + if _, ok := parseZooKeeperResponse([]byte("hello")); ok { + t.Fatal("unexpected match for non-zookeeper response") + } +}