feat: add masscan discovery backend (#403)
This commit is contained in:
committed by
GitHub
parent
fd0d948c16
commit
c37d584aa2
+4
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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() {}
|
||||
Reference in New Issue
Block a user