From 526a1cf943a04c276baedc93bdc33af4c315db4e Mon Sep 17 00:00:00 2001 From: Ananya9588 Date: Mon, 3 Aug 2026 00:33:13 +0530 Subject: [PATCH 1/2] Add codesys3, crimson, pcworx, and proconos OT protocol modules Adds passive fingerprinting scanners for four industrial protocols: CODESYS V3 NameService, Red Lion Crimson CR3, Phoenix Contact PC WorX, and Phoenix Contact/KW-Software ProConOS. Each includes unit tests against a fake local TCP server and a zschema output schema. --- bin/default_modules.go | 8 + modules/codesysv3.go | 10 + modules/codesysv3/codesysv3.go | 320 ++++++++++++++++++++++++++++ modules/codesysv3/codesysv3_test.go | 160 ++++++++++++++ modules/codesysv3/scanner.go | 114 ++++++++++ modules/crimson.go | 10 + modules/crimson/crimson.go | 87 ++++++++ modules/crimson/crimson_test.go | 146 +++++++++++++ modules/crimson/scanner.go | 75 +++++++ modules/pcworx.go | 10 + modules/pcworx/pcworx.go | 102 +++++++++ modules/pcworx/pcworx_test.go | 191 +++++++++++++++++ modules/pcworx/scanner.go | 86 ++++++++ modules/proconos.go | 10 + modules/proconos/proconos.go | 140 ++++++++++++ modules/proconos/proconos_test.go | 166 +++++++++++++++ modules/proconos/scanner.go | 75 +++++++ zgrab2_schemas/zgrab2/__init__.py | 4 + zgrab2_schemas/zgrab2/codesys3.py | 34 +++ zgrab2_schemas/zgrab2/crimson.py | 23 ++ zgrab2_schemas/zgrab2/pcworx.py | 26 +++ zgrab2_schemas/zgrab2/proconos.py | 26 +++ 22 files changed, 1823 insertions(+) create mode 100644 modules/codesysv3.go create mode 100644 modules/codesysv3/codesysv3.go create mode 100644 modules/codesysv3/codesysv3_test.go create mode 100644 modules/codesysv3/scanner.go create mode 100644 modules/crimson.go create mode 100644 modules/crimson/crimson.go create mode 100644 modules/crimson/crimson_test.go create mode 100644 modules/crimson/scanner.go create mode 100644 modules/pcworx.go create mode 100644 modules/pcworx/pcworx.go create mode 100644 modules/pcworx/pcworx_test.go create mode 100644 modules/pcworx/scanner.go create mode 100644 modules/proconos.go create mode 100644 modules/proconos/proconos.go create mode 100644 modules/proconos/proconos_test.go create mode 100644 modules/proconos/scanner.go create mode 100644 zgrab2_schemas/zgrab2/codesys3.py create mode 100644 zgrab2_schemas/zgrab2/crimson.py create mode 100644 zgrab2_schemas/zgrab2/pcworx.py create mode 100644 zgrab2_schemas/zgrab2/proconos.py diff --git a/bin/default_modules.go b/bin/default_modules.go index 71d1be98..126af34f 100644 --- a/bin/default_modules.go +++ b/bin/default_modules.go @@ -5,6 +5,8 @@ import ( "github.com/zmap/zgrab2/modules" "github.com/zmap/zgrab2/modules/bacnet" "github.com/zmap/zgrab2/modules/banner" + "github.com/zmap/zgrab2/modules/codesysv3" + "github.com/zmap/zgrab2/modules/crimson" "github.com/zmap/zgrab2/modules/dnp3" "github.com/zmap/zgrab2/modules/fox" "github.com/zmap/zgrab2/modules/ftp" @@ -18,8 +20,10 @@ import ( "github.com/zmap/zgrab2/modules/mysql" "github.com/zmap/zgrab2/modules/ntp" "github.com/zmap/zgrab2/modules/oracle" + "github.com/zmap/zgrab2/modules/pcworx" "github.com/zmap/zgrab2/modules/pop3" "github.com/zmap/zgrab2/modules/postgres" + "github.com/zmap/zgrab2/modules/proconos" "github.com/zmap/zgrab2/modules/redis" "github.com/zmap/zgrab2/modules/siemens" "github.com/zmap/zgrab2/modules/smb" @@ -33,6 +37,8 @@ func init() { defaultModules = map[string]zgrab2.Module{ "bacnet": bacnet.NewModule(), "banner": banner.NewModule(), + "codesys3": codesysv3.NewModule(), + "crimson": crimson.NewModule(), "dnp3": dnp3.NewModule(), "fox": fox.NewModule(), "ftp": ftp.NewModule(), @@ -46,8 +52,10 @@ func init() { "mysql": mysql.NewModule(), "ntp": ntp.NewModule(), "oracle": oracle.NewModule(), + "pcworx": pcworx.NewModule(), "pop3": pop3.NewModule(), "postgres": postgres.NewModule(), + "proconos": proconos.NewModule(), "redis": redis.NewModule(), "siemens": siemens.NewModule(), "smb": smb.NewModule(), diff --git a/modules/codesysv3.go b/modules/codesysv3.go new file mode 100644 index 00000000..5e6d9989 --- /dev/null +++ b/modules/codesysv3.go @@ -0,0 +1,10 @@ +package modules + +import ( + "github.com/zmap/zgrab2" + "github.com/zmap/zgrab2/modules/codesysv3" +) + +func init() { + zgrab2.RegisterModule(codesysv3.NewModule()) +} diff --git a/modules/codesysv3/codesysv3.go b/modules/codesysv3/codesysv3.go new file mode 100644 index 00000000..2afb3014 --- /dev/null +++ b/modules/codesysv3/codesysv3.go @@ -0,0 +1,320 @@ +package codesysv3 + +import ( + "bytes" + "encoding/binary" + "errors" + "fmt" + "net" + "strings" + "unicode/utf16" +) + +// CODESYS V3 runtimes expose a "NameService" that answers a ResolveAddr +// request with an Identification packet describing the device (vendor, +// device/node name, target type/id/version, serial number, ...). This is +// the same mechanism used by Rapid7's codesys3.lua and nmap's +// codesys-plc-info script. +// +// Two wire transports carry the same NameService datagram: +// - TCP: the datagram is wrapped in an 8-byte CmpBlkDrvTcp header +// (magic uint32 LE, then total length uint32 LE) and addressed +// absolutely (sender/receiver are full IP:port pairs). Usually TCP/11740-11743. +// - UDP: the datagram is sent unwrapped, addressed relative to the +// local subnet (a masked IP octet + a 2-bit port index folded into a +// single sender word). Usually UDP/1740-1743. +const ( + nsMagic byte = 0xC5 + nsRequest byte = 0x03 + nsResponse byte = 0x04 + pkgResolveAddr uint16 = 0xC202 + pkgIdentification uint16 = 0xC280 + nodeInfoVersion uint16 = 0x0400 + tcpBDMagic uint32 = 0xe8170100 +) + +var ( + ErrNoMagic = errors.New("no CODESYS V3 NameService datagram magic found") + ErrNotResponse = errors.New("not a CODESYS V3 NameService response") + ErrUnexpectedPkg = errors.New("unexpected CODESYS V3 package type") + ErrUnsupportedVer = errors.New("unsupported CODESYS V3 NodeInfo version") + ErrPayloadTruncated = errors.New("CODESYS V3 NameService payload truncated") + ErrBodyTruncated = errors.New("CODESYS V3 identification body truncated") + ErrRequiresIPv4 = errors.New("CODESYS V3 addressing requires an IPv4 address") +) + +// DeviceInfo is the JSON-serializable result of a CODESYS V3 scan. +type DeviceInfo struct { + VendorName string `json:"vendor_name,omitempty"` + DeviceName string `json:"device_name,omitempty"` + NodeName string `json:"node_name,omitempty"` + SerialNumber string `json:"serial_number,omitempty"` + TargetType uint32 `json:"target_type"` + TargetID uint32 `json:"target_id"` + TargetVersion uint32 `json:"target_version"` + TargetVersionStr string `json:"target_version_str,omitempty"` + Flags uint32 `json:"flags"` + MaxChannels uint16 `json:"max_channels"` + IntelByteOrder bool `json:"intel_byte_order"` + BlkDrvType uint8 `json:"blk_drv_type"` + RequestID uint32 `json:"request_id"` +} + +func versionString(v uint32) string { + return fmt.Sprintf("%d.%d.%d.%d", (v>>24)&0xff, (v>>16)&0xff, (v>>8)&0xff, v&0xff) +} + +// utf16leString decodes a UTF-16LE byte string, trimming the trailing NUL +// terminator(s) CODESYS pads these fields with. +func utf16leString(data []byte) string { + if len(data)%2 != 0 { + data = data[:len(data)-1] + } + units := make([]uint16, len(data)/2) + for i := range units { + units[i] = binary.LittleEndian.Uint16(data[i*2 : i*2+2]) + } + return strings.TrimSpace(strings.TrimRight(string(utf16.Decode(units)), "\x00")) +} + +// tcpAddrBytes encodes a port + IPv4 address the way CmpBlkDrvTcp addressing expects: 2 bytes big-endian port, then 4 raw IPv4 octets. +func tcpAddrBytes(ip net.IP, port uint16) ([]byte, error) { + v4 := ip.To4() + if v4 == nil { + return nil, ErrRequiresIPv4 + } + buf := make([]byte, 6) + binary.BigEndian.PutUint16(buf[0:2], port) + copy(buf[2:6], v4) + return buf, nil +} + +// BuildTCPResolveRequest builds a TCP CmpBlkDrvTcp-framed NameService +// ResolveAddr request between localAddr and remoteAddr (both must be IPv4). +func BuildTCPResolveRequest(localIP net.IP, localPort uint16, remoteIP net.IP, remotePort uint16, broadcastID uint16, requestID uint32) ([]byte, error) { + localAddrBytes, err := tcpAddrBytes(localIP, localPort) + if err != nil { + return nil, err + } + remoteAddrBytes, err := tcpAddrBytes(remoteIP, remotePort) + if err != nil { + return nil, err + } + + const hopinfo = ((0x0f & 0x1f) << 3) | (4 & 7) // header_length=4 words + const packetinfo = ((1 & 3) << 6) | (1 << 4) // absolute addressing + const addressLengths = 0x33 // 3 sender words, 3 receiver words + + datagram := make([]byte, 8, 8+len(localAddrBytes)+len(remoteAddrBytes)+8) + datagram[0] = nsMagic + datagram[1] = hopinfo + datagram[2] = packetinfo + datagram[3] = nsRequest + datagram[4] = 0x00 + datagram[5] = addressLengths + binary.BigEndian.PutUint16(datagram[6:8], broadcastID) + datagram = append(datagram, localAddrBytes...) + datagram = append(datagram, remoteAddrBytes...) + if pad := len(datagram) % 4; pad != 0 { + datagram = append(datagram, make([]byte, 4-pad)...) + } + + payload := make([]byte, 8) + binary.LittleEndian.PutUint16(payload[0:2], pkgResolveAddr) + binary.LittleEndian.PutUint16(payload[2:4], nodeInfoVersion) + binary.LittleEndian.PutUint32(payload[4:8], requestID) + datagram = append(datagram, payload...) + + total := uint32(8 + len(datagram)) + packet := make([]byte, 8, 8+len(datagram)) + binary.LittleEndian.PutUint32(packet[0:4], tcpBDMagic) + binary.LittleEndian.PutUint32(packet[4:8], total) + return append(packet, datagram...), nil +} + +// BuildUDPResolveRequest builds a UDP NameService ResolveAddr request. +// portIndex identifies which of the 4 UDP instances (1740-1743) is being +// queried and is folded into the sender address, along with the local IP +// masked to netmaskCIDR, per the wire format used by codesys3.lua. +func BuildUDPResolveRequest(localIP net.IP, portIndex int, netmaskCIDR int, broadcastID uint16, requestID uint32) ([]byte, error) { + v4 := localIP.To4() + if v4 == nil { + return nil, ErrRequiresIPv4 + } + + localBits := 32 - netmaskCIDR + if localBits < 0 { + localBits = 0 + } else if localBits > 32 { + localBits = 32 + } + const portBits = 2 + senderWords := (localBits + portBits + 15) / 16 + if senderWords > 0xF { + senderWords = 0xF + } + addressLengths := byte((senderWords & 0xF) << 4) + + myAddress := binary.BigEndian.Uint32(v4) + var mask uint32 + if localBits < 32 { + mask = (uint32(1) << uint(localBits)) - 1 + } else { + mask = 0xFFFFFFFF + } + senderAddress := (uint32(portIndex&3) << uint(localBits)) | (myAddress & mask) + + const hopinfo = ((0x0f & 0x1f) << 3) | (4 & 7) // header_length=4 words + const packetinfo = (1 & 3) << 6 // relative/broadcast addressing + + header := make([]byte, 8, 16) + header[0] = nsMagic + header[1] = hopinfo + header[2] = packetinfo + header[3] = nsRequest + header[4] = 0x00 + header[5] = addressLengths + binary.BigEndian.PutUint16(header[6:8], broadcastID) + + for i := 0; i < senderWords; i++ { + shift := uint(16 * (senderWords - 1 - i)) + word := make([]byte, 2) + binary.BigEndian.PutUint16(word, uint16((senderAddress>>shift)&0xffff)) + header = append(header, word...) + } + if pad := len(header) % 4; pad != 0 { + header = append(header, make([]byte, 4-pad)...) + } + + payload := make([]byte, 8) + binary.LittleEndian.PutUint16(payload[0:2], pkgResolveAddr) + binary.LittleEndian.PutUint16(payload[2:4], nodeInfoVersion) + binary.LittleEndian.PutUint32(payload[4:8], requestID) + return append(header, payload...), nil +} + +// ParseResponse validates and decodes a NameService Identification response +// (NodeInfo version 4.00), peeling the optional TCP block-driver framing +// first if present. +func ParseResponse(data []byte) (*DeviceInfo, error) { + if len(data) == 0 { + return nil, ErrNoMagic + } + + offset := 0 + if len(data) >= 8 && binary.LittleEndian.Uint32(data[:4]) == tcpBDMagic { + offset = 8 + } + + view := data[offset:] + c5 := bytes.IndexByte(view, nsMagic) + if c5 < 0 || c5+6 > len(view) { + return nil, ErrNoMagic + } + view = view[c5:] + + hopinfo := view[1] + serviceID := view[3] + addressLengths := view[5] + if serviceID != nsResponse { + return nil, ErrNotResponse + } + + headerLength := int(hopinfo & 7) + pos := headerLength*2 + int(addressLengths&0xF)*2 + int((addressLengths>>4)&0xF)*2 + if r := pos % 4; r != 0 { + pos += 4 - r + } + if pos+8 > len(view) { + return nil, ErrPayloadTruncated + } + + packageType := binary.LittleEndian.Uint16(view[pos : pos+2]) + version := binary.LittleEndian.Uint16(view[pos+2 : pos+4]) + requestID := binary.LittleEndian.Uint32(view[pos+4 : pos+8]) + pos += 8 + if packageType != pkgIdentification { + return nil, ErrUnexpectedPkg + } + if version != nodeInfoVersion { + return nil, ErrUnsupportedVer + } + // Structured fields before the variable-length string table (~39 bytes). + if pos+39 > len(view) { + return nil, ErrBodyTruncated + } + + maxChannels := binary.LittleEndian.Uint16(view[pos : pos+2]) + intelByteOrder := view[pos+2] + parentAddrSize := binary.LittleEndian.Uint16(view[pos+4 : pos+6]) + pos += 6 + + nodeNameLen := int(binary.LittleEndian.Uint16(view[pos : pos+2])) + deviceNameLen := int(binary.LittleEndian.Uint16(view[pos+2 : pos+4])) + vendorNameLen := int(binary.LittleEndian.Uint16(view[pos+4 : pos+6])) + pos += 6 + + targetType := binary.LittleEndian.Uint32(view[pos : pos+4]) + targetID := binary.LittleEndian.Uint32(view[pos+4 : pos+8]) + targetVersion := binary.LittleEndian.Uint32(view[pos+8 : pos+12]) + flags := binary.LittleEndian.Uint32(view[pos+12 : pos+16]) + pos += 16 + + serialLen := int(view[pos]) + oemLen := int(view[pos+1]) + blkDrvType := view[pos+2] + pos += 3 + pos += 1 + 8 // 1 pad byte + 8 reserved bytes + + take := func(n int) ([]byte, error) { + if n < 0 || pos+n > len(view) { + return nil, ErrBodyTruncated + } + b := view[pos : pos+n] + pos += n + return b, nil + } + + if _, err := take(int(parentAddrSize)); err != nil { + return nil, err + } + nodeNameBytes, err := take(nodeNameLen * 2) + if err != nil { + return nil, err + } + pos += 2 // NUL terminator + deviceNameBytes, err := take(deviceNameLen * 2) + if err != nil { + return nil, err + } + pos += 2 + vendorNameBytes, err := take(vendorNameLen * 2) + if err != nil { + return nil, err + } + pos += 2 + serialBytes, err := take(serialLen) + if err != nil { + return nil, err + } + pos += 1 + if _, err := take(oemLen); err != nil { + return nil, err + } + + return &DeviceInfo{ + VendorName: utf16leString(vendorNameBytes), + DeviceName: utf16leString(deviceNameBytes), + NodeName: utf16leString(nodeNameBytes), + SerialNumber: strings.TrimRight(string(serialBytes), "\x00"), + TargetType: targetType, + TargetID: targetID, + TargetVersion: targetVersion, + TargetVersionStr: versionString(targetVersion), + Flags: flags, + MaxChannels: maxChannels, + IntelByteOrder: intelByteOrder != 0, + BlkDrvType: blkDrvType, + RequestID: requestID, + }, nil +} diff --git a/modules/codesysv3/codesysv3_test.go b/modules/codesysv3/codesysv3_test.go new file mode 100644 index 00000000..4fd5d836 --- /dev/null +++ b/modules/codesysv3/codesysv3_test.go @@ -0,0 +1,160 @@ +package codesysv3 + +import ( + "encoding/binary" + "net" + "testing" +) + +func utf16leEncode(s string) []byte { + buf := make([]byte, 0, len(s)*2) + for _, r := range s { + b := make([]byte, 2) + binary.LittleEndian.PutUint16(b, uint16(r)) + buf = append(buf, b...) + } + return buf +} + +// buildFakeIdentificationResponse hand-builds a NameService Identification +// datagram (without TCP block-driver framing) matching the layout decoded by +// ParseResponse, so the decoder can be exercised without a live device. +func buildFakeIdentificationResponse() []byte { + // 8-byte fixed NameService header: magic, hopinfo (header_length=4 words + // in the low 3 bits), packetinfo (unused by the parser), service_id, + // message_id, address_lengths=0 (no address words), broadcast_id. + buf := []byte{nsMagic, 0x04, 0x00, nsResponse, 0x00, 0x00, 0x00, 0x00} + + // 8-byte package header: package_type, version, request_id (all LE). + pkgHdr := make([]byte, 8) + binary.LittleEndian.PutUint16(pkgHdr[0:2], pkgIdentification) + binary.LittleEndian.PutUint16(pkgHdr[2:4], nodeInfoVersion) + binary.LittleEndian.PutUint32(pkgHdr[4:8], 0xDEADBEEF) + buf = append(buf, pkgHdr...) + + nodeName := utf16leEncode("Node1") + deviceName := utf16leEncode("MyPLC") + vendorName := utf16leEncode("Acme Automation") + serial := []byte("SN12345") + + // 28-byte fixed body: max_channels, intel_byte_order, addr_difference, + // parent_addr_size, {node,device,vendor}_name_len, target_type/id/version, flags. + body := make([]byte, 28) + binary.LittleEndian.PutUint16(body[0:2], 8) // max_channels + body[2] = 1 // intel_byte_order + body[3] = 0 // addr_difference (unused by parser) + binary.LittleEndian.PutUint16(body[4:6], 0) + binary.LittleEndian.PutUint16(body[6:8], uint16(len(nodeName)/2)) + binary.LittleEndian.PutUint16(body[8:10], uint16(len(deviceName)/2)) + binary.LittleEndian.PutUint16(body[10:12], uint16(len(vendorName)/2)) + binary.LittleEndian.PutUint32(body[12:16], 0x1000) // target_type + binary.LittleEndian.PutUint32(body[16:20], 0x2000) // target_id + binary.LittleEndian.PutUint32(body[20:24], 0x04030201) // target_version -> "4.3.2.1" + binary.LittleEndian.PutUint32(body[24:28], 0x00000001) // flags + buf = append(buf, body...) + + buf = append(buf, byte(len(serial)), 0x00, 0x05) // serial_len, oem_len=0, blk_drv_type=5 + buf = append(buf, make([]byte, 9)...) // 1 pad byte + 8 reserved bytes + + // Variable string table: parent_addr (0 bytes) then NUL-terminated + // node/device/vendor UTF-16LE names, then the NUL-terminated serial. + buf = append(buf, nodeName...) + buf = append(buf, 0x00, 0x00) + buf = append(buf, deviceName...) + buf = append(buf, 0x00, 0x00) + buf = append(buf, vendorName...) + buf = append(buf, 0x00, 0x00) + buf = append(buf, serial...) + buf = append(buf, 0x00) + return buf +} + +func TestParseResponse(t *testing.T) { + info, err := ParseResponse(buildFakeIdentificationResponse()) + if err != nil { + t.Fatalf("ParseResponse failed: %v", err) + } + if info.VendorName != "Acme Automation" { + t.Errorf("VendorName = %q, want %q", info.VendorName, "Acme Automation") + } + if info.DeviceName != "MyPLC" { + t.Errorf("DeviceName = %q, want %q", info.DeviceName, "MyPLC") + } + if info.NodeName != "Node1" { + t.Errorf("NodeName = %q, want %q", info.NodeName, "Node1") + } + if info.SerialNumber != "SN12345" { + t.Errorf("SerialNumber = %q, want %q", info.SerialNumber, "SN12345") + } + if info.TargetVersionStr != "4.3.2.1" { + t.Errorf("TargetVersionStr = %q, want %q", info.TargetVersionStr, "4.3.2.1") + } + if info.TargetType != 0x1000 || info.TargetID != 0x2000 { + t.Errorf("TargetType/TargetID = %#x/%#x, want 0x1000/0x2000", info.TargetType, info.TargetID) + } + if !info.IntelByteOrder { + t.Errorf("IntelByteOrder = false, want true") + } + if info.BlkDrvType != 5 { + t.Errorf("BlkDrvType = %d, want 5", info.BlkDrvType) + } + if info.RequestID != 0xDEADBEEF { + t.Errorf("RequestID = %#x, want 0xdeadbeef", info.RequestID) + } +} + +func TestParseResponseTCPFramed(t *testing.T) { + resp := buildFakeIdentificationResponse() + framed := make([]byte, 8, 8+len(resp)) + binary.LittleEndian.PutUint32(framed[0:4], tcpBDMagic) + binary.LittleEndian.PutUint32(framed[4:8], uint32(8+len(resp))) + framed = append(framed, resp...) + + info, err := ParseResponse(framed) + if err != nil { + t.Fatalf("ParseResponse (TCP-framed) failed: %v", err) + } + if info.VendorName != "Acme Automation" { + t.Errorf("VendorName = %q, want %q", info.VendorName, "Acme Automation") + } +} + +func TestParseResponseRejectsGarbage(t *testing.T) { + if _, err := ParseResponse([]byte("not codesys")); err == nil { + t.Error("expected error for garbage input, got nil") + } + if _, err := ParseResponse(nil); err == nil { + t.Error("expected error for empty input, got nil") + } +} + +func TestBuildRequests(t *testing.T) { + local := net.ParseIP("192.168.1.10") + remote := net.ParseIP("192.168.1.20") + + tcpReq, err := BuildTCPResolveRequest(local, 11740, remote, 11740, 0x1234, 0x11223344) + if err != nil { + t.Fatalf("BuildTCPResolveRequest failed: %v", err) + } + if len(tcpReq) == 0 { + t.Error("BuildTCPResolveRequest returned an empty request") + } + if binary.LittleEndian.Uint32(tcpReq[:4]) != tcpBDMagic { + t.Error("BuildTCPResolveRequest did not prepend the CmpBlkDrvTcp magic") + } + + udpReq, err := BuildUDPResolveRequest(local, 0, 24, 0x1234, 0x11223344) + if err != nil { + t.Fatalf("BuildUDPResolveRequest failed: %v", err) + } + if len(udpReq) == 0 { + t.Error("BuildUDPResolveRequest returned an empty request") + } + if udpReq[0] != nsMagic { + t.Error("BuildUDPResolveRequest did not start with the NameService magic") + } + + if _, err := BuildTCPResolveRequest(net.ParseIP("::1"), 1, remote, 1, 0, 0); err == nil { + t.Error("expected BuildTCPResolveRequest to reject an IPv6 address") + } +} diff --git a/modules/codesysv3/scanner.go b/modules/codesysv3/scanner.go new file mode 100644 index 00000000..d3b16c42 --- /dev/null +++ b/modules/codesysv3/scanner.go @@ -0,0 +1,114 @@ +package codesysv3 + +import ( + "context" + "errors" + "math/rand" + "net" + + "github.com/zmap/zgrab2" +) + +// Flags holds the command-line configuration for this scan module. +type Flags struct { + zgrab2.BaseFlags + + // UDP switches the scanner to the UDP NameService variant (usually + // ports 1740-1743) instead of the default TCP CmpBlkDrvTcp variant + // (usually ports 11740-11743). The UDP variant expects the request to + // originate from the matching reserved local port (1740 + port index); + // pair --udp with --local-port to set that explicitly. + UDP bool `long:"udp" description:"use the UDP NameService variant instead of the default TCP block-driver variant"` +} + +// Help returns additional help text for the flags. +func (f Flags) Help() string { + return "The UDP variant expects the request to come from local port 1740 + (port - 1740); pair --udp with --local-port to control that." +} + +// Scanner implements the zgrab2.Scanner interface. +type Scanner struct { + zgrab2.BaseScanner + config *Flags +} + +// NewModule returns a module for the CODESYS V3 scanner. +func NewModule() *zgrab2.TypedModule[Flags, Scanner, *Scanner] { + return zgrab2.NewTypedModule[Flags, Scanner, *Scanner]( + "codesys3", + "codesys3", + "Probe for CODESYS V3 runtimes via the NameService ResolveAddr/Identification exchange", + 11740, + ) +} + +// Init implements zgrab2.Scanner. +func (scanner *Scanner) Init(flags zgrab2.ScanFlags) error { + f, _ := flags.(*Flags) + scanner.config = f + scanner.SetBaseFlags(&f.BaseFlags) + protocol := zgrab2.TransportTCP + if f.UDP { + protocol = zgrab2.TransportUDP + } + scanner.DialerGroupConfig = &zgrab2.DialerGroupConfig{ + TransportAgnosticDialerProtocol: protocol, + BaseFlags: &f.BaseFlags, + } + return nil +} + +// buildRequest constructs the ResolveAddr request appropriate for the +// established connection's transport, using its real local/remote addresses. +func (scanner *Scanner) buildRequest(conn net.Conn, target *zgrab2.ScanTarget) ([]byte, error) { + broadcastID := uint16(rand.Intn(0x10000)) + requestID := rand.Uint32() + + if scanner.config.UDP { + localAddr, ok := conn.LocalAddr().(*net.UDPAddr) + if !ok { + return nil, errors.New("expected a UDP local address") + } + portIndex := int(target.Port) - 1740 + return BuildUDPResolveRequest(localAddr.IP, portIndex, 24, broadcastID, requestID) + } + + localAddr, ok := conn.LocalAddr().(*net.TCPAddr) + if !ok { + return nil, errors.New("expected a TCP local address") + } + remoteAddr, ok := conn.RemoteAddr().(*net.TCPAddr) + if !ok { + return nil, errors.New("expected a TCP remote address") + } + return BuildTCPResolveRequest(localAddr.IP, uint16(localAddr.Port), remoteAddr.IP, uint16(remoteAddr.Port), broadcastID, requestID) +} + +// Scan implements zgrab2.Scanner. +func (scanner *Scanner) Scan(ctx context.Context, dialGroup *zgrab2.DialerGroup, target *zgrab2.ScanTarget) (zgrab2.ScanStatus, any, error) { + conn, err := dialGroup.Dial(ctx, target) + if err != nil { + return zgrab2.TryGetScanStatus(err), nil, err + } + defer conn.Close() + + req, err := scanner.buildRequest(conn, target) + if err != nil { + return zgrab2.SCAN_APPLICATION_ERROR, nil, err + } + if _, err := conn.Write(req); err != nil { + return zgrab2.TryGetScanStatus(err), nil, err + } + + buf := make([]byte, 8192) + n, err := conn.Read(buf) + if err != nil { + return zgrab2.TryGetScanStatus(err), nil, err + } + + result, err := ParseResponse(buf[:n]) + if err != nil { + return zgrab2.SCAN_PROTOCOL_ERROR, nil, err + } + return zgrab2.SCAN_SUCCESS, result, nil +} diff --git a/modules/crimson.go b/modules/crimson.go new file mode 100644 index 00000000..0ee2ebe0 --- /dev/null +++ b/modules/crimson.go @@ -0,0 +1,10 @@ +package modules + +import ( + "github.com/zmap/zgrab2" + "github.com/zmap/zgrab2/modules/crimson" +) + +func init() { + zgrab2.RegisterModule(crimson.NewModule()) +} diff --git a/modules/crimson/crimson.go b/modules/crimson/crimson.go new file mode 100644 index 00000000..155cb133 --- /dev/null +++ b/modules/crimson/crimson.go @@ -0,0 +1,87 @@ +package crimson + +import ( + "bytes" + "encoding/hex" + "errors" + "net" + "strings" +) + +// Based on nmap's cr3-fingerprint.nse script and the CR3 fingerprinting +// probes used against it: the client sends a small fixed "get property" +// request for a numeric property ID (0x2b for manufacturer, 0x2a for model) +// and the device replies with a 6-byte header followed by a NUL-terminated +// ASCII string. +// Protocol runs over TCP, usually on port 789. +const ( + probeManufacturerHex = "0004012b1b00" + probeModelHex = "0004012a1a00" + + // headerSize is the length of the fixed CR3 response header that + // precedes the NUL-terminated string payload. + headerSize = 6 + readBufSize = 4096 +) + +var ( + probeManufacturer []byte + probeModel []byte +) + +func init() { + var err error + probeManufacturer, err = hex.DecodeString(probeManufacturerHex) + if err != nil { + panic("could not decode Crimson manufacturer probe") + } + probeModel, err = hex.DecodeString(probeModelHex) + if err != nil { + panic("could not decode Crimson model probe") + } +} + +// ErrNotCrimson is returned when neither probe yields a recognizable CR3 string. +var ErrNotCrimson = errors.New("no valid Crimson/Red Lion CR3 response") + +// DeviceInfo is the JSON-serializable result of a Crimson scan. +type DeviceInfo struct { + // Manufacturer is the vendor string returned for property 0x2b (typically "Red Lion Controls"). + Manufacturer string `json:"manufacturer,omitempty"` + // Model is the device/model string returned for property 0x2a. + Model string `json:"model,omitempty"` +} + +// exchange writes probe to conn and returns whatever response comes back. +func exchange(conn net.Conn, probe []byte) ([]byte, error) { + if _, err := conn.Write(probe); err != nil { + return nil, err + } + buf := make([]byte, readBufSize) + n, err := conn.Read(buf) + if err != nil { + return nil, err + } + return buf[:n], nil +} + +// parseCR3String extracts the NUL-terminated ASCII payload from a CR3 +// response, skipping the fixed 6-byte header. It returns "" if the response +// is too short or doesn't contain any alphanumeric text, since devices that +// don't understand the probe often echo back empty or garbage data. +func parseCR3String(resp []byte) string { + if len(resp) <= headerSize { + return "" + } + body := bytes.TrimSuffix(resp[headerSize:], []byte{0}) + if i := bytes.IndexByte(body, 0); i >= 0 { + body = body[:i] + } + text := strings.TrimSpace(string(body)) + for _, r := range text { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') { + return text + } + } + return "" +} diff --git a/modules/crimson/crimson_test.go b/modules/crimson/crimson_test.go new file mode 100644 index 00000000..bbcebb47 --- /dev/null +++ b/modules/crimson/crimson_test.go @@ -0,0 +1,146 @@ +package crimson + +import ( + "context" + "fmt" + "io" + "net" + "testing" + "time" + + "github.com/zmap/zgrab2" +) + +func getScanner(t *testing.T, port int) *Scanner { + m := NewModule() + scanner := m.NewScanner() + flags := m.NewFlags().(*Flags) + flags.Port = uint(port) + flags.TargetTimeout = 2 * time.Second + if err := scanner.Init(flags); err != nil { + t.Fatalf("Init failed: %v", err) + } + return scanner.(*Scanner) +} + +// buildFakeCR3Response hand-builds a CR3 "get property" response: a 6-byte +// header (ignored by the parser) followed by a NUL-terminated ASCII string. +func buildFakeCR3Response(s string) []byte { + buf := make([]byte, headerSize) + buf = append(buf, []byte(s)...) + buf = append(buf, 0x00) + return buf +} + +// runFakeServer accepts a single connection and answers each request it +// reads with the next response in order (one per probe). +func runFakeServer(t *testing.T, port int, responses ...[]byte) net.Listener { + listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) + if err != nil { + t.Fatalf("failed to listen: %v", err) + } + go func() { + sock, err := listener.Accept() + if err != nil { + return + } + defer sock.Close() + buf := make([]byte, 1024) + for _, resp := range responses { + if _, err := sock.Read(buf); err != nil && err != io.EOF { + return + } + if _, err := sock.Write(resp); err != nil { + return + } + } + }() + return listener +} + +func scanTarget(port int) *zgrab2.ScanTarget { + return &zgrab2.ScanTarget{IP: net.ParseIP("127.0.0.1"), Port: uint(port)} +} + +func TestScanSuccess(t *testing.T) { + const port = 20789 + listener := runFakeServer(t, port, + buildFakeCR3Response("Red Lion Controls"), + buildFakeCR3Response("CR3000"), + ) + defer listener.Close() + + scanner := getScanner(t, port) + dialerGroup, err := scanner.GetDialerGroupConfig().GetDefaultDialerGroupFromConfig() + if err != nil { + t.Fatalf("failed to get dialer group: %v", err) + } + + status, result, err := scanner.Scan(context.Background(), dialerGroup, scanTarget(port)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if status != zgrab2.SCAN_SUCCESS { + t.Fatalf("status = %v, want SCAN_SUCCESS", status) + } + info, ok := result.(*DeviceInfo) + if !ok { + t.Fatalf("result is not *DeviceInfo: %T", result) + } + if info.Manufacturer != "Red Lion Controls" { + t.Errorf("Manufacturer = %q, want %q", info.Manufacturer, "Red Lion Controls") + } + if info.Model != "CR3000" { + t.Errorf("Model = %q, want %q", info.Model, "CR3000") + } +} + +func TestScanNoResponse(t *testing.T) { + const port = 20790 + listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) + if err != nil { + t.Fatalf("failed to listen: %v", err) + } + defer listener.Close() + go func() { + sock, err := listener.Accept() + if err != nil { + return + } + defer sock.Close() + }() + + scanner := getScanner(t, port) + dialerGroup, err := scanner.GetDialerGroupConfig().GetDefaultDialerGroupFromConfig() + if err != nil { + t.Fatalf("failed to get dialer group: %v", err) + } + + status, _, err := scanner.Scan(context.Background(), dialerGroup, scanTarget(port)) + if err == nil { + t.Fatal("expected an error when the device answers with nothing, got nil") + } + if status != zgrab2.SCAN_PROTOCOL_ERROR { + t.Errorf("status = %v, want SCAN_PROTOCOL_ERROR", status) + } +} + +func TestParseCR3String(t *testing.T) { + tests := []struct { + name string + resp []byte + want string + }{ + {"valid", buildFakeCR3Response("Red Lion Controls"), "Red Lion Controls"}, + {"too short", []byte{0x00, 0x01, 0x02}, ""}, + {"no alnum", buildFakeCR3Response("!!!"), ""}, + {"empty body", buildFakeCR3Response(""), ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := parseCR3String(tt.resp); got != tt.want { + t.Errorf("parseCR3String() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/modules/crimson/scanner.go b/modules/crimson/scanner.go new file mode 100644 index 00000000..be9cded0 --- /dev/null +++ b/modules/crimson/scanner.go @@ -0,0 +1,75 @@ +package crimson + +import ( + "context" + + "github.com/zmap/zgrab2" +) + +// Flags holds the command-line configuration for this scan module. +type Flags struct { + zgrab2.BaseFlags +} + +// Scanner implements the zgrab2.Scanner interface. +type Scanner struct { + zgrab2.BaseScanner + config *Flags +} + +// NewModule returns a module for the Crimson scanner. +func NewModule() *zgrab2.TypedModule[Flags, Scanner, *Scanner] { + return zgrab2.NewTypedModule[Flags, Scanner, *Scanner]( + "crimson", + "Red Lion Crimson", + "Probe for Red Lion Crimson V3 HMI/PLC configuration devices", + 789, + ) +} + +// Init implements zgrab2.Scanner. +func (scanner *Scanner) Init(flags zgrab2.ScanFlags) error { + f, _ := flags.(*Flags) + scanner.config = f + scanner.SetBaseFlags(&f.BaseFlags) + scanner.DialerGroupConfig = &zgrab2.DialerGroupConfig{ + TransportAgnosticDialerProtocol: zgrab2.TransportTCP, + BaseFlags: &f.BaseFlags, + } + return nil +} + +// Scan implements zgrab2.Scanner. It queries the manufacturer and model CR3 +// properties over a single TCP connection. Some devices only answer one +// query per session, so if the model query comes back empty after a +// successful manufacturer query, it's retried on a fresh connection. +func (scanner *Scanner) Scan(ctx context.Context, dialGroup *zgrab2.DialerGroup, target *zgrab2.ScanTarget) (zgrab2.ScanStatus, any, error) { + conn, err := dialGroup.Dial(ctx, target) + if err != nil { + return zgrab2.TryGetScanStatus(err), nil, err + } + defer conn.Close() + + result := &DeviceInfo{} + + if mfgResp, err := exchange(conn, probeManufacturer); err == nil { + result.Manufacturer = parseCR3String(mfgResp) + } + if modelResp, err := exchange(conn, probeModel); err == nil { + result.Model = parseCR3String(modelResp) + } + + if result.Manufacturer != "" && result.Model == "" { + if conn2, err := dialGroup.Dial(ctx, target); err == nil { + if resp, err := exchange(conn2, probeModel); err == nil { + result.Model = parseCR3String(resp) + } + conn2.Close() + } + } + + if result.Manufacturer == "" && result.Model == "" { + return zgrab2.SCAN_PROTOCOL_ERROR, nil, ErrNotCrimson + } + return zgrab2.SCAN_SUCCESS, result, nil +} diff --git a/modules/pcworx.go b/modules/pcworx.go new file mode 100644 index 00000000..61b2c296 --- /dev/null +++ b/modules/pcworx.go @@ -0,0 +1,10 @@ +package modules + +import ( + "github.com/zmap/zgrab2" + "github.com/zmap/zgrab2/modules/pcworx" +) + +func init() { + zgrab2.RegisterModule(pcworx.NewModule()) +} diff --git a/modules/pcworx/pcworx.go b/modules/pcworx/pcworx.go new file mode 100644 index 00000000..fd2c3a50 --- /dev/null +++ b/modules/pcworx/pcworx.go @@ -0,0 +1,102 @@ +package pcworx + +import ( + "bytes" + "errors" + "strings" +) + +// The Phoenix Contact PC WorX protocol runs over TCP, usually on port 1962. +// A session starts with a fixed "init comms" handshake; the device echoes +// back a fixed-shape response containing a one-byte session id (byte 17) +// that must be threaded through two more requests to pull PLC identity +// info out of a third response. Byte layout is taken from the +// nmap-service-probes "pcworx" match rule. + +// initComms is the initial handshake request. +var initComms = []byte{ + 0x01, 0x01, 0x00, 0x1a, 0x00, 0x00, 0x00, 0x00, + 0x78, 0x80, 0x00, 0x03, 0x00, 0x0c, + 'I', 'B', 'E', 'T', 'H', '0', '1', 'N', '0', '_', 'M', 0x00, +} + +// handshakePrefix is everything up to the session id byte (index 17) in a +// valid handshake response; the two bytes after the session id must also be 0x00. +var handshakePrefix = []byte{ + 0x81, 0x01, 0x00, 0x14, 0x00, 0x00, 0x00, 0x01, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, +} + +const sessionIDOffset = 17 + +// ErrNotPCWorx is returned when the handshake response doesn't match the +// expected PC WorX signature. +var ErrNotPCWorx = errors.New("no valid PC WorX response") + +// DeviceInfo is the JSON-serializable result of a PC WorX scan. Only the +// handshake is required for a confirmed detection; the identity fields are +// filled in on a best-effort basis since not every device answers the +// follow-up info request. +type DeviceInfo struct { + PLCType string `json:"plc_type,omitempty"` + ModelNumber string `json:"model_number,omitempty"` + FirmwareVersion string `json:"firmware_version,omitempty"` + FirmwareDate string `json:"firmware_date,omitempty"` + FirmwareTime string `json:"firmware_time,omitempty"` +} + +// matchesHandshake reports whether resp is a valid PC WorX handshake reply. +func matchesHandshake(resp []byte) bool { + if len(resp) < 20 { + return false + } + if !bytes.Equal(resp[:sessionIDOffset], handshakePrefix) { + return false + } + return resp[sessionIDOffset+1] == 0x00 && resp[sessionIDOffset+2] == 0x00 +} + +// buildSetSessionRequest builds the second-stage request that activates the session id. +func buildSetSessionRequest(sid byte) []byte { + req := []byte{0x01, 0x05, 0x00, 0x16, 0x00, 0x01, 0x00, 0x00, 0x78, 0x80, 0x00} + req = append(req, sid) + req = append(req, 0x00, 0x00, 0x00, 0x06, 0x00, 0x04, 0x02, 0x95, 0x00, 0x00) + return req +} + +// buildInfoRequest builds the third-stage request that asks for PLC identity info. +func buildInfoRequest(sid byte) []byte { + req := []byte{0x01, 0x06, 0x00, 0x0e, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00} + req = append(req, sid) + req = append(req, 0x04, 0x00) + return req +} + +// cstrAt reads a NUL-terminated (or buffer-end-terminated) string at an +// absolute byte offset. +func cstrAt(data []byte, offset int) string { + if offset < 0 || offset >= len(data) { + return "" + } + end := bytes.IndexByte(data[offset:], 0x00) + if end < 0 { + end = len(data) - offset + } + return strings.TrimSpace(string(data[offset : offset+end])) +} + +// parseInfoResponse extracts PLC identity fields from a third-stage info +// response. Field offsets come from the nmap PC WorX probe. +func parseInfoResponse(resp []byte) *DeviceInfo { + if len(resp) == 0 || resp[0] != 0x81 { + return nil + } + info := &DeviceInfo{ + PLCType: cstrAt(resp, 30), + ModelNumber: cstrAt(resp, 152), + FirmwareVersion: cstrAt(resp, 66), + FirmwareDate: cstrAt(resp, 79), + FirmwareTime: cstrAt(resp, 91), + } + return info +} diff --git a/modules/pcworx/pcworx_test.go b/modules/pcworx/pcworx_test.go new file mode 100644 index 00000000..d7cd5cc6 --- /dev/null +++ b/modules/pcworx/pcworx_test.go @@ -0,0 +1,191 @@ +package pcworx + +import ( + "context" + "fmt" + "io" + "net" + "testing" + "time" + + "github.com/zmap/zgrab2" +) + +func getScanner(t *testing.T, port int) *Scanner { + m := NewModule() + scanner := m.NewScanner() + flags := m.NewFlags().(*Flags) + flags.Port = uint(port) + flags.TargetTimeout = 2 * time.Second + if err := scanner.Init(flags); err != nil { + t.Fatalf("Init failed: %v", err) + } + return scanner.(*Scanner) +} + +func scanTarget(port int) *zgrab2.ScanTarget { + return &zgrab2.ScanTarget{IP: net.ParseIP("127.0.0.1"), Port: uint(port)} +} + +// buildFakeHandshakeResponse builds a valid handshake reply carrying the +// given session id byte. +func buildFakeHandshakeResponse(sid byte) []byte { + resp := append([]byte{}, handshakePrefix...) + resp = append(resp, sid, 0x00, 0x00) + return resp +} + +// buildFakeInfoResponse builds a third-stage response with PLC identity +// fields at the fixed offsets parseInfoResponse expects. +func buildFakeInfoResponse(plcType, model, fwVersion, fwDate, fwTime string) []byte { + buf := make([]byte, 200) + buf[0] = 0x81 + putCStr := func(s string, offset int) { + copy(buf[offset:], s) + } + putCStr(plcType, 30) + putCStr(fwVersion, 66) + putCStr(fwDate, 79) + putCStr(fwTime, 91) + putCStr(model, 152) + return buf +} + +// runFakeServer accepts a single connection and answers each request it +// reads with the next response in order (one per protocol stage). +func runFakeServer(t *testing.T, port int, responses ...[]byte) net.Listener { + listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) + if err != nil { + t.Fatalf("failed to listen: %v", err) + } + go func() { + sock, err := listener.Accept() + if err != nil { + return + } + defer sock.Close() + buf := make([]byte, 1024) + for _, resp := range responses { + if _, err := sock.Read(buf); err != nil && err != io.EOF { + return + } + if _, err := sock.Write(resp); err != nil { + return + } + } + }() + return listener +} + +func TestScanSuccess(t *testing.T) { + const port = 21962 + const sid = byte(0x07) + listener := runFakeServer(t, port, + buildFakeHandshakeResponse(sid), + []byte{0x81, 0x01}, // set-session ack, contents unchecked by the scanner + buildFakeInfoResponse("ILC 350 PN", "2700981", "4.65", "Jan 1 2024", "12:00:00"), + ) + defer listener.Close() + + scanner := getScanner(t, port) + dialerGroup, err := scanner.GetDialerGroupConfig().GetDefaultDialerGroupFromConfig() + if err != nil { + t.Fatalf("failed to get dialer group: %v", err) + } + + status, result, err := scanner.Scan(context.Background(), dialerGroup, scanTarget(port)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if status != zgrab2.SCAN_SUCCESS { + t.Fatalf("status = %v, want SCAN_SUCCESS", status) + } + info, ok := result.(*DeviceInfo) + if !ok { + t.Fatalf("result is not *DeviceInfo: %T", result) + } + if info.PLCType != "ILC 350 PN" { + t.Errorf("PLCType = %q, want %q", info.PLCType, "ILC 350 PN") + } + if info.ModelNumber != "2700981" { + t.Errorf("ModelNumber = %q, want %q", info.ModelNumber, "2700981") + } + if info.FirmwareVersion != "4.65" { + t.Errorf("FirmwareVersion = %q, want %q", info.FirmwareVersion, "4.65") + } +} + +func TestScanHandshakeOnlySucceedsWithEmptyIdentity(t *testing.T) { + // A device that answers the handshake but never the follow-up requests + // still counts as a confirmed PC WorX detection. + const port = 21963 + listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) + if err != nil { + t.Fatalf("failed to listen: %v", err) + } + defer listener.Close() + go func() { + sock, err := listener.Accept() + if err != nil { + return + } + defer sock.Close() + buf := make([]byte, 1024) + if _, err := sock.Read(buf); err != nil && err != io.EOF { + return + } + _, _ = sock.Write(buildFakeHandshakeResponse(0x01)) + // No further responses; the scanner's follow-up requests will fail + // to read and it should fall back to an empty DeviceInfo. + }() + + scanner := getScanner(t, port) + dialerGroup, err := scanner.GetDialerGroupConfig().GetDefaultDialerGroupFromConfig() + if err != nil { + t.Fatalf("failed to get dialer group: %v", err) + } + + status, result, err := scanner.Scan(context.Background(), dialerGroup, scanTarget(port)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if status != zgrab2.SCAN_SUCCESS { + t.Fatalf("status = %v, want SCAN_SUCCESS", status) + } + info := result.(*DeviceInfo) + if info.PLCType != "" || info.ModelNumber != "" { + t.Errorf("expected empty DeviceInfo, got %+v", info) + } +} + +func TestScanRejectsBadHandshake(t *testing.T) { + const port = 21964 + listener := runFakeServer(t, port, []byte("not pcworx")) + defer listener.Close() + + scanner := getScanner(t, port) + dialerGroup, err := scanner.GetDialerGroupConfig().GetDefaultDialerGroupFromConfig() + if err != nil { + t.Fatalf("failed to get dialer group: %v", err) + } + + status, _, err := scanner.Scan(context.Background(), dialerGroup, scanTarget(port)) + if err == nil { + t.Fatal("expected an error for a non-PCWorx handshake, got nil") + } + if status != zgrab2.SCAN_PROTOCOL_ERROR { + t.Errorf("status = %v, want SCAN_PROTOCOL_ERROR", status) + } +} + +func TestMatchesHandshake(t *testing.T) { + if !matchesHandshake(buildFakeHandshakeResponse(0x07)) { + t.Error("expected a well-formed handshake response to match") + } + if matchesHandshake([]byte("too short")) { + t.Error("expected a too-short response to not match") + } + if matchesHandshake(append(append([]byte{}, handshakePrefix...), 0x07, 0x01, 0x00)) { + t.Error("expected a response with a non-zero byte after the session id to not match") + } +} diff --git a/modules/pcworx/scanner.go b/modules/pcworx/scanner.go new file mode 100644 index 00000000..6fb51831 --- /dev/null +++ b/modules/pcworx/scanner.go @@ -0,0 +1,86 @@ +package pcworx + +import ( + "context" + "net" + + "github.com/zmap/zgrab2" +) + +// Flags holds the command-line configuration for this scan module. +type Flags struct { + zgrab2.BaseFlags +} + +// Scanner implements the zgrab2.Scanner interface. +type Scanner struct { + zgrab2.BaseScanner + config *Flags +} + +// NewModule returns a module for the PC WorX scanner. +func NewModule() *zgrab2.TypedModule[Flags, Scanner, *Scanner] { + return zgrab2.NewTypedModule[Flags, Scanner, *Scanner]( + "pcworx", + "PC WorX (Phoenix Contact)", + "Probe for Phoenix Contact PC WorX PLC runtimes", + 1962, + ) +} + +// Init implements zgrab2.Scanner. +func (scanner *Scanner) Init(flags zgrab2.ScanFlags) error { + f, _ := flags.(*Flags) + scanner.config = f + scanner.SetBaseFlags(&f.BaseFlags) + scanner.DialerGroupConfig = &zgrab2.DialerGroupConfig{ + TransportAgnosticDialerProtocol: zgrab2.TransportTCP, + BaseFlags: &f.BaseFlags, + } + return nil +} + +// exchange writes req to conn and returns whatever response comes back. +func exchange(conn net.Conn, req []byte) ([]byte, error) { + if _, err := conn.Write(req); err != nil { + return nil, err + } + buf := make([]byte, 4096) + n, err := conn.Read(buf) + if err != nil { + return nil, err + } + return buf[:n], nil +} + +// Scan implements zgrab2.Scanner. The initial handshake alone is enough to +// confirm a PC WorX device; the two follow-up requests that pull PLC +// identity fields are best-effort and don't affect the scan status if a +// device doesn't answer them. +func (scanner *Scanner) Scan(ctx context.Context, dialGroup *zgrab2.DialerGroup, target *zgrab2.ScanTarget) (zgrab2.ScanStatus, any, error) { + conn, err := dialGroup.Dial(ctx, target) + if err != nil { + return zgrab2.TryGetScanStatus(err), nil, err + } + defer conn.Close() + + handshakeResp, err := exchange(conn, initComms) + if err != nil { + return zgrab2.TryGetScanStatus(err), nil, err + } + if !matchesHandshake(handshakeResp) { + return zgrab2.SCAN_PROTOCOL_ERROR, nil, ErrNotPCWorx + } + sid := handshakeResp[sessionIDOffset] + + result := &DeviceInfo{} + if _, err := exchange(conn, buildSetSessionRequest(sid)); err == nil { + if infoResp, err := exchange(conn, buildInfoRequest(sid)); err == nil { + if info := parseInfoResponse(infoResp); info != nil { + result = info + } + } + } + + return zgrab2.SCAN_SUCCESS, result, nil +} diff --git a/modules/proconos.go b/modules/proconos.go new file mode 100644 index 00000000..176bdf9a --- /dev/null +++ b/modules/proconos.go @@ -0,0 +1,10 @@ +package modules + +import ( + "github.com/zmap/zgrab2" + "github.com/zmap/zgrab2/modules/proconos" +) + +func init() { + zgrab2.RegisterModule(proconos.NewModule()) +} diff --git a/modules/proconos/proconos.go b/modules/proconos/proconos.go new file mode 100644 index 00000000..8546d30d --- /dev/null +++ b/modules/proconos/proconos.go @@ -0,0 +1,140 @@ +package proconos + +import ( + "bytes" + "errors" +) + +// probe elicits a device-info response from a ProConOS (Phoenix Contact / +// KW-Software) PLC runtime. The response layout is: +// +// 0xcc 0x01 <3 bytes> 0x02 0x92 0x00 'V' "ProConOS V" +// " " <3-letter month> " " (build date, ignored) +// \x00 \x00 \x00 \x00 +// +// Runs over TCP, usually on port 20547. +var probe = []byte{0xcc, 0x01, 0x00, 0x0b, 0x40, 0x02, 0x00, 0x00, 0x47, 0xee} + +const readBufSize = 4096 + +// ErrNotProConOS is returned when the response doesn't match the expected +// ProConOS signature. +var ErrNotProConOS = errors.New("no valid ProConOS response") + +// DeviceInfo is the JSON-serializable result of a ProConOS scan. +type DeviceInfo struct { + // OSVersion is the runtime OS version reported before the "ProConOS V" marker. + OSVersion string `json:"os_version,omitempty"` + // Version is the ProConOS runtime version. + Version string `json:"version,omitempty"` + // PLC is the PLC/hardware model name. + PLC string `json:"plc,omitempty"` + // Project is the loaded project name (or "A/B" if two distinct names are reported). + Project string `json:"project,omitempty"` + // Source is the source path/identifier reported by the device. + Source string `json:"source,omitempty"` +} + +func isDigitOrDot(b byte) bool { + return (b >= '0' && b <= '9') || b == '.' +} + +// readDigitsOrDots consumes a run of ASCII digits/dots starting at pos. +func readDigitsOrDots(resp []byte, pos int) (string, int) { + start := pos + for pos < len(resp) && isDigitOrDot(resp[pos]) { + pos++ + } + return string(resp[start:pos]), pos +} + +// skipNulls advances pos past a run of one or more NUL bytes. +func skipNulls(resp []byte, pos int) (int, bool) { + start := pos + for pos < len(resp) && resp[pos] == 0x00 { + pos++ + } + return pos, pos > start +} + +// readUntilNull reads bytes up to (not including) the next NUL byte. +func readUntilNull(resp []byte, pos int) (string, int, bool) { + idx := bytes.IndexByte(resp[pos:], 0x00) + if idx < 0 { + return "", pos, false + } + return string(resp[pos : pos+idx]), pos + idx, true +} + +// parseResponse validates the fixed ProConOS signature bytes and extracts +// the runtime version, PLC name, project name(s), and source path from the +// NUL-delimited string table that follows. +func parseResponse(resp []byte) (*DeviceInfo, error) { + const magicLen = 9 // 0xcc 0x01 + 3 arbitrary bytes + 0x02 0x92 0x00 + 'V' + if len(resp) < magicLen || + resp[0] != 0xcc || resp[1] != 0x01 || + resp[5] != 0x02 || resp[6] != 0x92 || resp[7] != 0x00 || + resp[8] != 'V' { + return nil, ErrNotProConOS + } + + pos := magicLen + var osVersion, version, plc, projectA, projectB, source string + var ok bool + + osVersion, pos = readDigitsOrDots(resp, pos) + if osVersion == "" { + return nil, ErrNotProConOS + } + + const marker = "ProConOS V" + if pos+len(marker) > len(resp) || string(resp[pos:pos+len(marker)]) != marker { + return nil, ErrNotProConOS + } + pos += len(marker) + + version, pos = readDigitsOrDots(resp, pos) + if version == "" { + return nil, ErrNotProConOS + } + + // Skip the build-date stamp (e.g. " Jan 1 2024") up to the NUL padding + // that separates it from the string table. + dateEnd := bytes.IndexByte(resp[pos:], 0x00) + if dateEnd < 0 { + return nil, ErrNotProConOS + } + pos += dateEnd + + if pos, ok = skipNulls(resp, pos); !ok { + return nil, ErrNotProConOS + } + if plc, pos, ok = readUntilNull(resp, pos); !ok { + return nil, ErrNotProConOS + } + pos, _ = skipNulls(resp, pos) + if projectA, pos, ok = readUntilNull(resp, pos); !ok { + return nil, ErrNotProConOS + } + pos, _ = skipNulls(resp, pos) + if projectB, pos, ok = readUntilNull(resp, pos); !ok { + return nil, ErrNotProConOS + } + pos, _ = skipNulls(resp, pos) + if source, _, ok = readUntilNull(resp, pos); !ok { + return nil, ErrNotProConOS + } + + project := projectA + if projectB != "" && projectB != projectA { + project = projectA + "/" + projectB + } + + return &DeviceInfo{ + OSVersion: osVersion, + Version: version, + PLC: plc, + Project: project, + Source: source, + }, nil +} diff --git a/modules/proconos/proconos_test.go b/modules/proconos/proconos_test.go new file mode 100644 index 00000000..e881ce41 --- /dev/null +++ b/modules/proconos/proconos_test.go @@ -0,0 +1,166 @@ +package proconos + +import ( + "context" + "fmt" + "io" + "net" + "testing" + "time" + + "github.com/zmap/zgrab2" +) + +func getScanner(t *testing.T, port int) *Scanner { + m := NewModule() + scanner := m.NewScanner() + flags := m.NewFlags().(*Flags) + flags.Port = uint(port) + flags.TargetTimeout = 2 * time.Second + if err := scanner.Init(flags); err != nil { + t.Fatalf("Init failed: %v", err) + } + return scanner.(*Scanner) +} + +// buildFakeDeviceInfoResponse hand-builds a ProConOS device-info response +// matching the layout decoded by parseResponse, so the decoder can be +// exercised without a live PLC. +func buildFakeDeviceInfoResponse(osVersion, version, plc, projectA, projectB, source string) []byte { + buf := []byte{0xcc, 0x01, 0x00, 0x0b, 0x40, 0x02, 0x92, 0x00, 'V'} + buf = append(buf, []byte(osVersion)...) + buf = append(buf, []byte("ProConOS V")...) + buf = append(buf, []byte(version)...) + buf = append(buf, []byte(" Jan 1 2024")...) + buf = append(buf, 0x00, 0x00) // date terminator + padding, consumed by skipNulls + buf = append(buf, []byte(plc)...) + buf = append(buf, 0x00) + buf = append(buf, []byte(projectA)...) + buf = append(buf, 0x00) + buf = append(buf, []byte(projectB)...) + buf = append(buf, 0x00) + buf = append(buf, []byte(source)...) + buf = append(buf, 0x00) + return buf +} + +func runFakeServer(t *testing.T, port int, response []byte) net.Listener { + listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) + if err != nil { + t.Fatalf("failed to listen: %v", err) + } + go func() { + sock, err := listener.Accept() + if err != nil { + return + } + defer sock.Close() + buf := make([]byte, 1024) + if _, err := sock.Read(buf); err != nil && err != io.EOF { + return + } + _, _ = sock.Write(response) + }() + return listener +} + +func TestScanSuccess(t *testing.T) { + const port = 21547 + response := buildFakeDeviceInfoResponse("3.90", "5.10", "PLC-X20", "ProjA", "ProjB", "USB:MyProject.pro") + listener := runFakeServer(t, port, response) + defer listener.Close() + + scanner := getScanner(t, port) + dialerGroup, err := scanner.GetDialerGroupConfig().GetDefaultDialerGroupFromConfig() + if err != nil { + t.Fatalf("failed to get dialer group: %v", err) + } + target := &zgrab2.ScanTarget{IP: net.ParseIP("127.0.0.1"), Port: uint(port)} + + status, result, err := scanner.Scan(context.Background(), dialerGroup, target) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if status != zgrab2.SCAN_SUCCESS { + t.Fatalf("status = %v, want SCAN_SUCCESS", status) + } + info, ok := result.(*DeviceInfo) + if !ok { + t.Fatalf("result is not *DeviceInfo: %T", result) + } + if info.OSVersion != "3.90" { + t.Errorf("OSVersion = %q, want %q", info.OSVersion, "3.90") + } + if info.Version != "5.10" { + t.Errorf("Version = %q, want %q", info.Version, "5.10") + } + if info.PLC != "PLC-X20" { + t.Errorf("PLC = %q, want %q", info.PLC, "PLC-X20") + } + if info.Project != "ProjA/ProjB" { + t.Errorf("Project = %q, want %q", info.Project, "ProjA/ProjB") + } + if info.Source != "USB:MyProject.pro" { + t.Errorf("Source = %q, want %q", info.Source, "USB:MyProject.pro") + } +} + +func TestScanSameProjectNames(t *testing.T) { + const port = 21548 + response := buildFakeDeviceInfoResponse("3.90", "5.10", "PLC-X20", "SameProj", "SameProj", "USB:MyProject.pro") + listener := runFakeServer(t, port, response) + defer listener.Close() + + scanner := getScanner(t, port) + dialerGroup, err := scanner.GetDialerGroupConfig().GetDefaultDialerGroupFromConfig() + if err != nil { + t.Fatalf("failed to get dialer group: %v", err) + } + target := &zgrab2.ScanTarget{IP: net.ParseIP("127.0.0.1"), Port: uint(port)} + + status, result, err := scanner.Scan(context.Background(), dialerGroup, target) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if status != zgrab2.SCAN_SUCCESS { + t.Fatalf("status = %v, want SCAN_SUCCESS", status) + } + info := result.(*DeviceInfo) + if info.Project != "SameProj" { + t.Errorf("Project = %q, want %q (deduplicated)", info.Project, "SameProj") + } +} + +func TestScanRejectsGarbage(t *testing.T) { + const port = 21549 + listener := runFakeServer(t, port, []byte("not proconos")) + defer listener.Close() + + scanner := getScanner(t, port) + dialerGroup, err := scanner.GetDialerGroupConfig().GetDefaultDialerGroupFromConfig() + if err != nil { + t.Fatalf("failed to get dialer group: %v", err) + } + target := &zgrab2.ScanTarget{IP: net.ParseIP("127.0.0.1"), Port: uint(port)} + + status, _, err := scanner.Scan(context.Background(), dialerGroup, target) + if err == nil { + t.Fatal("expected an error for a non-ProConOS response, got nil") + } + if status != zgrab2.SCAN_PROTOCOL_ERROR { + t.Errorf("status = %v, want SCAN_PROTOCOL_ERROR", status) + } +} + +func TestParseResponseTable(t *testing.T) { + valid := buildFakeDeviceInfoResponse("3.90", "5.10", "PLC-X20", "ProjA", "ProjB", "src") + if _, err := parseResponse(valid); err != nil { + t.Errorf("parseResponse(valid) failed: %v", err) + } + if _, err := parseResponse([]byte("garbage")); err == nil { + t.Error("expected error for garbage input, got nil") + } + if _, err := parseResponse(nil); err == nil { + t.Error("expected error for empty input, got nil") + } +} diff --git a/modules/proconos/scanner.go b/modules/proconos/scanner.go new file mode 100644 index 00000000..993d7d91 --- /dev/null +++ b/modules/proconos/scanner.go @@ -0,0 +1,75 @@ +package proconos + +import ( + "context" + "net" + + "github.com/zmap/zgrab2" +) + +// Flags holds the command-line configuration for this scan module. +type Flags struct { + zgrab2.BaseFlags +} + +// Scanner implements the zgrab2.Scanner interface. +type Scanner struct { + zgrab2.BaseScanner + config *Flags +} + +// NewModule returns a module for the ProConOS scanner. +func NewModule() *zgrab2.TypedModule[Flags, Scanner, *Scanner] { + return zgrab2.NewTypedModule[Flags, Scanner, *Scanner]( + "proconos", + "ProConOS", + "Probe for Phoenix Contact / KW-Software ProConOS PLC runtimes", + 20547, + ) +} + +// Init implements zgrab2.Scanner. +func (scanner *Scanner) Init(flags zgrab2.ScanFlags) error { + f, _ := flags.(*Flags) + scanner.config = f + scanner.SetBaseFlags(&f.BaseFlags) + scanner.DialerGroupConfig = &zgrab2.DialerGroupConfig{ + TransportAgnosticDialerProtocol: zgrab2.TransportTCP, + BaseFlags: &f.BaseFlags, + } + return nil +} + +// exchange writes probe to conn and returns whatever response comes back. +func exchange(conn net.Conn, probe []byte) ([]byte, error) { + if _, err := conn.Write(probe); err != nil { + return nil, err + } + buf := make([]byte, readBufSize) + n, err := conn.Read(buf) + if err != nil { + return nil, err + } + return buf[:n], nil +} + +// Scan implements zgrab2.Scanner. It sends the fixed ProConOS device-info +// request and parses the resulting NUL-delimited string table. +func (scanner *Scanner) Scan(ctx context.Context, dialGroup *zgrab2.DialerGroup, target *zgrab2.ScanTarget) (zgrab2.ScanStatus, any, error) { + conn, err := dialGroup.Dial(ctx, target) + if err != nil { + return zgrab2.TryGetScanStatus(err), nil, err + } + defer conn.Close() + + resp, err := exchange(conn, probe) + if err != nil { + return zgrab2.TryGetScanStatus(err), nil, err + } + + result, err := parseResponse(resp) + if err != nil { + return zgrab2.SCAN_PROTOCOL_ERROR, nil, err + } + return zgrab2.SCAN_SUCCESS, result, nil +} diff --git a/zgrab2_schemas/zgrab2/__init__.py b/zgrab2_schemas/zgrab2/__init__.py index f7fed9db..6eb553e6 100644 --- a/zgrab2_schemas/zgrab2/__init__.py +++ b/zgrab2_schemas/zgrab2/__init__.py @@ -30,3 +30,7 @@ from . import mqtt from . import pptp from . import checkpoint +from . import codesys3 +from . import crimson +from . import pcworx +from . import proconos diff --git a/zgrab2_schemas/zgrab2/codesys3.py b/zgrab2_schemas/zgrab2/codesys3.py new file mode 100644 index 00000000..35bd8ea0 --- /dev/null +++ b/zgrab2_schemas/zgrab2/codesys3.py @@ -0,0 +1,34 @@ +# zschema sub-schema for zgrab2's codesysv3 module (protocol name "codesys3") +# Registers zgrab2-codesys3 globally, and codesys3 with the main zgrab2 schema. +from zschema.leaves import * +from zschema.compounds import * +import zschema.registry + +from . import zgrab2 + +codesys3_scan_response = SubRecord( + { + "result": SubRecord( + { + "vendor_name": String(), + "device_name": String(), + "node_name": String(), + "serial_number": String(), + "target_type": Unsigned32BitInteger(), + "target_id": Unsigned32BitInteger(), + "target_version": Unsigned32BitInteger(), + "target_version_str": String(), + "flags": Unsigned32BitInteger(), + "max_channels": Unsigned16BitInteger(), + "intel_byte_order": Boolean(), + "blk_drv_type": Unsigned8BitInteger(), + "request_id": Unsigned32BitInteger(), + } + ) + }, + extends=zgrab2.base_scan_response, +) + +zschema.registry.register_schema("zgrab2-codesys3", codesys3_scan_response) + +zgrab2.register_scan_response_type("codesys3", codesys3_scan_response) diff --git a/zgrab2_schemas/zgrab2/crimson.py b/zgrab2_schemas/zgrab2/crimson.py new file mode 100644 index 00000000..a608b65e --- /dev/null +++ b/zgrab2_schemas/zgrab2/crimson.py @@ -0,0 +1,23 @@ +# zschema sub-schema for zgrab2's crimson module +# Registers zgrab2-crimson globally, and crimson with the main zgrab2 schema. +from zschema.leaves import * +from zschema.compounds import * +import zschema.registry + +from . import zgrab2 + +crimson_scan_response = SubRecord( + { + "result": SubRecord( + { + "manufacturer": String(), + "model": String(), + } + ) + }, + extends=zgrab2.base_scan_response, +) + +zschema.registry.register_schema("zgrab2-crimson", crimson_scan_response) + +zgrab2.register_scan_response_type("crimson", crimson_scan_response) diff --git a/zgrab2_schemas/zgrab2/pcworx.py b/zgrab2_schemas/zgrab2/pcworx.py new file mode 100644 index 00000000..3f9f1852 --- /dev/null +++ b/zgrab2_schemas/zgrab2/pcworx.py @@ -0,0 +1,26 @@ +# zschema sub-schema for zgrab2's pcworx module +# Registers zgrab2-pcworx globally, and pcworx with the main zgrab2 schema. +from zschema.leaves import * +from zschema.compounds import * +import zschema.registry + +from . import zgrab2 + +pcworx_scan_response = SubRecord( + { + "result": SubRecord( + { + "plc_type": String(), + "model_number": String(), + "firmware_version": String(), + "firmware_date": String(), + "firmware_time": String(), + } + ) + }, + extends=zgrab2.base_scan_response, +) + +zschema.registry.register_schema("zgrab2-pcworx", pcworx_scan_response) + +zgrab2.register_scan_response_type("pcworx", pcworx_scan_response) diff --git a/zgrab2_schemas/zgrab2/proconos.py b/zgrab2_schemas/zgrab2/proconos.py new file mode 100644 index 00000000..043d5327 --- /dev/null +++ b/zgrab2_schemas/zgrab2/proconos.py @@ -0,0 +1,26 @@ +# zschema sub-schema for zgrab2's proconos module +# Registers zgrab2-proconos globally, and proconos with the main zgrab2 schema. +from zschema.leaves import * +from zschema.compounds import * +import zschema.registry + +from . import zgrab2 + +proconos_scan_response = SubRecord( + { + "result": SubRecord( + { + "os_version": String(), + "version": String(), + "plc": String(), + "project": String(), + "source": String(), + } + ) + }, + extends=zgrab2.base_scan_response, +) + +zschema.registry.register_schema("zgrab2-proconos", proconos_scan_response) + +zgrab2.register_scan_response_type("proconos", proconos_scan_response) From 83a6411faef2616a7f3025048fc25b1e988af3c5 Mon Sep 17 00:00:00 2001 From: Ananya Date: Mon, 10 Aug 2026 13:38:16 +0530 Subject: [PATCH 2/2] Fix lint issues and add fuzz test for OT modules - Fix govet shadow warnings (err shadowing) in codesysv3, crimson, pcworx - Preallocate slices per prealloc linter suggestions in codesysv3, crimson, pcworx, proconos test helpers - Add FuzzParseResponse for codesysv3 to satisfy fuzz coverage check --- modules/codesysv3/codesysv3_fuzz_test.go | 23 +++++++++++++++++++++++ modules/codesysv3/codesysv3_test.go | 15 +++++++++------ modules/codesysv3/scanner.go | 2 +- modules/crimson/crimson_test.go | 10 +++++----- modules/pcworx/pcworx.go | 6 ++++-- modules/pcworx/pcworx_test.go | 6 +++--- modules/proconos/proconos_test.go | 3 ++- 7 files changed, 47 insertions(+), 18 deletions(-) create mode 100644 modules/codesysv3/codesysv3_fuzz_test.go diff --git a/modules/codesysv3/codesysv3_fuzz_test.go b/modules/codesysv3/codesysv3_fuzz_test.go new file mode 100644 index 00000000..fd3a0f05 --- /dev/null +++ b/modules/codesysv3/codesysv3_fuzz_test.go @@ -0,0 +1,23 @@ +package codesysv3 + +import ( + "encoding/binary" + "testing" +) + +func FuzzParseResponse(f *testing.F) { + f.Add(buildFakeIdentificationResponse()) + f.Add([]byte{}) + f.Add([]byte{nsMagic}) + + resp := buildFakeIdentificationResponse() + framed := make([]byte, 8, 8+len(resp)) + binary.LittleEndian.PutUint32(framed[0:4], tcpBDMagic) + binary.LittleEndian.PutUint32(framed[4:8], uint32(8+len(resp))) + framed = append(framed, resp...) + f.Add(framed) + + f.Fuzz(func(t *testing.T, data []byte) { + _, _ = ParseResponse(data) + }) +} diff --git a/modules/codesysv3/codesysv3_test.go b/modules/codesysv3/codesysv3_test.go index 4fd5d836..204d9dbb 100644 --- a/modules/codesysv3/codesysv3_test.go +++ b/modules/codesysv3/codesysv3_test.go @@ -20,10 +20,18 @@ func utf16leEncode(s string) []byte { // datagram (without TCP block-driver framing) matching the layout decoded by // ParseResponse, so the decoder can be exercised without a live device. func buildFakeIdentificationResponse() []byte { + nodeName := utf16leEncode("Node1") + deviceName := utf16leEncode("MyPLC") + vendorName := utf16leEncode("Acme Automation") + serial := []byte("SN12345") + // 8-byte fixed NameService header: magic, hopinfo (header_length=4 words // in the low 3 bits), packetinfo (unused by the parser), service_id, // message_id, address_lengths=0 (no address words), broadcast_id. - buf := []byte{nsMagic, 0x04, 0x00, nsResponse, 0x00, 0x00, 0x00, 0x00} + const headerLen, pkgHdrLen, bodyLen, trailerLen = 8, 8, 28, 3 + 9 + buf := make([]byte, 0, headerLen+pkgHdrLen+bodyLen+trailerLen+ + len(nodeName)+2+len(deviceName)+2+len(vendorName)+2+len(serial)+1) + buf = append(buf, nsMagic, 0x04, 0x00, nsResponse, 0x00, 0x00, 0x00, 0x00) // 8-byte package header: package_type, version, request_id (all LE). pkgHdr := make([]byte, 8) @@ -32,11 +40,6 @@ func buildFakeIdentificationResponse() []byte { binary.LittleEndian.PutUint32(pkgHdr[4:8], 0xDEADBEEF) buf = append(buf, pkgHdr...) - nodeName := utf16leEncode("Node1") - deviceName := utf16leEncode("MyPLC") - vendorName := utf16leEncode("Acme Automation") - serial := []byte("SN12345") - // 28-byte fixed body: max_channels, intel_byte_order, addr_difference, // parent_addr_size, {node,device,vendor}_name_len, target_type/id/version, flags. body := make([]byte, 28) diff --git a/modules/codesysv3/scanner.go b/modules/codesysv3/scanner.go index d3b16c42..d8fff01a 100644 --- a/modules/codesysv3/scanner.go +++ b/modules/codesysv3/scanner.go @@ -96,7 +96,7 @@ func (scanner *Scanner) Scan(ctx context.Context, dialGroup *zgrab2.DialerGroup, if err != nil { return zgrab2.SCAN_APPLICATION_ERROR, nil, err } - if _, err := conn.Write(req); err != nil { + if _, err = conn.Write(req); err != nil { return zgrab2.TryGetScanStatus(err), nil, err } diff --git a/modules/crimson/crimson_test.go b/modules/crimson/crimson_test.go index bbcebb47..8ca7507f 100644 --- a/modules/crimson/crimson_test.go +++ b/modules/crimson/crimson_test.go @@ -26,7 +26,7 @@ func getScanner(t *testing.T, port int) *Scanner { // buildFakeCR3Response hand-builds a CR3 "get property" response: a 6-byte // header (ignored by the parser) followed by a NUL-terminated ASCII string. func buildFakeCR3Response(s string) []byte { - buf := make([]byte, headerSize) + buf := make([]byte, headerSize, headerSize+len(s)+1) buf = append(buf, []byte(s)...) buf = append(buf, 0x00) return buf @@ -40,8 +40,8 @@ func runFakeServer(t *testing.T, port int, responses ...[]byte) net.Listener { t.Fatalf("failed to listen: %v", err) } go func() { - sock, err := listener.Accept() - if err != nil { + sock, acceptErr := listener.Accept() + if acceptErr != nil { return } defer sock.Close() @@ -103,8 +103,8 @@ func TestScanNoResponse(t *testing.T) { } defer listener.Close() go func() { - sock, err := listener.Accept() - if err != nil { + sock, acceptErr := listener.Accept() + if acceptErr != nil { return } defer sock.Close() diff --git a/modules/pcworx/pcworx.go b/modules/pcworx/pcworx.go index fd2c3a50..82e4d254 100644 --- a/modules/pcworx/pcworx.go +++ b/modules/pcworx/pcworx.go @@ -58,7 +58,8 @@ func matchesHandshake(resp []byte) bool { // buildSetSessionRequest builds the second-stage request that activates the session id. func buildSetSessionRequest(sid byte) []byte { - req := []byte{0x01, 0x05, 0x00, 0x16, 0x00, 0x01, 0x00, 0x00, 0x78, 0x80, 0x00} + req := make([]byte, 0, 22) + req = append(req, 0x01, 0x05, 0x00, 0x16, 0x00, 0x01, 0x00, 0x00, 0x78, 0x80, 0x00) req = append(req, sid) req = append(req, 0x00, 0x00, 0x00, 0x06, 0x00, 0x04, 0x02, 0x95, 0x00, 0x00) return req @@ -66,7 +67,8 @@ func buildSetSessionRequest(sid byte) []byte { // buildInfoRequest builds the third-stage request that asks for PLC identity info. func buildInfoRequest(sid byte) []byte { - req := []byte{0x01, 0x06, 0x00, 0x0e, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00} + req := make([]byte, 0, 14) + req = append(req, 0x01, 0x06, 0x00, 0x0e, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00) req = append(req, sid) req = append(req, 0x04, 0x00) return req diff --git a/modules/pcworx/pcworx_test.go b/modules/pcworx/pcworx_test.go index d7cd5cc6..ddd97ca7 100644 --- a/modules/pcworx/pcworx_test.go +++ b/modules/pcworx/pcworx_test.go @@ -125,13 +125,13 @@ func TestScanHandshakeOnlySucceedsWithEmptyIdentity(t *testing.T) { } defer listener.Close() go func() { - sock, err := listener.Accept() - if err != nil { + sock, acceptErr := listener.Accept() + if acceptErr != nil { return } defer sock.Close() buf := make([]byte, 1024) - if _, err := sock.Read(buf); err != nil && err != io.EOF { + if _, readErr := sock.Read(buf); readErr != nil && readErr != io.EOF { return } _, _ = sock.Write(buildFakeHandshakeResponse(0x01)) diff --git a/modules/proconos/proconos_test.go b/modules/proconos/proconos_test.go index e881ce41..6eb5befd 100644 --- a/modules/proconos/proconos_test.go +++ b/modules/proconos/proconos_test.go @@ -27,7 +27,8 @@ func getScanner(t *testing.T, port int) *Scanner { // matching the layout decoded by parseResponse, so the decoder can be // exercised without a live PLC. func buildFakeDeviceInfoResponse(osVersion, version, plc, projectA, projectB, source string) []byte { - buf := []byte{0xcc, 0x01, 0x00, 0x0b, 0x40, 0x02, 0x92, 0x00, 'V'} + buf := make([]byte, 0, 9+len(osVersion)+10+len(version)+12+2+len(plc)+1+len(projectA)+1+len(projectB)+1+len(source)+1) + buf = append(buf, 0xcc, 0x01, 0x00, 0x0b, 0x40, 0x02, 0x92, 0x00, 'V') buf = append(buf, []byte(osVersion)...) buf = append(buf, []byte("ProConOS V")...) buf = append(buf, []byte(version)...)