Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down
174 changes: 174 additions & 0 deletions command/icmp_discover.go
Original file line number Diff line number Diff line change
@@ -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
}
147 changes: 147 additions & 0 deletions command/icmp_discover_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
}
4 changes: 3 additions & 1 deletion command/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion pkg/ip/ip.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions pkg/ip/ip_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading