From a1dfde17c00764415a4e4903612307868c25a9a8 Mon Sep 17 00:00:00 2001 From: v-byte-cpu <65545655+v-byte-cpu@users.noreply.github.com> Date: Wed, 12 Aug 2026 22:29:54 +0400 Subject: [PATCH] feat(icmp): add IPv6 multicast neighbor discovery Add an ICMPv6 discovery subcommand that sends a correlated echo request to a link-local multicast group and emits neighbor-cache compatible results. Select link-local source addresses for link-local multicast targets and filter incoming traffic to matching echo replies. --- README.md | 26 +++- command/icmp_discover.go | 174 ++++++++++++++++++++++ command/icmp_discover_test.go | 147 +++++++++++++++++++ command/root.go | 4 +- pkg/ip/ip.go | 2 +- pkg/ip/ip_test.go | 1 + pkg/scan/icmp/bpf.go | 9 ++ pkg/scan/icmp/discover.go | 201 +++++++++++++++++++++++++ pkg/scan/icmp/discover_bpf_test.go | 24 +++ pkg/scan/icmp/discover_test.go | 228 +++++++++++++++++++++++++++++ 10 files changed, 812 insertions(+), 4 deletions(-) create mode 100644 command/icmp_discover.go create mode 100644 command/icmp_discover_test.go create mode 100644 pkg/scan/icmp/discover.go create mode 100644 pkg/scan/icmp/discover_bpf_test.go create mode 100644 pkg/scan/icmp/discover_test.go diff --git a/README.md b/README.md index 44e122d..6fbed4a 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ The goal of this project is to create the fastest network scanner with clean and ## ✨ Features * **⚡ 30x times faster** than nmap - * **ARP and NDP scans**: Discover IPv4 and IPv6 neighbors on local networks + * **ARP, NDP, and multicast ICMPv6 scans**: Discover IPv4 and IPv6 neighbors on local networks * **Dual-stack scanning**: ICMP, TCP, UDP, and SOCKS5 support IPv4 and IPv6 * **ICMP scan**: Use advanced ICMP scanning techniques to detect live hosts and firewall rules * **TCP SYN scan**: Traditional half-open scan to find open TCP ports @@ -120,9 +120,31 @@ sx ndp --json 'fe80::%en0/120' | tee neighbor.cache Scoped link-local hosts and prefixes are supported. The interface zone is retained in JSON output, for example `fe80::1%en0`. NDP also supports `--live` and `--file` in the same way as ARP and the other IP scanners. +For a fast best-effort IPv6 discovery pass, send one ICMPv6 Echo Request to the link-local all-nodes group (`ff02::1`): + +``` +sudo sx icmp discover --iface en0 --json | tee neighbor.cache +``` + +An explicit link-local multicast group can be supplied as the optional argument: + +``` +sudo sx icmp discover --iface en0 'ff02::1%en0' +``` + +By default, the command uses the first link-local IPv6 address assigned to the interface. If the interface has multiple link-local addresses, select one explicitly with `--srcip`: + +``` +sudo sx icmp discover --iface en0 --srcip 'fe80::1234%en0' +``` + +The source override must be a link-local unicast IPv6 address. Its optional zone must match `--iface`. As with other raw-packet scans, the override does not have to be assigned to the interface. + +The command maps the multicast IPv6 destination to its `33:33:xx:xx:xx:xx` Ethernet address and reports each correlated Echo Reply as the same `ip`, `mac`, and `vendor` JSONL shape used by ARP and NDP. Link-local result addresses retain the interface zone, so the JSON output can be passed directly to TCP or UDP scans as a neighbor cache. This is best-effort discovery: many hosts or firewalls ignore multicast Echo Requests, and an empty result does not mean that the link has no hosts or reveal every multicast membership. + ### TCP scan -Unlike nmap and other scanners that implicitly resolve link-layer addresses before the actual scan, `sx` explicitly uses a **neighbor cache**. The cache is a JSONL file with `ip`, `mac`, and optional `vendor` fields. It can contain IPv4 entries produced by `sx arp` and IPv6 entries produced by `sx ndp`. Higher-level scans read it from stdin by default. +Unlike nmap and other scanners that implicitly resolve link-layer addresses before the actual scan, `sx` explicitly uses a **neighbor cache**. The cache is a JSONL file with `ip`, `mac`, and optional `vendor` fields. It can contain IPv4 entries produced by `sx arp` and IPv6 entries produced by `sx ndp` or `sx icmp discover`. Higher-level scans read it from stdin by default. This also avoids repeating ARP or NDP discovery for every higher-level scan. diff --git a/command/icmp_discover.go b/command/icmp_discover.go new file mode 100644 index 0000000..f4eb8b1 --- /dev/null +++ b/command/icmp_discover.go @@ -0,0 +1,174 @@ +package command + +import ( + "context" + "errors" + "fmt" + "net" + "net/netip" + "os" + "os/signal" + "strings" + "time" + + "github.com/spf13/cobra" + "github.com/v-byte-cpu/sx/command/log" + "github.com/v-byte-cpu/sx/pkg/ip" + "github.com/v-byte-cpu/sx/pkg/scan" + "github.com/v-byte-cpu/sx/pkg/scan/icmp" +) + +const defaultICMPDiscoveryGroup = "ff02::1" + +var ( + errICMPDiscoveryGroup = errors.New("group must be a single IPv6 link-local multicast address") + errICMPDiscoveryInterfaceRequired = errors.New("--iface is required") +) + +type icmpDiscoverCmd struct { + cmd *cobra.Command + opts icmpDiscoverCmdOpts +} + +func newICMPDiscoverCmd() *icmpDiscoverCmd { + c := &icmpDiscoverCmd{} + c.cmd = &cobra.Command{ + Use: "discover [group]", + Example: strings.Join([]string{ + "icmp discover --iface en0", + "icmp discover --iface en0 --srcip fe80::1234", + "icmp discover --iface en0 ff02::1%en0", + "icmp discover --iface en0 --json", + }, "\n"), + Short: "Discover IPv6 neighbors with one multicast echo request", + Long: "Best-effort IPv6 neighbor discovery using one ICMPv6 Echo Request to a link-local multicast group. " + + "Hosts may ignore multicast echo requests, so an empty result does not prove that the link has no hosts.", + Args: cobra.MaximumNArgs(1), + RunE: func(_ *cobra.Command, args []string) error { return c.run(args) }, + } + c.cmd.Flags().BoolVar(&c.opts.json, "json", false, "enable JSON output") + c.cmd.Flags().StringVarP(&c.opts.rawInterface, "iface", "i", "", "set interface to send/receive packets") + c.cmd.Flags().StringVar(&c.opts.rawSrcIP, "srcip", "", "set source IP address for generated packets") + c.cmd.Flags().DurationVar(&c.opts.exitDelay, "exit-delay", time.Second, + "set how long to wait for response packets after sending the request") + return c +} + +func (c *icmpDiscoverCmd) run(args []string) error { + if c.opts.rawInterface == "" { + return errICMPDiscoveryInterfaceRequired + } + group := "" + if len(args) == 1 { + group = args[0] + } + dstPrefix, dstZone, err := parseICMPDiscoveryGroup(group) + if err != nil { + return err + } + if err = validateICMPDiscoveryZone(dstZone, c.opts.rawInterface); err != nil { + return err + } + if err = c.opts.parseRawOptions(); err != nil { + return err + } + if c.opts.scanRange, err = c.opts.getScanRangeForFamily(dstPrefix, dstZone, true); err != nil { + return err + } + if err = validateICMPDiscoverySourceIP(c.opts.scanRange.SrcIP); err != nil { + return err + } + if !validICMPDiscoverySourceMAC(c.opts.scanRange.SrcMAC) { + return errSrcMAC + } + probe, err := icmp.NewDiscoveryProbe() + if err != nil { + return fmt.Errorf("create ICMPv6 discovery probe: %w", err) + } + logger, err := c.opts.getLogger() + if err != nil { + return err + } + + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) + defer cancel() + method := c.opts.newScanMethod(ctx, probe) + return startPacketScanEngine(ctx, newPacketScanConfig( + withPacketScanMethod(method), + withPacketBPFFilter(icmp.DiscoveryBPFFilter), + withPacketEngineConfig(newEngineConfig( + withLogger(logger), + withScanRange(c.opts.scanRange), + withExitDelay(c.opts.exitDelay), + )), + )) +} + +type icmpDiscoverCmdOpts struct { + packetScanCmdOpts + scanRange *scan.Range +} + +func (o *icmpDiscoverCmdOpts) getLogger() (log.Logger, error) { + logger, err := o.packetScanCmdOpts.getLogger(icmp.DiscoveryScanType, os.Stdout) + if err != nil { + return nil, err + } + return log.NewUniqueLogger(logger), nil +} + +func (o *icmpDiscoverCmdOpts) newScanMethod(ctx context.Context, probe icmp.DiscoveryProbe) *icmp.DiscoveryScanMethod { + requests := scan.NewIPRequestGenerator(scan.NewIPGenerator()) + packets := scan.NewPacketGenerator(icmp.NewDiscoveryPacketFiller(probe)) + source := scan.NewPacketSource(requests, packets) + return icmp.NewDiscoveryScanMethod( + source, + scan.NewResultChan(ctx, 1000), + probe, + o.scanRange.SrcIP, + o.scanRange.Interface.Name, + ) +} + +func parseICMPDiscoveryGroup(raw string) (netip.Prefix, string, error) { + if raw == "" { + raw = defaultICMPDiscoveryGroup + } + if strings.Contains(raw, "/") { + return netip.Prefix{}, "", errICMPDiscoveryGroup + } + prefix, zone, err := ip.ParsePrefix(raw) + if err != nil || prefix.Bits() != 128 || !prefix.Addr().IsLinkLocalMulticast() { + if err != nil { + return netip.Prefix{}, "", fmt.Errorf("%w: %v", errICMPDiscoveryGroup, err) + } + return netip.Prefix{}, "", errICMPDiscoveryGroup + } + return prefix, zone, nil +} + +func validateICMPDiscoveryZone(zone, interfaceName string) error { + if zone != "" && zone != interfaceName { + return errSrcInterface + } + return nil +} + +func validateICMPDiscoverySourceIP(source netip.Addr) error { + if !source.Is6() || !source.IsLinkLocalUnicast() { + return errSrcIP + } + return nil +} + +func validICMPDiscoverySourceMAC(mac net.HardwareAddr) bool { + if len(mac) != 6 || mac[0]&1 != 0 { + return false + } + for _, b := range mac { + if b != 0 { + return true + } + } + return false +} diff --git a/command/icmp_discover_test.go b/command/icmp_discover_test.go new file mode 100644 index 0000000..e717b04 --- /dev/null +++ b/command/icmp_discover_test.go @@ -0,0 +1,147 @@ +package command + +import ( + "net/netip" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestRootRegistersICMPDiscover(t *testing.T) { + t.Parallel() + + cmd, _, err := newRootCmd("test").Find([]string{"icmp", "discover"}) + + require.NoError(t, err) + require.Equal(t, "discover", cmd.Name()) +} + +func TestParseICMPDiscoveryGroup(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + expectedPrefix netip.Prefix + expectedZone string + }{ + { + name: "DefaultAllNodes", + expectedPrefix: netip.MustParsePrefix("ff02::1/128"), + }, + { + name: "ScopedGroup", + input: "ff02::fb%en0", + expectedPrefix: netip.MustParsePrefix("ff02::fb/128"), + expectedZone: "en0", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + prefix, zone, err := parseICMPDiscoveryGroup(tt.input) + + require.NoError(t, err) + require.Equal(t, tt.expectedPrefix, prefix) + require.Equal(t, tt.expectedZone, zone) + }) + } +} + +func TestParseICMPDiscoveryGroupRejectsInvalidTargets(t *testing.T) { + t.Parallel() + + for _, target := range []string{ + "192.0.2.1", + "fe80::1", + "ff05::1", + "ff02::1/128", + } { + t.Run(target, func(t *testing.T) { + t.Parallel() + + _, _, err := parseICMPDiscoveryGroup(target) + + require.ErrorIs(t, err, errICMPDiscoveryGroup) + }) + } +} + +func TestICMPDiscoverCmdFlags(t *testing.T) { + t.Parallel() + + c := newICMPDiscoverCmd() + + require.NotNil(t, c.cmd.Flags().Lookup("iface")) + require.NotNil(t, c.cmd.Flags().Lookup("json")) + require.NotNil(t, c.cmd.Flags().Lookup("exit-delay")) + require.NotNil(t, c.cmd.Flags().Lookup("srcip")) + require.Nil(t, c.cmd.Flags().Lookup("srcmac")) + require.Nil(t, c.cmd.Flags().Lookup("rate")) + require.Equal(t, time.Second, c.opts.exitDelay) + + require.NoError(t, c.cmd.ParseFlags([]string{"--srcip", "fe80::1234%en0"})) + require.Equal(t, "fe80::1234%en0", c.opts.rawSrcIP) +} + +func TestICMPDiscoverCmdRequiresInterface(t *testing.T) { + t.Parallel() + + c := newICMPDiscoverCmd() + err := c.cmd.RunE(c.cmd, nil) + + require.ErrorIs(t, err, errICMPDiscoveryInterfaceRequired) +} + +func TestValidateICMPDiscoveryZone(t *testing.T) { + t.Parallel() + + require.NoError(t, validateICMPDiscoveryZone("", "en0")) + require.NoError(t, validateICMPDiscoveryZone("en0", "en0")) + require.ErrorIs(t, validateICMPDiscoveryZone("en1", "en0"), errSrcInterface) +} + +func TestICMPDiscoverCmdRejectsMismatchedScope(t *testing.T) { + t.Parallel() + + c := newICMPDiscoverCmd() + require.NoError(t, c.cmd.ParseFlags([]string{"--iface", "en0"})) + + err := c.cmd.RunE(c.cmd, []string{"ff02::1%en1"}) + + require.ErrorIs(t, err, errSrcInterface) +} + +func TestValidateICMPDiscoverySourceIP(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + source string + wantErr bool + }{ + {name: "LinkLocal", source: "fe80::1234"}, + {name: "ScopedLinkLocal", source: "fe80::1234%en0"}, + {name: "IPv4", source: "192.0.2.1", wantErr: true}, + {name: "GlobalIPv6", source: "2001:db8::1", wantErr: true}, + {name: "UnspecifiedIPv6", source: "::", wantErr: true}, + {name: "MulticastIPv6", source: "ff02::1", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := validateICMPDiscoverySourceIP(netip.MustParseAddr(tt.source)) + + if tt.wantErr { + require.ErrorIs(t, err, errSrcIP) + return + } + require.NoError(t, err) + }) + } +} diff --git a/command/root.go b/command/root.go index 715ac6e..26da08f 100644 --- a/command/root.go +++ b/command/root.go @@ -35,11 +35,13 @@ func newRootCmd(version string) *cobra.Command { newTCPNULLCmd().cmd, newTCPXmasCmd().cmd, ) + icmpCmd := newICMPCmd().cmd + icmpCmd.AddCommand(newICMPDiscoverCmd().cmd) cmd.AddCommand( newARPCmd().cmd, newNDPCmd().cmd, - newICMPCmd().cmd, + icmpCmd, newUDPCmd().cmd, tcpCmd, newSocksCmd().cmd, diff --git a/pkg/ip/ip.go b/pkg/ip/ip.go index c99c67a..eddd843 100644 --- a/pkg/ip/ip.go +++ b/pkg/ip/ip.go @@ -64,7 +64,7 @@ func GetInterfaceIP(iface *net.Interface, target netip.Addr) (netip.Addr, error) func selectInterfaceIP(addresses []netip.Prefix, target netip.Addr) netip.Addr { wantIPv4 := target.Is4() - wantLinkLocal := target.Is6() && target.IsLinkLocalUnicast() + wantLinkLocal := target.Is6() && (target.IsLinkLocalUnicast() || target.IsLinkLocalMulticast()) for _, prefix := range addresses { addr := prefix.Addr().Unmap() if addr.Is4() != wantIPv4 { diff --git a/pkg/ip/ip_test.go b/pkg/ip/ip_test.go index 5847d18..a56ee8c 100644 --- a/pkg/ip/ip_test.go +++ b/pkg/ip/ip_test.go @@ -80,6 +80,7 @@ func TestSelectInterfaceIPByAddressFamilyAndScope(t *testing.T) { {name: "IPv4", target: netip.MustParseAddr("198.51.100.1"), expected: netip.MustParseAddr("192.0.2.2")}, {name: "IPv6Global", target: netip.MustParseAddr("2001:db8:1::1"), expected: netip.MustParseAddr("2001:db8::2")}, {name: "IPv6LinkLocal", target: netip.MustParseAddr("fe80::1"), expected: netip.MustParseAddr("fe80::2")}, + {name: "IPv6LinkLocalMulticast", target: netip.MustParseAddr("ff02::1"), expected: netip.MustParseAddr("fe80::2")}, } for _, tt := range tests { diff --git a/pkg/scan/icmp/bpf.go b/pkg/scan/icmp/bpf.go index 3022b5a..081fb2b 100644 --- a/pkg/scan/icmp/bpf.go +++ b/pkg/scan/icmp/bpf.go @@ -1,6 +1,7 @@ package icmp import ( + "fmt" "strings" "github.com/v-byte-cpu/sx/pkg/scan" @@ -28,3 +29,11 @@ func BPFFilter(r *scan.Range) (filter string, maxPacketLength int) { } return sb.String(), MaxPacketLength } + +func DiscoveryBPFFilter(r *scan.Range) (string, int) { + filter := "icmp6 and icmp6[0] == 129 and icmp6[1] == 0" + if r != nil && r.SrcIP.Is6() { + filter = fmt.Sprintf("%s and ip6 dst host %s", filter, r.SrcIP.WithZone("")) + } + return filter, MaxPacketLength +} diff --git a/pkg/scan/icmp/discover.go b/pkg/scan/icmp/discover.go new file mode 100644 index 0000000..4badfb2 --- /dev/null +++ b/pkg/scan/icmp/discover.go @@ -0,0 +1,201 @@ +package icmp + +import ( + "bytes" + cryptorand "crypto/rand" + "encoding/binary" + "errors" + "net" + "net/netip" + + "github.com/google/gopacket" + "github.com/google/gopacket/layers" + "github.com/google/gopacket/macs" + "github.com/v-byte-cpu/sx/pkg/scan" + "github.com/v-byte-cpu/sx/pkg/scan/neighbor" +) + +const DiscoveryScanType = "icmpdiscover" + +const discoveryNonceSize = 16 + +var ( + errDiscoveryIPv6Addresses = errors.New("ICMPv6 discovery requires a link-local source and link-local multicast destination") + errDiscoverySourceMAC = errors.New("ICMPv6 discovery requires a unicast Ethernet source MAC") +) + +// DiscoveryProbe identifies one multicast echo request and correlates its replies. +type DiscoveryProbe struct { + Identifier uint16 + Sequence uint16 + Nonce [discoveryNonceSize]byte +} + +// NewDiscoveryProbe creates the correlation values for one discovery request. +func NewDiscoveryProbe() (DiscoveryProbe, error) { + var random [2 + discoveryNonceSize]byte + if _, err := cryptorand.Read(random[:]); err != nil { + return DiscoveryProbe{}, err + } + return DiscoveryProbe{ + Identifier: binary.BigEndian.Uint16(random[:2]), + Sequence: 1, + Nonce: [discoveryNonceSize]byte(random[2:]), + }, nil +} + +// DiscoveryPacketFiller builds the single ICMPv6 multicast echo request. +type DiscoveryPacketFiller struct { + probe DiscoveryProbe +} + +var _ scan.PacketFiller = (*DiscoveryPacketFiller)(nil) + +func NewDiscoveryPacketFiller(probe DiscoveryProbe) *DiscoveryPacketFiller { + return &DiscoveryPacketFiller{probe: probe} +} + +func (f *DiscoveryPacketFiller) Fill(packet gopacket.SerializeBuffer, request *scan.Request) error { + if !request.SrcIP.Is6() || !request.SrcIP.IsLinkLocalUnicast() || + !request.DstIP.Is6() || !request.DstIP.IsLinkLocalMulticast() { + return errDiscoveryIPv6Addresses + } + if !validUnicastMAC(request.SrcMAC) { + return errDiscoverySourceMAC + } + + destination := request.DstIP.WithZone("").As16() + ethernet := &layers.Ethernet{ + SrcMAC: request.SrcMAC, + DstMAC: net.HardwareAddr{0x33, 0x33, destination[12], destination[13], destination[14], destination[15]}, + EthernetType: layers.EthernetTypeIPv6, + } + ipv6 := &layers.IPv6{ + Version: 6, + HopLimit: 1, + NextHeader: layers.IPProtocolICMPv6, + SrcIP: net.IP(request.SrcIP.WithZone("").AsSlice()), + DstIP: net.IP(destination[:]), + } + icmpv6 := &layers.ICMPv6{ + TypeCode: layers.CreateICMPv6TypeCode(layers.ICMPv6TypeEchoRequest, 0), + } + if err := icmpv6.SetNetworkLayerForChecksum(ipv6); err != nil { + return err + } + echo := &layers.ICMPv6Echo{ + Identifier: f.probe.Identifier, + SeqNumber: f.probe.Sequence, + } + return gopacket.SerializeLayers( + packet, + gopacket.SerializeOptions{FixLengths: true, ComputeChecksums: true}, + ethernet, + ipv6, + icmpv6, + echo, + gopacket.Payload(f.probe.Nonce[:]), + ) +} + +func validUnicastMAC(mac net.HardwareAddr) bool { + if len(mac) != 6 || mac[0]&1 != 0 { + return false + } + for _, b := range mac { + if b != 0 { + return true + } + } + return false +} + +// DiscoveryScanMethod sends a discovery request and emits correlated IPv6 neighbors. +type DiscoveryScanMethod struct { + scan.PacketSource + parser *gopacket.DecodingLayerParser + results scan.ResultChan + probe DiscoveryProbe + localIP netip.Addr + zone string + + decoded []gopacket.LayerType + eth layers.Ethernet + ipv6 layers.IPv6 + icmpv6 layers.ICMPv6 + echo layers.ICMPv6Echo +} + +var _ scan.PacketMethod = (*DiscoveryScanMethod)(nil) + +func NewDiscoveryScanMethod( + source scan.PacketSource, + results scan.ResultChan, + probe DiscoveryProbe, + localIP netip.Addr, + zone string, +) *DiscoveryScanMethod { + method := &DiscoveryScanMethod{ + PacketSource: source, + results: results, + probe: probe, + localIP: localIP.WithZone(""), + zone: zone, + } + method.parser = gopacket.NewDecodingLayerParser( + layers.LayerTypeEthernet, + &method.eth, + &method.ipv6, + &method.icmpv6, + &method.echo, + ) + method.parser.IgnoreUnsupported = true + return method +} + +func (m *DiscoveryScanMethod) Results() <-chan scan.Result { + return m.results.Chan() +} + +func (m *DiscoveryScanMethod) ProcessPacketData(data []byte, _ *gopacket.CaptureInfo) error { + if err := m.parser.DecodeLayers(data, &m.decoded); err != nil { + return err + } + if !m.correlatedReply() { + return nil + } + + address, _ := netip.AddrFromSlice(m.ipv6.SrcIP) + if address.IsLinkLocalUnicast() && m.zone != "" { + address = address.WithZone(m.zone) + } + mac := m.eth.SrcMAC + var prefix [3]byte + copy(prefix[:], mac) + m.results.Put(&neighbor.ScanResult{ + IP: address.String(), + MAC: mac.String(), + Vendor: macs.ValidMACPrefixMap[prefix], + }) + return nil +} + +func (m *DiscoveryScanMethod) correlatedReply() bool { + if len(m.decoded) != 4 || + m.icmpv6.TypeCode.Type() != layers.ICMPv6TypeEchoReply || + m.icmpv6.TypeCode.Code() != 0 || + m.echo.Identifier != m.probe.Identifier || + m.echo.SeqNumber != m.probe.Sequence || + len(m.icmpv6.Payload) != 4+discoveryNonceSize || + !bytes.Equal(m.icmpv6.Payload[4:], m.probe.Nonce[:]) || + !validUnicastMAC(m.eth.SrcMAC) { + return false + } + + destination, ok := netip.AddrFromSlice(m.ipv6.DstIP) + if !ok || destination != m.localIP { + return false + } + source, ok := netip.AddrFromSlice(m.ipv6.SrcIP) + return ok && source.Is6() && !source.IsMulticast() && !source.IsUnspecified() +} diff --git a/pkg/scan/icmp/discover_bpf_test.go b/pkg/scan/icmp/discover_bpf_test.go new file mode 100644 index 0000000..c8f7d5f --- /dev/null +++ b/pkg/scan/icmp/discover_bpf_test.go @@ -0,0 +1,24 @@ +package icmp + +import ( + "net/netip" + "testing" + + "github.com/google/gopacket/layers" + "github.com/google/gopacket/pcap" + "github.com/stretchr/testify/require" + "github.com/v-byte-cpu/sx/pkg/scan" +) + +func TestDiscoveryBPFFilter(t *testing.T) { + t.Parallel() + + filter, maxPacketLength := DiscoveryBPFFilter(&scan.Range{ + SrcIP: netip.MustParseAddr("fe80::2"), + }) + + require.Equal(t, "icmp6 and icmp6[0] == 129 and icmp6[1] == 0 and ip6 dst host fe80::2", filter) + require.Equal(t, MaxPacketLength, maxPacketLength) + _, err := pcap.CompileBPFFilter(layers.LinkTypeEthernet, maxPacketLength, filter) + require.NoError(t, err) +} diff --git a/pkg/scan/icmp/discover_test.go b/pkg/scan/icmp/discover_test.go new file mode 100644 index 0000000..0496a34 --- /dev/null +++ b/pkg/scan/icmp/discover_test.go @@ -0,0 +1,228 @@ +package icmp + +import ( + "context" + "net" + "net/netip" + "testing" + + "github.com/google/gopacket" + "github.com/google/gopacket/layers" + "github.com/stretchr/testify/require" + "github.com/v-byte-cpu/sx/pkg/scan" + "github.com/v-byte-cpu/sx/pkg/scan/neighbor" +) + +func TestDiscoveryPacketFillerBuildsMulticastEchoRequest(t *testing.T) { + t.Parallel() + + probe := DiscoveryProbe{ + Identifier: 0x1234, + Sequence: 1, + Nonce: [discoveryNonceSize]byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}, + } + packet := gopacket.NewSerializeBuffer() + err := NewDiscoveryPacketFiller(probe).Fill(packet, &scan.Request{ + SrcIP: netip.MustParseAddr("fe80::2"), + DstIP: netip.MustParseAddr("ff02::1234:5678"), + SrcMAC: net.HardwareAddr{0x02, 0, 0, 0, 0, 2}, + }) + require.NoError(t, err) + + decoded := gopacket.NewPacket(packet.Bytes(), layers.LayerTypeEthernet, gopacket.Default) + eth, ok := decoded.Layer(layers.LayerTypeEthernet).(*layers.Ethernet) + require.True(t, ok) + require.Equal(t, net.HardwareAddr{0x33, 0x33, 0x12, 0x34, 0x56, 0x78}, eth.DstMAC) + require.Equal(t, layers.EthernetTypeIPv6, eth.EthernetType) + + ipv6, ok := decoded.Layer(layers.LayerTypeIPv6).(*layers.IPv6) + require.True(t, ok) + require.Equal(t, uint8(1), ipv6.HopLimit) + require.Equal(t, layers.IPProtocolICMPv6, ipv6.NextHeader) + require.Equal(t, net.ParseIP("fe80::2"), ipv6.SrcIP) + require.Equal(t, net.ParseIP("ff02::1234:5678"), ipv6.DstIP) + + icmpv6, ok := decoded.Layer(layers.LayerTypeICMPv6).(*layers.ICMPv6) + require.True(t, ok) + require.Equal(t, uint8(layers.ICMPv6TypeEchoRequest), icmpv6.TypeCode.Type()) + require.Zero(t, icmpv6.TypeCode.Code()) + + echo, ok := decoded.Layer(layers.LayerTypeICMPv6Echo).(*layers.ICMPv6Echo) + require.True(t, ok) + require.Equal(t, probe.Identifier, echo.Identifier) + require.Equal(t, probe.Sequence, echo.SeqNumber) + require.Equal(t, probe.Nonce[:], icmpv6.Payload[4:]) +} + +func TestDiscoveryPacketFillerRejectsInvalidLinkData(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + request *scan.Request + err error + }{ + { + name: "GlobalSourceIP", + request: &scan.Request{ + SrcIP: netip.MustParseAddr("2001:db8::2"), DstIP: netip.MustParseAddr("ff02::1"), + SrcMAC: net.HardwareAddr{0x02, 0, 0, 0, 0, 2}, + }, + err: errDiscoveryIPv6Addresses, + }, + { + name: "UnicastDestinationIP", + request: &scan.Request{ + SrcIP: netip.MustParseAddr("fe80::2"), DstIP: netip.MustParseAddr("fe80::1"), + SrcMAC: net.HardwareAddr{0x02, 0, 0, 0, 0, 2}, + }, + err: errDiscoveryIPv6Addresses, + }, + { + name: "MulticastSourceMAC", + request: &scan.Request{ + SrcIP: netip.MustParseAddr("fe80::2"), DstIP: netip.MustParseAddr("ff02::1"), + SrcMAC: net.HardwareAddr{0x33, 0x33, 0, 0, 0, 2}, + }, + err: errDiscoverySourceMAC, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := NewDiscoveryPacketFiller(testDiscoveryProbe()).Fill(gopacket.NewSerializeBuffer(), tt.request) + + require.ErrorIs(t, err, tt.err) + }) + } +} + +func TestDiscoveryScanMethodEmitsCorrelatedNeighbor(t *testing.T) { + t.Parallel() + + probe := testDiscoveryProbe() + packet := serializeDiscoveryReply(t, probe, "fe80::1", "fe80::2", net.HardwareAddr{0x02, 0, 0, 0, 0, 1}) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + method := NewDiscoveryScanMethod(nil, scan.NewResultChan(ctx, 1), probe, netip.MustParseAddr("fe80::2"), "en0") + + require.NoError(t, method.ProcessPacketData(packet, &gopacket.CaptureInfo{})) + + result := (<-method.Results()).(*neighbor.ScanResult) + require.Equal(t, "fe80::1%en0", result.IP) + require.Equal(t, "02:00:00:00:00:01", result.MAC) +} + +func TestDiscoveryScanMethodRejectsUncorrelatedReplies(t *testing.T) { + t.Parallel() + + probe := testDiscoveryProbe() + tests := []struct { + name string + probe DiscoveryProbe + sourceIP string + destIP string + sourceMAC net.HardwareAddr + }{ + { + name: "WrongIdentifier", + probe: DiscoveryProbe{Identifier: probe.Identifier + 1, Sequence: probe.Sequence, Nonce: probe.Nonce}, + sourceIP: "fe80::1", + destIP: "fe80::2", + sourceMAC: net.HardwareAddr{0x02, 0, 0, 0, 0, 1}, + }, + { + name: "WrongNonce", + probe: DiscoveryProbe{Identifier: probe.Identifier, Sequence: probe.Sequence, Nonce: [discoveryNonceSize]byte{0xff}}, + sourceIP: "fe80::1", + destIP: "fe80::2", + sourceMAC: net.HardwareAddr{0x02, 0, 0, 0, 0, 1}, + }, + { + name: "WrongDestination", + probe: probe, + sourceIP: "fe80::1", + destIP: "fe80::3", + sourceMAC: net.HardwareAddr{0x02, 0, 0, 0, 0, 1}, + }, + { + name: "MulticastSourceIP", + probe: probe, + sourceIP: "ff02::1", + destIP: "fe80::2", + sourceMAC: net.HardwareAddr{0x02, 0, 0, 0, 0, 1}, + }, + { + name: "MulticastSourceMAC", + probe: probe, + sourceIP: "fe80::1", + destIP: "fe80::2", + sourceMAC: net.HardwareAddr{0x33, 0x33, 0, 0, 0, 1}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + packet := serializeDiscoveryReply(t, tt.probe, tt.sourceIP, tt.destIP, tt.sourceMAC) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + method := NewDiscoveryScanMethod(nil, scan.NewResultChan(ctx, 1), probe, netip.MustParseAddr("fe80::2"), "en0") + + require.NoError(t, method.ProcessPacketData(packet, &gopacket.CaptureInfo{})) + select { + case result := <-method.Results(): + require.Fail(t, "unexpected result", "%v", result) + default: + } + }) + } +} + +func testDiscoveryProbe() DiscoveryProbe { + return DiscoveryProbe{ + Identifier: 0x1234, + Sequence: 1, + Nonce: [discoveryNonceSize]byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}, + } +} + +func serializeDiscoveryReply( + t *testing.T, + probe DiscoveryProbe, + sourceIP string, + destinationIP string, + sourceMAC net.HardwareAddr, +) []byte { + t.Helper() + + packet := gopacket.NewSerializeBuffer() + ethernet := &layers.Ethernet{ + SrcMAC: sourceMAC, + DstMAC: net.HardwareAddr{0x02, 0, 0, 0, 0, 2}, + EthernetType: layers.EthernetTypeIPv6, + } + ipv6 := &layers.IPv6{ + Version: 6, + HopLimit: 64, + NextHeader: layers.IPProtocolICMPv6, + SrcIP: net.ParseIP(sourceIP), + DstIP: net.ParseIP(destinationIP), + } + icmpv6 := &layers.ICMPv6{TypeCode: layers.CreateICMPv6TypeCode(layers.ICMPv6TypeEchoReply, 0)} + require.NoError(t, icmpv6.SetNetworkLayerForChecksum(ipv6)) + echo := &layers.ICMPv6Echo{Identifier: probe.Identifier, SeqNumber: probe.Sequence} + require.NoError(t, gopacket.SerializeLayers( + packet, + gopacket.SerializeOptions{FixLengths: true, ComputeChecksums: true}, + ethernet, + ipv6, + icmpv6, + echo, + gopacket.Payload(probe.Nonce[:]), + )) + return packet.Bytes() +}