feat: add masscan discovery backend (#403)

This commit is contained in:
Brendan Le Glaunec
2026-02-28 20:51:47 +01:00
committed by GitHub
parent fd0d948c16
commit c37d584aa2
10 changed files with 359 additions and 6 deletions
+4 -1
View File
@@ -2,7 +2,10 @@ FROM alpine
RUN apk --update add --no-cache nmap \ RUN apk --update add --no-cache nmap \
nmap-nselibs \ nmap-nselibs \
nmap-scripts nmap-scripts \
masscan \
libpcap \
libpcap-dev
WORKDIR /app/cameradar WORKDIR /app/cameradar
+46 -3
View File
@@ -189,12 +189,12 @@ docker run --rm -t --net=host \
### Skip discovery with `--skip-scan` ### Skip discovery with `--skip-scan`
If you already know the RTSP endpoints, you can skip discovery and treat each 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. useful on restricted networks or when you want to attack a known inventory.
Skipping discovery means: 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. - Targets resolve to IP addresses. Hostnames resolve via DNS.
- CIDR blocks and IPv4 ranges expand to every address in the range. - CIDR blocks and IPv4 ranges expand to every address in the range.
- Large ranges create many targets, so use them carefully. - 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 In this example, Cameradar attempts dictionary attacks against
ports 554 and 8554 of `192.168.1.10`. 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 ## Security and responsible use
Cameradar is a penetration testing tool. 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. 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` ### `SCAN_SPEED` / `--scan-speed` / `-s`
This optional variable sets nmap discovery presets for speed or accuracy. This optional variable sets nmap discovery presets for speed or accuracy.
Lower it on slow networks and raise it on fast networks. Lower it on slow networks and raise it on fast networks.
See [nmap timing templates](https://nmap.org/book/man-performance.html). See [nmap timing templates](https://nmap.org/book/man-performance.html).
This option is ignored when `--scanner masscan` is used.
Default value: `4` Default value: `4`
### `SKIP_SCAN` / `--skip-scan` ### `SKIP_SCAN` / `--skip-scan`
@@ -324,7 +363,7 @@ Default value: `2000ms`
This optional variable enables more verbose output. 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` 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` `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 ## License
Copyright 2026 Ullaakut Copyright 2026 Ullaakut
+4
View File
@@ -79,6 +79,7 @@ func runCameradar(ctx context.Context, cmd *cli.Command) error {
routesPath, routesPath,
credsPath, credsPath,
outputPath, outputPath,
cmd.String(flagScanner),
cmd.Int16(flagScanSpeed), cmd.Int16(flagScanSpeed),
cmd.Duration(flagAttackInterval), cmd.Duration(flagAttackInterval),
cmd.Duration(flagTimeout), cmd.Duration(flagTimeout),
@@ -97,6 +98,7 @@ func runCameradar(ctx context.Context, cmd *cli.Command) error {
Targets: targets, Targets: targets,
Ports: ports, Ports: ports,
ScanSpeed: cmd.Int16(flagScanSpeed), ScanSpeed: cmd.Int16(flagScanSpeed),
Scanner: cmd.String(flagScanner),
} }
var scanner cameradar.StreamScanner var scanner cameradar.StreamScanner
scanner, err = scan.New(config, reporter) scanner, err = scan.New(config, reporter)
@@ -141,6 +143,7 @@ func buildStartupOptions(
routesPath string, routesPath string,
credsPath string, credsPath string,
outputPath string, outputPath string,
scanner string,
scanSpeed int16, scanSpeed int16,
attackInterval time.Duration, attackInterval time.Duration,
timeout time.Duration, timeout time.Duration,
@@ -153,6 +156,7 @@ func buildStartupOptions(
"ports: " + strings.Join(ports, ", "), "ports: " + strings.Join(ports, ", "),
"custom-routes: " + fallbackValue(routesPath, "builtin"), "custom-routes: " + fallbackValue(routesPath, "builtin"),
"custom-credentials: " + fallbackValue(credsPath, "builtin"), "custom-credentials: " + fallbackValue(credsPath, "builtin"),
"scanner: " + fallbackValue(scanner, "nmap"),
"scan-speed: " + strconv.FormatInt(int64(scanSpeed), 10), "scan-speed: " + strconv.FormatInt(int64(scanSpeed), 10),
"skip-scan: " + strconv.FormatBool(skipScan), "skip-scan: " + strconv.FormatBool(skipScan),
"attack-interval: " + attackInterval.String(), "attack-interval: " + attackInterval.String(),
+7
View File
@@ -20,6 +20,7 @@ const (
flagPorts = "ports" flagPorts = "ports"
flagCustomRoutes = "custom-routes" flagCustomRoutes = "custom-routes"
flagCustomCredentials = "custom-credentials" flagCustomCredentials = "custom-credentials"
flagScanner = "scanner"
flagScanSpeed = "scan-speed" flagScanSpeed = "scan-speed"
flagAttackInterval = "attack-interval" flagAttackInterval = "attack-interval"
flagTimeout = "timeout" flagTimeout = "timeout"
@@ -62,6 +63,12 @@ var flags = cmd.Flags{
Aliases: []string{"c"}, Aliases: []string{"c"},
Sources: cli.EnvVars(strcase.ToSNAKE(flagCustomCredentials)), 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{ &cli.Int16Flag{
Name: flagScanSpeed, Name: flagScanSpeed,
Usage: "The nmap speed preset to use for scanning (lower is stealthier)", Usage: "The nmap speed preset to use for scanning (lower is stealthier)",
+2 -1
View File
@@ -1,8 +1,9 @@
module github.com/Ullaakut/cameradar/v6 module github.com/Ullaakut/cameradar/v6
go 1.25.0 go 1.25.3
require ( require (
github.com/Ullaakut/masscan v1.0.0
github.com/Ullaakut/nmap/v4 v4.0.0 github.com/Ullaakut/nmap/v4 v4.0.0
github.com/bluenviron/gortsplib/v5 v5.3.0 github.com/bluenviron/gortsplib/v5 v5.3.0
github.com/charmbracelet/bubbles v0.21.0 github.com/charmbracelet/bubbles v0.21.0
+2
View File
@@ -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/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 h1:68vKo2VN8DE9AdN4tnkWnmdhqdbpUFM8OF3Airm7fz8=
github.com/Microsoft/hcsshim v0.11.4/go.mod h1:smjE4dvqPX9Zldna+t5FG3rnoHhaB7QYxPRqGcpAD9w= 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 h1:QwpxX5F+S14ZEvBQKc37xnvpPXcw4vK0rsZkGV4h98s=
github.com/Ullaakut/nmap/v4 v4.0.0/go.mod h1:B+MtOtHdb+jR9bc11BNwZX1QVHOtsDjfKkXMCZtRzbw= github.com/Ullaakut/nmap/v4 v4.0.0/go.mod h1:B+MtOtHdb+jR9bc11BNwZX1QVHOtsDjfKkXMCZtRzbw=
github.com/VictoriaMetrics/metrics v1.40.1 h1:FrF5uJRpIVj9fayWcn8xgiI+FYsKGMslzPuOXjdeyR4= github.com/VictoriaMetrics/metrics v1.40.1 h1:FrF5uJRpIVj9fayWcn8xgiI+FYsKGMslzPuOXjdeyR4=
+24 -1
View File
@@ -1,17 +1,28 @@
package scan package scan
import ( import (
"fmt"
"strings"
"github.com/Ullaakut/cameradar/v6" "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/nmap"
"github.com/Ullaakut/cameradar/v6/internal/scan/skip" "github.com/Ullaakut/cameradar/v6/internal/scan/skip"
) )
// Supported discovery backends.
const (
ScannerNmap = "nmap"
ScannerMasscan = "masscan"
)
// Config configures how Cameradar discovers RTSP streams. // Config configures how Cameradar discovers RTSP streams.
type Config struct { type Config struct {
SkipScan bool SkipScan bool
Targets []string Targets []string
Ports []string Ports []string
ScanSpeed int16 ScanSpeed int16
Scanner string
} }
// Reporter reports scan progress and debug information. // 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 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)
}
} }
+28
View File
@@ -64,3 +64,31 @@ func TestNew_SkipScanPropagatesErrors(t *testing.T) {
require.Error(t, err) require.Error(t, err)
assert.ErrorContains(t, err, "invalid port range") 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)
}
+109
View File
@@ -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)
}
+133
View File
@@ -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() {}