From 0a28db73713575ff909bc6913436c928f3965819 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Tue, 19 May 2026 00:01:58 +0800 Subject: [PATCH] feat: add NFS, RMI and IPMI plugins NFS (2049/TCP): Sun RPC EXPORT call, lists shared directories RMI (1099/TCP): Java RMI handshake, detects exposed JMX/RMI endpoints IPMI (623/UDP): RMCP ping + channel auth capabilities probe All pure stdlib, zero new dependencies. --- pkg/fscan/scanner.go | 3 + plugins/services/ipmi.go | 165 +++++++++++++++++++++++++++++++++ plugins/services/nfs.go | 191 +++++++++++++++++++++++++++++++++++++++ plugins/services/rmi.go | 87 ++++++++++++++++++ 4 files changed, 446 insertions(+) create mode 100644 plugins/services/ipmi.go create mode 100644 plugins/services/nfs.go create mode 100644 plugins/services/rmi.go diff --git a/pkg/fscan/scanner.go b/pkg/fscan/scanner.go index 8e144e9..9a397b7 100644 --- a/pkg/fscan/scanner.go +++ b/pkg/fscan/scanner.go @@ -40,8 +40,11 @@ var defaultSafePlugins = []string{ "rdp", "redis", "imap", + "ipmi", "jdwp", + "nfs", "pop3", + "rmi", "rsync", "smb", "smtp", diff --git a/plugins/services/ipmi.go b/plugins/services/ipmi.go new file mode 100644 index 0000000..f80b92d --- /dev/null +++ b/plugins/services/ipmi.go @@ -0,0 +1,165 @@ +//go:build plugin_ipmi || !plugin_selective + +package services + +import ( + "context" + "encoding/binary" + "fmt" + "time" + + "github.com/shadow1ng/fscan/common" + "github.com/shadow1ng/fscan/plugins" +) + +type IPMIPlugin struct { + plugins.BasePlugin +} + +func NewIPMIPlugin() *IPMIPlugin { + return &IPMIPlugin{BasePlugin: plugins.NewBasePlugin("ipmi")} +} + +func (p *IPMIPlugin) 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) + + if result := p.rmcpPing(ctx, target, timeout, session); result != nil { + return result + } + return &ScanResult{Success: false, Service: "ipmi"} +} + +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 + } + + if _, err := conn.Write(ping); err != nil { + 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 + } + + banner := "IPMI/RMCP service detected" + if n >= 16 { + banner = fmt.Sprintf("IPMI/RMCP detected (supported entities: 0x%02x)", buf[15]) + if buf[15]&0x80 != 0 { + banner += " [IPMI supported]" + } + } + + // Try to get channel auth capabilities for more info + if authInfo := p.getChannelAuth(conn); authInfo != "" { + banner += " " + authInfo + } + + return &ScanResult{ + Success: true, + Type: plugins.ResultTypeVuln, + Service: "ipmi", + VulInfo: "IPMI Service Exposed (hash dump possible with rakp)", + Banner: banner, + } +} + +func (p *IPMIPlugin) getChannelAuth(conn interface { + Read([]byte) (int, error) + Write([]byte) (int, error) + SetDeadline(time.Time) error +}) string { + _ = conn.SetDeadline(time.Now().Add(2 * time.Second)) + + // IPMI Get Channel Authentication Capabilities + // RMCP header + IPMI session wrapper + message + pkt := []byte{ + 0x06, 0x00, 0xff, 0x07, // RMCP: version, reserved, seq=0xff, class=IPMI + 0x00, 0x00, 0x00, 0x00, // auth type = none + 0x00, 0x00, 0x00, 0x00, // session seq + 0x00, 0x00, 0x00, 0x00, // session id + 0x09, // message length + 0x20, // target = BMC + 0x18, // netFn=App(6) << 2 | lun=0 + 0xc8, // checksum + 0x81, // source + 0x00, // seq + 0x38, // cmd = Get Channel Auth Capabilities + 0x8e, // channel=14 (current), IPMI v2.0 + 0x04, // privilege = Administrator + 0xb5, // checksum + } + + if _, err := conn.Write(pkt); err != nil { + return "" + } + + buf := make([]byte, 512) + n, err := conn.Read(buf) + if err != nil || n < 30 { + return "" + } + + // Parse auth capabilities from response + if n >= 27 { + authTypes := buf[22] + var methods []string + if authTypes&0x01 != 0 { + methods = append(methods, "none") + } + if authTypes&0x02 != 0 { + methods = append(methods, "md2") + } + if authTypes&0x04 != 0 { + methods = append(methods, "md5") + } + if authTypes&0x10 != 0 { + methods = append(methods, "password") + } + if authTypes&0x20 != 0 { + methods = append(methods, "oem") + } + if len(methods) > 0 { + return fmt.Sprintf("[auth: %v]", methods) + } + } + return "" +} + +func init() { + RegisterUDPPluginWithPorts("ipmi", func() Plugin { + return NewIPMIPlugin() + }, []int{623}) + _ = binary.BigEndian // suppress unused import if needed +} diff --git a/plugins/services/nfs.go b/plugins/services/nfs.go new file mode 100644 index 0000000..e55928e --- /dev/null +++ b/plugins/services/nfs.go @@ -0,0 +1,191 @@ +//go:build plugin_nfs || !plugin_selective + +package services + +import ( + "context" + "encoding/binary" + "fmt" + "time" + + "github.com/shadow1ng/fscan/common" + "github.com/shadow1ng/fscan/plugins" +) + +type NFSPlugin struct { + plugins.BasePlugin +} + +func NewNFSPlugin() *NFSPlugin { + return &NFSPlugin{BasePlugin: plugins.NewBasePlugin("nfs")} +} + +func (p *NFSPlugin) 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: "nfs"} + } + defer conn.Close() + _ = conn.SetDeadline(time.Now().Add(timeout)) + + exports, err := p.getExports(conn) + if err != nil { + return &ScanResult{Success: false, Service: "nfs"} + } + + if len(exports) == 0 { + return &ScanResult{ + Success: true, + Type: plugins.ResultTypeService, + Service: "nfs", + Banner: "NFS service detected (no exports)", + } + } + + banner := fmt.Sprintf("NFS exports: %v", exports) + return &ScanResult{ + Success: true, + Type: plugins.ResultTypeVuln, + Service: "nfs", + VulInfo: fmt.Sprintf("NFS Exported Shares: %v", exports), + Banner: banner, + } +} + +func (p *NFSPlugin) getExports(conn interface { + Read([]byte) (int, error) + Write([]byte) (int, error) +}) ([]string, error) { + // Sun RPC call: program=MOUNT(100005), version=3, procedure=EXPORT(5) + xid := uint32(0x12345678) + rpcCall := p.buildRPCCall(xid, 100005, 3, 5, nil) + rpcFragment := p.wrapRPCFragment(rpcCall) + + if _, err := conn.Write(rpcFragment); err != nil { + return nil, err + } + + // Read fragment header (4 bytes) + response + buf := make([]byte, 4096) + n, err := conn.Read(buf) + if err != nil || n < 28 { + return nil, fmt.Errorf("short response: %d bytes", n) + } + + // Skip fragment header (4 bytes), parse RPC reply + reply := buf[4:n] + if len(reply) < 24 { + return nil, fmt.Errorf("invalid reply") + } + + replyXID := binary.BigEndian.Uint32(reply[0:4]) + if replyXID != xid { + return nil, fmt.Errorf("xid mismatch") + } + msgType := binary.BigEndian.Uint32(reply[4:8]) + if msgType != 1 { // REPLY + return nil, fmt.Errorf("not a reply") + } + replyStatus := binary.BigEndian.Uint32(reply[8:12]) + if replyStatus != 0 { // MSG_ACCEPTED + return nil, fmt.Errorf("reply rejected") + } + + // Skip auth verifier + offset := 12 + if offset+8 > len(reply) { + return nil, fmt.Errorf("truncated") + } + // verifier flavor + length + verifierLen := binary.BigEndian.Uint32(reply[offset+4 : offset+8]) + offset += 8 + int(verifierLen) + + // Accept status + if offset+4 > len(reply) { + return nil, fmt.Errorf("truncated") + } + acceptStatus := binary.BigEndian.Uint32(reply[offset : offset+4]) + if acceptStatus != 0 { // SUCCESS + return nil, fmt.Errorf("accept status: %d", acceptStatus) + } + offset += 4 + + return p.parseExportList(reply[offset:]), nil +} + +func (p *NFSPlugin) parseExportList(data []byte) []string { + var exports []string + offset := 0 + for offset+4 <= len(data) { + valueFollows := binary.BigEndian.Uint32(data[offset : offset+4]) + offset += 4 + if valueFollows == 0 { + break + } + if offset+4 > len(data) { + break + } + strLen := binary.BigEndian.Uint32(data[offset : offset+4]) + offset += 4 + if int(strLen) > len(data)-offset { + break + } + exports = append(exports, string(data[offset:offset+int(strLen)])) + offset += int(strLen) + // Align to 4 bytes + if pad := (4 - strLen%4) % 4; pad > 0 { + offset += int(pad) + } + // Skip group list + for offset+4 <= len(data) { + groupFollows := binary.BigEndian.Uint32(data[offset : offset+4]) + offset += 4 + if groupFollows == 0 { + break + } + if offset+4 > len(data) { + break + } + groupLen := binary.BigEndian.Uint32(data[offset : offset+4]) + offset += 4 + int(groupLen) + if pad := (4 - groupLen%4) % 4; pad > 0 { + offset += int(pad) + } + } + } + return exports +} + +func (p *NFSPlugin) buildRPCCall(xid, program, version, procedure uint32, data []byte) []byte { + authNone := []byte{0, 0, 0, 0, 0, 0, 0, 0} // AUTH_NONE flavor=0, len=0 + + buf := make([]byte, 0, 40+len(data)) + buf = binary.BigEndian.AppendUint32(buf, xid) + buf = binary.BigEndian.AppendUint32(buf, 0) // CALL + buf = binary.BigEndian.AppendUint32(buf, 2) // RPC version + buf = binary.BigEndian.AppendUint32(buf, program) + buf = binary.BigEndian.AppendUint32(buf, version) + buf = binary.BigEndian.AppendUint32(buf, procedure) + buf = append(buf, authNone...) // credentials + buf = append(buf, authNone...) // verifier + buf = append(buf, data...) + return buf +} + +func (p *NFSPlugin) wrapRPCFragment(data []byte) []byte { + header := make([]byte, 4) + binary.BigEndian.PutUint32(header, uint32(len(data))|0x80000000) // last fragment + return append(header, data...) +} + +func init() { + RegisterPluginWithPorts("nfs", func() Plugin { + return NewNFSPlugin() + }, []int{2049}) +} diff --git a/plugins/services/rmi.go b/plugins/services/rmi.go new file mode 100644 index 0000000..b7110ba --- /dev/null +++ b/plugins/services/rmi.go @@ -0,0 +1,87 @@ +//go:build plugin_rmi || !plugin_selective + +package services + +import ( + "context" + "fmt" + "time" + + "github.com/shadow1ng/fscan/common" + "github.com/shadow1ng/fscan/plugins" +) + +// Java RMI protocol magic: "JRMI" + version 2 + StreamProtocol +var rmiHandshake = []byte{0x4a, 0x52, 0x4d, 0x49, 0x00, 0x02, 0x4b} + +type RMIPlugin struct { + plugins.BasePlugin +} + +func NewRMIPlugin() *RMIPlugin { + return &RMIPlugin{BasePlugin: plugins.NewBasePlugin("rmi")} +} + +func (p *RMIPlugin) 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: "rmi"} + } + defer conn.Close() + _ = conn.SetDeadline(time.Now().Add(timeout)) + + if _, err := conn.Write(rmiHandshake); err != nil { + return &ScanResult{Success: false, Service: "rmi"} + } + + buf := make([]byte, 256) + n, err := conn.Read(buf) + if err != nil || n < 5 { + return &ScanResult{Success: false, Service: "rmi"} + } + + // RMI server responds with 0x4e (ProtocolAck) followed by endpoint info + if buf[0] != 0x4e { + return &ScanResult{Success: false, Service: "rmi"} + } + + endpoint := parseRMIEndpoint(buf[1:n]) + + return &ScanResult{ + Success: true, + Type: plugins.ResultTypeVuln, + Service: "rmi", + VulInfo: "Java RMI/JMX Service Exposed", + Banner: endpoint, + } +} + +func parseRMIEndpoint(data []byte) string { + if len(data) < 4 { + return "Java RMI" + } + // Skip 2 bytes (host length big-endian) + hostLen := int(data[0])<<8 | int(data[1]) + if hostLen <= 0 || hostLen+2 > len(data) { + return "Java RMI" + } + host := string(data[2 : 2+hostLen]) + offset := 2 + hostLen + if offset+4 > len(data) { + return fmt.Sprintf("Java RMI endpoint=%s", host) + } + port := int(data[offset])<<24 | int(data[offset+1])<<16 | int(data[offset+2])<<8 | int(data[offset+3]) + return fmt.Sprintf("Java RMI endpoint=%s:%d", host, port) +} + +func init() { + RegisterPluginWithPorts("rmi", func() Plugin { + return NewRMIPlugin() + }, []int{1099, 1098, 9999, 4444}) +}