From c37d584aa2d3088d3afc34b10175e2eee3af1ec2 Mon Sep 17 00:00:00 2001 From: Brendan Le Glaunec Date: Sat, 28 Feb 2026 20:51:47 +0100 Subject: [PATCH] feat: add masscan discovery backend (#403) --- Dockerfile | 5 +- README.md | 49 +++++++++- cmd/cameradar/cameradar.go | 4 + cmd/cameradar/main.go | 7 ++ go.mod | 3 +- go.sum | 2 + internal/scan/builder.go | 25 ++++- internal/scan/builder_test.go | 28 ++++++ internal/scan/masscan/scanner.go | 109 +++++++++++++++++++++ internal/scan/masscan/scanner_test.go | 133 ++++++++++++++++++++++++++ 10 files changed, 359 insertions(+), 6 deletions(-) create mode 100644 internal/scan/masscan/scanner.go create mode 100644 internal/scan/masscan/scanner_test.go diff --git a/Dockerfile b/Dockerfile index 0da1a9b..efeda06 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,10 @@ FROM alpine RUN apk --update add --no-cache nmap \ nmap-nselibs \ - nmap-scripts + nmap-scripts \ + masscan \ + libpcap \ + libpcap-dev WORKDIR /app/cameradar diff --git a/README.md b/README.md index 93966fd..e4456fa 100644 --- a/README.md +++ b/README.md @@ -189,12 +189,12 @@ docker run --rm -t --net=host \ ### Skip discovery with `--skip-scan` If you already know the RTSP endpoints, you can skip discovery and treat each -target and port as a stream candidate. This mode does not run nmap and can be +target and port as a stream candidate. This mode does not run discovery and can be useful on restricted networks or when you want to attack a known inventory. Skipping discovery means: -- Cameradar does not run nmap and does not detect device models. +- Cameradar does not run discovery and does not detect device models. - Targets resolve to IP addresses. Hostnames resolve via DNS. - CIDR blocks and IPv4 ranges expand to every address in the range. - Large ranges create many targets, so use them carefully. @@ -212,6 +212,30 @@ docker run --rm -t --net=host \ In this example, Cameradar attempts dictionary attacks against ports 554 and 8554 of `192.168.1.10`. +### Choose the discovery scanner with `--scanner` + +Cameradar supports two discovery backends: + +- `nmap` (default) +- `masscan` + +Use `nmap` when you want more reliable RTSP discovery: it performs service +identification and can better distinguish RTSP from other open ports. + +Use `masscan` when scanning very large networks: it is generally faster and +more efficient at scale, but it does not provide service discovery. + +```bash +docker run --rm -t --net=host \ + ullaakut/cameradar \ + --scanner masscan \ + --ports "554,8554" \ + --targets 192.168.1.0/24 +``` + +> [!WARNING] +> `--scan-speed` only applies to the `nmap` scanner. + ## Security and responsible use Cameradar is a penetration testing tool. @@ -287,12 +311,27 @@ It replaces the default credentials dictionary used for the dictionary attack. If unset, Cameradar uses the built-in credentials dictionary. +### `SCANNER` / `--scanner` + +This optional variable sets the discovery backend. + +* `nmap` includes service discovery and is generally more reliable when you want +to specifically identify RTSP services. +* `masscan` is generally more efficient for large-scale discovery, but it does +not identify services and therefore can be less specific for RTSP. + +Supported values: `nmap`, `masscan` + +Default value: `nmap` + ### `SCAN_SPEED` / `--scan-speed` / `-s` This optional variable sets nmap discovery presets for speed or accuracy. Lower it on slow networks and raise it on fast networks. See [nmap timing templates](https://nmap.org/book/man-performance.html). +This option is ignored when `--scanner masscan` is used. + Default value: `4` ### `SKIP_SCAN` / `--skip-scan` @@ -324,7 +363,7 @@ Default value: `2000ms` This optional variable enables more verbose output. -It outputs nmap results, cURL requests, and more. +It outputs discovery results (`nmap` or `masscan`), cURL requests, and more. Default: `false` @@ -414,6 +453,10 @@ Cameradar supports both basic and digest authentication. `docker run --rm -t --net=host -v /tmp:/tmp ullaakut/cameradar --targets 192.168.0.0/24 --custom-credentials "/tmp/dictionaries/credentials.json" --custom-routes "/tmp/dictionaries/routes" --ports 554,5554,8554` +> Running cameradar with masscan discovery + +`docker run --rm -t --net=host ullaakut/cameradar --scanner masscan --targets 192.168.0.0/24 --ports 554,8554` + ## License Copyright 2026 Ullaakut diff --git a/cmd/cameradar/cameradar.go b/cmd/cameradar/cameradar.go index a5fb766..0ffd74b 100644 --- a/cmd/cameradar/cameradar.go +++ b/cmd/cameradar/cameradar.go @@ -79,6 +79,7 @@ func runCameradar(ctx context.Context, cmd *cli.Command) error { routesPath, credsPath, outputPath, + cmd.String(flagScanner), cmd.Int16(flagScanSpeed), cmd.Duration(flagAttackInterval), cmd.Duration(flagTimeout), @@ -97,6 +98,7 @@ func runCameradar(ctx context.Context, cmd *cli.Command) error { Targets: targets, Ports: ports, ScanSpeed: cmd.Int16(flagScanSpeed), + Scanner: cmd.String(flagScanner), } var scanner cameradar.StreamScanner scanner, err = scan.New(config, reporter) @@ -141,6 +143,7 @@ func buildStartupOptions( routesPath string, credsPath string, outputPath string, + scanner string, scanSpeed int16, attackInterval time.Duration, timeout time.Duration, @@ -153,6 +156,7 @@ func buildStartupOptions( "ports: " + strings.Join(ports, ", "), "custom-routes: " + fallbackValue(routesPath, "builtin"), "custom-credentials: " + fallbackValue(credsPath, "builtin"), + "scanner: " + fallbackValue(scanner, "nmap"), "scan-speed: " + strconv.FormatInt(int64(scanSpeed), 10), "skip-scan: " + strconv.FormatBool(skipScan), "attack-interval: " + attackInterval.String(), diff --git a/cmd/cameradar/main.go b/cmd/cameradar/main.go index b7ec571..0499fcc 100644 --- a/cmd/cameradar/main.go +++ b/cmd/cameradar/main.go @@ -20,6 +20,7 @@ const ( flagPorts = "ports" flagCustomRoutes = "custom-routes" flagCustomCredentials = "custom-credentials" + flagScanner = "scanner" flagScanSpeed = "scan-speed" flagAttackInterval = "attack-interval" flagTimeout = "timeout" @@ -62,6 +63,12 @@ var flags = cmd.Flags{ Aliases: []string{"c"}, Sources: cli.EnvVars(strcase.ToSNAKE(flagCustomCredentials)), }, + &cli.StringFlag{ + Name: flagScanner, + Usage: "Discovery scanner backend: nmap or masscan", + Sources: cli.EnvVars(strcase.ToSNAKE(flagScanner)), + Value: "nmap", + }, &cli.Int16Flag{ Name: flagScanSpeed, Usage: "The nmap speed preset to use for scanning (lower is stealthier)", diff --git a/go.mod b/go.mod index 42c40b7..2f43c40 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,9 @@ module github.com/Ullaakut/cameradar/v6 -go 1.25.0 +go 1.25.3 require ( + github.com/Ullaakut/masscan v1.0.0 github.com/Ullaakut/nmap/v4 v4.0.0 github.com/bluenviron/gortsplib/v5 v5.3.0 github.com/charmbracelet/bubbles v0.21.0 diff --git a/go.sum b/go.sum index 75644cb..ad21e02 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,8 @@ github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migc github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= github.com/Microsoft/hcsshim v0.11.4 h1:68vKo2VN8DE9AdN4tnkWnmdhqdbpUFM8OF3Airm7fz8= github.com/Microsoft/hcsshim v0.11.4/go.mod h1:smjE4dvqPX9Zldna+t5FG3rnoHhaB7QYxPRqGcpAD9w= +github.com/Ullaakut/masscan v1.0.0 h1:+YtpxNcIEaB2lMWNy+oDZF+5pP86S7vSzCKMjW6UDDA= +github.com/Ullaakut/masscan v1.0.0/go.mod h1:2LQUQ88hmdXZ+JqQTx6RaszuZDRIAwjEoUL+sVXCAe8= github.com/Ullaakut/nmap/v4 v4.0.0 h1:QwpxX5F+S14ZEvBQKc37xnvpPXcw4vK0rsZkGV4h98s= github.com/Ullaakut/nmap/v4 v4.0.0/go.mod h1:B+MtOtHdb+jR9bc11BNwZX1QVHOtsDjfKkXMCZtRzbw= github.com/VictoriaMetrics/metrics v1.40.1 h1:FrF5uJRpIVj9fayWcn8xgiI+FYsKGMslzPuOXjdeyR4= diff --git a/internal/scan/builder.go b/internal/scan/builder.go index 8866381..10c1280 100644 --- a/internal/scan/builder.go +++ b/internal/scan/builder.go @@ -1,17 +1,28 @@ package scan import ( + "fmt" + "strings" + "github.com/Ullaakut/cameradar/v6" + "github.com/Ullaakut/cameradar/v6/internal/scan/masscan" "github.com/Ullaakut/cameradar/v6/internal/scan/nmap" "github.com/Ullaakut/cameradar/v6/internal/scan/skip" ) +// Supported discovery backends. +const ( + ScannerNmap = "nmap" + ScannerMasscan = "masscan" +) + // Config configures how Cameradar discovers RTSP streams. type Config struct { SkipScan bool Targets []string Ports []string ScanSpeed int16 + Scanner string } // Reporter reports scan progress and debug information. @@ -31,5 +42,17 @@ func New(config Config, reporter Reporter) (cameradar.StreamScanner, error) { return skip.New(expandedTargets, config.Ports), nil } - return nmap.New(config.ScanSpeed, expandedTargets, config.Ports, reporter) + scanner := strings.ToLower(strings.TrimSpace(config.Scanner)) + if scanner == "" { + scanner = ScannerNmap + } + + switch scanner { + case ScannerNmap: + return nmap.New(config.ScanSpeed, expandedTargets, config.Ports, reporter) + case ScannerMasscan: + return masscan.New(expandedTargets, config.Ports, reporter) + default: + return nil, fmt.Errorf("unsupported scanner %q", scanner) + } } diff --git a/internal/scan/builder_test.go b/internal/scan/builder_test.go index c9eda33..40a1350 100644 --- a/internal/scan/builder_test.go +++ b/internal/scan/builder_test.go @@ -64,3 +64,31 @@ func TestNew_SkipScanPropagatesErrors(t *testing.T) { require.Error(t, err) assert.ErrorContains(t, err, "invalid port range") } + +func TestNew_UnsupportedScanner(t *testing.T) { + config := scan.Config{ + Targets: []string{"192.0.2.1"}, + Ports: []string{"554"}, + Scanner: "unsupported", + } + + _, err := scan.New(config, nil) + require.Error(t, err) + assert.ErrorContains(t, err, "unsupported scanner") +} + +func TestNew_SkipScanIgnoresUnsupportedScanner(t *testing.T) { + config := scan.Config{ + SkipScan: true, + Targets: []string{"192.0.2.1"}, + Ports: []string{"554"}, + Scanner: "unsupported", + } + + scanner, err := scan.New(config, nil) + require.NoError(t, err) + + streams, err := scanner.Scan(t.Context()) + require.NoError(t, err) + assert.Equal(t, []cameradar.Stream{{Address: netip.MustParseAddr("192.0.2.1"), Port: 554}}, streams) +} diff --git a/internal/scan/masscan/scanner.go b/internal/scan/masscan/scanner.go new file mode 100644 index 0000000..9668db5 --- /dev/null +++ b/internal/scan/masscan/scanner.go @@ -0,0 +1,109 @@ +package masscan + +import ( + "context" + "fmt" + "net/netip" + "strings" + + "github.com/Ullaakut/cameradar/v6" + masscanlib "github.com/Ullaakut/masscan" +) + +// Reporter reports scan progress and debug information. +type Reporter interface { + Debug(step cameradar.Step, message string) + Progress(step cameradar.Step, message string) +} + +// Runner is something that can run a masscan scan. +type Runner interface { + Run(ctx context.Context) (*masscanlib.Run, error) +} + +// Scanner scans targets and ports for RTSP streams. +type Scanner struct { + runner Runner + reporter Reporter +} + +// New returns a Scanner configured with the provided targets and ports. +func New(targets, ports []string, reporter Reporter) (*Scanner, error) { + runner, err := masscanlib.NewScanner( + masscanlib.WithTargets(targets...), + masscanlib.WithPorts(ports...), + masscanlib.WithOpenOnly(), + ) + if err != nil { + return nil, fmt.Errorf("creating masscan scanner: %w", err) + } + + return &Scanner{ + runner: runner, + reporter: reporter, + }, nil +} + +// Scan discovers RTSP streams on the configured targets and ports. +func (s *Scanner) Scan(ctx context.Context) ([]cameradar.Stream, error) { + return runScan(ctx, s.runner, s.reporter) +} + +func runScan(ctx context.Context, runner Runner, reporter Reporter) ([]cameradar.Stream, error) { + results, err := runner.Run(ctx) + if err != nil { + return nil, fmt.Errorf("scanning network: %w", err) + } + + for _, warning := range results.Warnings() { + reporter.Debug(cameradar.StepScan, "masscan warning: "+warning) + } + + var streams []cameradar.Stream + for _, host := range results.Hosts { + address := strings.TrimSpace(host.Address) + if address == "" { + reporter.Progress(cameradar.StepScan, "Skipping host with empty address") + continue + } + + addr, err := netip.ParseAddr(address) + if err != nil { + reporter.Progress(cameradar.StepScan, fmt.Sprintf("Skipping invalid address %q: %v", host.Address, err)) + continue + } + + for _, port := range host.Ports { + if port.Status != "open" { + continue + } + + if port.Number <= 0 || port.Number > 65535 { + reporter.Progress(cameradar.StepScan, fmt.Sprintf("Skipping invalid port %d on %s", port.Number, host.Address)) + continue + } + + streams = append(streams, cameradar.Stream{ + Address: addr, + Port: uint16(port.Number), + }) + } + } + + reporter.Progress(cameradar.StepScan, fmt.Sprintf("Found %d RTSP streams", len(streams))) + updateSummary(reporter, streams) + + return streams, nil +} + +type summaryUpdater interface { + UpdateSummary(streams []cameradar.Stream) +} + +func updateSummary(reporter Reporter, streams []cameradar.Stream) { + updater, ok := reporter.(summaryUpdater) + if !ok { + return + } + updater.UpdateSummary(streams) +} diff --git a/internal/scan/masscan/scanner_test.go b/internal/scan/masscan/scanner_test.go new file mode 100644 index 0000000..7f912a6 --- /dev/null +++ b/internal/scan/masscan/scanner_test.go @@ -0,0 +1,133 @@ +package masscan + +import ( + "context" + "errors" + "net/netip" + "sync" + "testing" + + "github.com/Ullaakut/cameradar/v6" + masscanlib "github.com/Ullaakut/masscan" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRunScan(t *testing.T) { + tests := []struct { + name string + result *masscanlib.Run + err error + wantStreams []cameradar.Stream + wantDebug []string + wantProgress []string + wantErrContains string + }{ + { + name: "filters invalid addresses, closed and invalid ports", + result: &masscanlib.Run{ + Hosts: []masscanlib.Host{ + { + Address: "192.0.2.10", + Ports: []masscanlib.Port{ + {Number: 554, Status: "open"}, + {Number: 8554, Status: "closed"}, + {Number: 0, Status: "open"}, + }, + }, + {Address: "not-an-ip", Ports: []masscanlib.Port{{Number: 8554, Status: "open"}}}, + {Address: "", Ports: []masscanlib.Port{{Number: 8554, Status: "open"}}}, + }, + }, + wantStreams: []cameradar.Stream{ + {Address: netip.MustParseAddr("192.0.2.10"), Port: 554}, + }, + wantProgress: []string{ + "Skipping invalid port 0 on 192.0.2.10", + "Skipping invalid address \"not-an-ip\": ParseAddr(\"not-an-ip\"): unable to parse IP", + "Skipping host with empty address", + "Found 1 RTSP streams", + }, + }, + { + name: "collects streams from multiple hosts", + result: &masscanlib.Run{ + Hosts: []masscanlib.Host{ + {Address: "192.0.2.10", Ports: []masscanlib.Port{{Number: 8554, Status: "open"}}}, + {Address: "198.51.100.9", Ports: []masscanlib.Port{{Number: 554, Status: "open"}}}, + }, + }, + wantStreams: []cameradar.Stream{ + {Address: netip.MustParseAddr("192.0.2.10"), Port: 8554}, + {Address: netip.MustParseAddr("198.51.100.9"), Port: 554}, + }, + wantProgress: []string{"Found 2 RTSP streams"}, + }, + { + name: "returns error when scan fails", + err: errors.New("scan failed"), + wantErrContains: "scanning network", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + reporter := &recordingReporter{} + + streams, err := runScan(t.Context(), fakeRunner{result: test.result, err: test.err}, reporter) + + if test.wantErrContains != "" { + require.Error(t, err) + assert.ErrorContains(t, err, test.wantErrContains) + assert.Empty(t, streams) + assert.Empty(t, reporter.progress) + assert.Equal(t, test.wantDebug, reporter.debug) + return + } + + require.NoError(t, err) + assert.Equal(t, test.wantStreams, streams) + assert.Equal(t, test.wantDebug, reporter.debug) + for _, progress := range test.wantProgress { + assert.Contains(t, reporter.progress, progress) + } + }) + } +} + +type fakeRunner struct { + result *masscanlib.Run + err error +} + +func (f fakeRunner) Run(context.Context) (*masscanlib.Run, error) { + return f.result, f.err +} + +type recordingReporter struct { + mu sync.Mutex + debug []string + progress []string +} + +func (r *recordingReporter) Start(cameradar.Step, string) {} + +func (r *recordingReporter) Done(cameradar.Step, string) {} + +func (r *recordingReporter) Progress(_ cameradar.Step, message string) { + r.mu.Lock() + defer r.mu.Unlock() + r.progress = append(r.progress, message) +} + +func (r *recordingReporter) Debug(_ cameradar.Step, message string) { + r.mu.Lock() + defer r.mu.Unlock() + r.debug = append(r.debug, message) +} + +func (r *recordingReporter) Error(cameradar.Step, error) {} + +func (r *recordingReporter) Summary([]cameradar.Stream, error) {} + +func (r *recordingReporter) Close() {}