Compare commits

..

34 Commits

Author SHA1 Message Date
whiteboxsolutions 82d063ca8b fix: update go.mod
Co-authored-by: Brendan Le Glaunec <brendan.le-glaunec@epitech.eu>
2026-03-14 14:31:18 -07:00
Jake Daynes 561c19581c doc: update port list in readme 2026-03-13 04:24:13 -07:00
Jake Daynes d7366c970b fix: remove whitespace for linter 2026-03-13 04:23:25 -07:00
Jake Daynes beb59543dc fix: update inline error for linter 2026-03-13 04:21:14 -07:00
Jake Daynes e4ec21e135 fix: fix formatting 2026-03-13 04:16:32 -07:00
Jake Daynes f715379a44 fix: add rtsp/rtsps const 2026-03-13 04:15:26 -07:00
Jake Daynes 8d216370d3 fix: clean for linter 2026-03-13 04:12:48 -07:00
Jake Daynes f3eb09812b fix: update probeDescribeHeaders to support tls dialer & continue if bad credentials in attack 2026-03-13 03:58:00 -07:00
Jake Daynes 75e9b8dc50 fix: set attackRoute to use pointer to persist redirect 2026-03-13 03:42:56 -07:00
Jake Daynes d70d774be6 fix: add tlsConfig to RTSP client to skip self-signed certs 2026-03-13 03:29:18 -07:00
Jake Daynes 62ab02acf0 fix: reorder test case for scan test 2026-03-13 02:55:56 -07:00
Jake Daynes d3a51c18c0 fix: revert credAttack since redirect will already have happened 2026-03-13 02:53:46 -07:00
Jake Daynes 4535a38ad8 fix: update rtsp url builder test to use secure flag 2026-03-13 02:38:02 -07:00
Jake Daynes 9195485b99 fix: update masscan test to handle Secure flag 2026-03-13 02:33:33 -07:00
Jake Daynes 4cc5808883 fix: update nmap scan test to handle Secure flag 2026-03-13 02:32:27 -07:00
Jake Daynes 2d96b9ed8d fix: update skip test to handle Secure flag 2026-03-13 02:23:56 -07:00
Jake Daynes 58e81a7c02 fix: update summary test to handle Secure flag 2026-03-13 02:21:59 -07:00
Jake Daynes a3ab13cbf5 fix: update test runner to include Secure flag 2026-03-13 02:20:01 -07:00
Jake Daynes 2bb748e78f fix: update rtsp url tests 2026-03-13 02:19:21 -07:00
Jake Daynes 5c30baa11e update m3u RTSP URL formatter to handle scheme 2026-03-13 02:01:45 -07:00
Jake Daynes 5912ed5283 update RTSPURL formatter to handle scheme 2026-03-13 01:59:23 -07:00
Jake Daynes 6871ee1c09 update skip stream builder to include Secure state 2026-03-13 01:57:35 -07:00
Jake Daynes 4953b16a08 update masscan scan to include Secure state 2026-03-13 01:56:55 -07:00
Jake Daynes 2865295911 update nmap scan to include Secure state 2026-03-13 01:56:19 -07:00
Jake Daynes 5bb2684d9e update function calls to include context 2026-03-13 01:54:48 -07:00
Jake Daynes 2c548c6b68 update credAttack & routeAttack to support redirect 2026-03-13 01:53:57 -07:00
Jake Daynes f89af3bfd0 add handleRedirect method 2026-03-13 01:50:58 -07:00
Jake Daynes f66c0f6d94 update buildRTSPURL to handle secure flag 2026-03-13 01:45:23 -07:00
Jake Daynes c2c9a6e546 add rtsps ports to default list 2026-03-13 01:43:35 -07:00
Jake Daynes 6d52cf0259 add secure bool 2026-03-13 01:43:08 -07:00
dependabot[bot] 1700227483 Bump docker/login-action from 3 to 4 in the all group (#420)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-09 22:26:26 +01:00
Brendan Le Glaunec b335f98330 docs: add instructions on what targets to test against (#418) 2026-03-09 14:54:54 +01:00
Brendan Le Glaunec 2e8343526e fix: command/flags to prevent subcommand being required (#411) 2026-03-09 08:18:15 +01:00
Brendan Le Glaunec 0f26f25cb9 docs: remove config section in favor of wiki, add links to wiki (#412) 2026-03-09 07:53:21 +01:00
17 changed files with 206 additions and 38 deletions
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
if: steps.install-go.outputs.cache-hit != 'true'
- name: Log in to Docker Hub
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
+43
View File
@@ -14,6 +14,49 @@ Clone the repo and install dependencies using Go modules.
go mod download
```
### Test against fake targets
Use the following options when you want reproducible local testing.
#### Testing discovery behavior
Use `scanme.nmap.org` to validate discovery-related behavior.
- `scanme.nmap.org` does not expose RTSP or RTSPS ports.
- Target its open ports (for example `22`, `80`, `9929`, `31337`) to test discovery flow, reporting, and scan handling.
Example command:
```bash
cameradar -t scanme.nmap.org -p 22
```
#### Testing RTSP and attack behavior
Use [RTSPAllTheThings](https://github.com/Ullaakut/RTSPAllTheThings) to test RTSP-specific logic and camera attack flows.
- It supports both basic and digest authentication.
- It behaves like a standards-compliant RTSP camera.
> [!CAUTION]
> It is no longer maintained and has limited camera emulation coverage.
Example command:
```bash
docker run --net=host -p 8554:8554 -e RTSP_USERNAME=admin -e RTSP_PASSWORD=12345 -e RTSP_PORT=8554 -e RTSP_AUTHENTICATION_METHOD=digest ullaakut/rtspatt
```
Many real cameras slightly diverge from strict RTSP behavior. For example, some devices allow `DESCRIBE` without authentication, or return `403` and `404` in an order that differs from strict expectations.
Unfortunately, RTSPATT cannot reproduce those behaviors.
#### Prefer real cameras when possible
The most reliable testing method is running against real cameras and real network conditions.
> [!CAUTION]
> Scan only authorized targets and networks.
## Run tests
```bash
+1 -1
View File
@@ -308,7 +308,7 @@ See [Troubleshooting & FAQ](https://github.com/Ullaakut/cameradar/wiki/Troublesh
`docker run --rm -t --net=host -v /tmp:/tmp ullaakut/cameradar --targets /tmp/test.txt --ports 8554`
> Running cameradar on a subnetwork with custom dictionaries, on ports 554, 5554 and 8554
> Running cameradar on a subnetwork with custom dictionaries, on ports 554, 5554, 8554, 322, and 8322
`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`
+10 -17
View File
@@ -38,18 +38,17 @@ var (
var flags = cmd.Flags{
&cli.StringSliceFlag{
Name: flagTargets,
Usage: "The targets on which to scan for open RTSP streams in a network range format",
Aliases: []string{"t"},
Sources: cli.EnvVars(strcase.ToSNAKE(flagTargets)),
Required: true,
Name: flagTargets,
Usage: "The targets on which to scan for open RTSP streams in a network range format",
Aliases: []string{"t"},
Sources: cli.EnvVars(strcase.ToSNAKE(flagTargets)),
},
&cli.StringSliceFlag{
Name: flagPorts,
Usage: "The ports on which to search for RTSP streams",
Aliases: []string{"p"},
Sources: cli.EnvVars(strcase.ToSNAKE(flagPorts)),
Value: []string{"554", "5554", "8554", "http"},
Value: []string{"554", "5554", "8554", "http", "322", "8322"},
},
&cli.StringFlag{
Name: flagCustomRoutes,
@@ -128,19 +127,13 @@ func realMain() (code int) {
}
}()
scanCommand := &cli.Command{
Name: "scan",
Usage: "Scan targets for RTSP streams",
Flags: flags,
Action: runCameradar,
}
app := &cli.Command{
Name: "Cameradar",
Version: version,
DefaultCommand: scanCommand.Name,
Name: "Cameradar",
Version: version,
Usage: "Scan targets for RTSP streams",
Flags: flags,
Action: runCameradar,
Commands: []*cli.Command{
scanCommand,
{
Name: "version",
Usage: "Print version information",
+20 -6
View File
@@ -224,7 +224,7 @@ func (a Attacker) attackCredentialsForStream(ctx context.Context, target camerad
msg := fmt.Sprintf("credential attempt failed for %s:%d (%s:%s): %v", target.Address.String(), target.Port, username, password, err)
a.reporter.Debug(cameradar.StepAttackCredentials, msg)
return target, nil
continue
}
if ok {
@@ -253,7 +253,7 @@ func (a Attacker) attackRoutesForStream(ctx context.Context, target cameradar.St
if emitProgress {
a.reporter.Progress(cameradar.StepAttackRoutes, cameradar.ProgressTickMessage())
}
ok, err := a.routeAttack(target, dummyRoute)
ok, err := a.routeAttack(ctx, &target, dummyRoute)
if err != nil {
a.reporter.Debug(cameradar.StepAttackRoutes, fmt.Sprintf("route probe failed for %s:%d: %v", target.Address.String(), target.Port, err))
return target, nil
@@ -275,7 +275,7 @@ func (a Attacker) attackRoutesForStream(ctx context.Context, target cameradar.St
if emitProgress {
a.reporter.Progress(cameradar.StepAttackRoutes, cameradar.ProgressTickMessage())
}
ok, err := a.routeAttack(target, route)
ok, err := a.routeAttack(ctx, &target, route)
if err != nil {
a.reporter.Debug(cameradar.StepAttackRoutes, fmt.Sprintf("route attempt failed for %s:%d (%s): %v", target.Address.String(), target.Port, route, err))
return target, nil
@@ -290,18 +290,30 @@ func (a Attacker) attackRoutesForStream(ctx context.Context, target cameradar.St
return target, nil
}
func (a Attacker) routeAttack(stream cameradar.Stream, route string) (bool, error) {
u, urlStr, err := buildRTSPURL(stream, route, stream.Username, stream.Password)
func (a Attacker) routeAttack(ctx context.Context, stream *cameradar.Stream, route string) (bool, error) {
u, urlStr, err := buildRTSPURL(*stream, route, stream.Username, stream.Password)
if err != nil {
return false, fmt.Errorf("building rtsp url: %w", err)
}
code, err := a.describeStatus(u)
code, headers, err := a.probeDescribeHeaders(ctx, u, urlStr)
if err != nil {
return false, fmt.Errorf("performing describe request at %q: %w", urlStr, err)
}
a.reporter.Debug(cameradar.StepAttackRoutes, fmt.Sprintf("DESCRIBE %s RTSP/1.0 > %d", urlStr, code))
if code == base.StatusMovedPermanently {
a.handleRedirect(stream, headers)
u, urlStr, err = buildRTSPURL(*stream, route, stream.Username, stream.Password)
if err == nil {
code, _, err = a.probeDescribeHeaders(ctx, u, urlStr)
if err == nil {
a.reporter.Debug(cameradar.StepAttackRoutes, fmt.Sprintf("DESCRIBE %s RTSP/1.0 (redirect followed) > %d", urlStr, code))
}
}
}
access := code == base.StatusOK || code == base.StatusUnauthorized || code == base.StatusForbidden
return access, nil
}
@@ -314,10 +326,12 @@ func (a Attacker) credAttack(stream cameradar.Stream, username, password string)
code, err := a.describeStatus(u)
if err != nil {
a.reporter.Debug(cameradar.StepAttackCredentials, fmt.Sprintf("Error testing %s:%s -> %v", username, password, err))
return false, fmt.Errorf("performing describe request at %q: %w", urlStr, err)
}
a.reporter.Debug(cameradar.StepAttackCredentials, fmt.Sprintf("DESCRIBE %s RTSP/1.0 > %d", urlStr, code))
return code == base.StatusOK || code == base.StatusNotFound, nil
}
+58 -2
View File
@@ -3,9 +3,11 @@ package attack
import (
"bufio"
"context"
"crypto/tls"
"errors"
"fmt"
"net"
"net/netip"
"net/textproto"
"net/url"
"strconv"
@@ -19,10 +21,16 @@ import (
"github.com/bluenviron/gortsplib/v5/pkg/liberrors"
)
const (
rtsp = "rtsp"
rtsps = "rtsps"
)
func (a Attacker) newRTSPClient(u *base.URL) (*gortsplib.Client, error) {
client := &gortsplib.Client{
ReadTimeout: a.timeout,
WriteTimeout: a.timeout,
TLSConfig: &tls.Config{InsecureSkipVerify: true},
}
client.Scheme = u.Scheme
client.Host = u.Host
@@ -63,7 +71,16 @@ func (a Attacker) describeStatus(u *base.URL) (base.StatusCode, error) {
// which is exactly what we need in order to detect authentication methods.
func (a Attacker) probeDescribeHeaders(ctx context.Context, u *base.URL, urlStr string) (base.StatusCode, base.Header, error) {
dialer := &net.Dialer{Timeout: a.timeout}
conn, err := dialer.DialContext(ctx, "tcp", u.Host)
var conn net.Conn
var err error
if u.Scheme == rtsps {
tlsDialer := &tls.Dialer{NetDialer: dialer, Config: &tls.Config{InsecureSkipVerify: true}}
conn, err = tlsDialer.DialContext(ctx, "tcp", u.Host)
} else {
conn, err = dialer.DialContext(ctx, "tcp", u.Host)
}
if err != nil {
return 0, nil, err
}
@@ -117,6 +134,40 @@ func (a Attacker) probeDescribeHeaders(ctx context.Context, u *base.URL, urlStr
return base.StatusCode(code), headers, nil
}
func (a Attacker) handleRedirect(stream *cameradar.Stream, resHeaders base.Header) {
locations := headerValues(resHeaders, "Location")
if len(locations) == 0 {
return
}
location, err := url.Parse(locations[0])
if err != nil {
return
}
switch location.Scheme {
case rtsps:
stream.Secure = true
case rtsp:
stream.Secure = false
}
if location.Hostname() != "" {
addr, err := netip.ParseAddr(location.Hostname())
if err == nil {
stream.Address = addr
}
}
if location.Port() != "" {
port, err := strconv.Atoi(location.Port())
if err == nil {
if port >= 0 && port <= 65535 {
stream.Port = uint16(port)
}
}
}
}
func authTypeFromHeaders(values base.HeaderValue) cameradar.AuthType {
if len(values) == 0 {
return cameradar.AuthUnknown
@@ -168,8 +219,13 @@ func buildRTSPURL(stream cameradar.Stream, route, username, password string) (*b
host := net.JoinHostPort(stream.Address.String(), strconv.Itoa(int(stream.Port)))
path := "/" + strings.TrimLeft(strings.TrimSpace(route), "/") // Ensure path starts with a single "/"
scheme := rtsp
if stream.Secure {
scheme = rtsps
}
u := &url.URL{
Scheme: "rtsp",
Scheme: scheme,
Host: host,
Path: path,
}
+18 -1
View File
@@ -19,6 +19,7 @@ func TestBuildRTSPURL(t *testing.T) {
route string
username string
password string
secure bool
wantURL string
}{
{
@@ -74,11 +75,27 @@ func TestBuildRTSPURL(t *testing.T) {
username: "user",
wantURL: "rtsp://user:@192.168.0.10:554/stream",
},
{
name: "secure rtsps scheme without credentials",
route: "stream",
secure: true,
wantURL: "rtsps://192.168.0.10:554/stream",
},
{
name: "secure rtsps scheme with credentials",
route: "stream",
username: "admin",
password: "password",
secure: true,
wantURL: "rtsps://admin:password@192.168.0.10:554/stream",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, gotURL, err := buildRTSPURL(stream, test.route, test.username, test.password)
s := stream
s.Secure = test.secure
_, gotURL, err := buildRTSPURL(s, test.route, test.username, test.password)
require.NoError(t, err)
require.Equal(t, test.wantURL, gotURL)
})
+6 -1
View File
@@ -119,5 +119,10 @@ func formatRTSPURL(stream cameradar.Stream) string {
credentials = stream.Username + ":" + stream.Password + "@"
}
return "rtsp://" + credentials + stream.Address.String() + ":" + strconv.FormatUint(uint64(stream.Port), 10) + path
scheme := "rtsp"
if stream.Secure {
scheme = "rtsps"
}
return fmt.Sprintf("%s://%s%s:%s%s", scheme, credentials, stream.Address.String(), strconv.FormatUint(uint64(stream.Port), 10), path)
}
+3
View File
@@ -83,9 +83,12 @@ func runScan(ctx context.Context, runner Runner, reporter Reporter) ([]cameradar
continue
}
isSecure := port.Number == 322 || port.Number == 8322
streams = append(streams, cameradar.Stream{
Address: addr,
Port: uint16(port.Number),
Secure: isSecure,
})
}
}
+5 -4
View File
@@ -53,15 +53,16 @@ func TestRunScan(t *testing.T) {
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: "192.0.2.10", Ports: []masscanlib.Port{{Number: 8554, Status: "open"}, {Number: 8322, 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},
{Address: netip.MustParseAddr("192.0.2.10"), Port: 8554, Secure: false},
{Address: netip.MustParseAddr("192.0.2.10"), Port: 8322, Secure: true},
{Address: netip.MustParseAddr("198.51.100.9"), Port: 554, Secure: false},
},
wantProgress: []string{"Found 2 RTSP streams"},
wantProgress: []string{"Found 3 RTSP streams"},
},
{
name: "returns error when scan fails",
+3
View File
@@ -71,6 +71,8 @@ func runScan(ctx context.Context, nmap Runner, reporter Reporter) ([]cameradar.S
continue
}
isSecure := strings.Contains(port.Service.Name, "rtsps") || strings.Contains(port.Service.Name, "ssl") || port.ID == 322 || port.ID == 8322
for _, address := range host.Addresses {
addr, err := netip.ParseAddr(address.Addr)
if err != nil {
@@ -82,6 +84,7 @@ func runScan(ctx context.Context, nmap Runner, reporter Reporter) ([]cameradar.S
Device: port.Service.Product,
Address: addr,
Port: port.ID,
Secure: isSecure,
})
}
}
+17 -1
View File
@@ -54,6 +54,7 @@ func TestScanner_Scan(t *testing.T) {
Addresses: []nmaplib.Address{{Addr: "192.0.2.10"}, {Addr: "192.0.2.11"}},
Ports: []nmaplib.Port{
openPort(8554, "rtsp-alt", "Model A"),
openPort(322, "rtsps", "Model C"),
},
},
nmaplib.Host{
@@ -68,19 +69,34 @@ func TestScanner_Scan(t *testing.T) {
Device: "Model A",
Address: netip.MustParseAddr("192.0.2.10"),
Port: 8554,
Secure: false,
},
{
Device: "Model A",
Address: netip.MustParseAddr("192.0.2.11"),
Port: 8554,
Secure: false,
},
{
Device: "Model C",
Address: netip.MustParseAddr("192.0.2.10"),
Port: 322,
Secure: true,
},
{
Device: "Model C",
Address: netip.MustParseAddr("192.0.2.11"),
Port: 322,
Secure: true,
},
{
Device: "Model B",
Address: netip.MustParseAddr("198.51.100.9"),
Port: 554,
Secure: false,
},
},
wantProgress: "Found 3 RTSP streams",
wantProgress: "Found 5 RTSP streams",
},
{
name: "returns error when scan fails",
+2
View File
@@ -51,9 +51,11 @@ func buildStreamsFromTargets(ctx context.Context, targets, ports []string) ([]ca
streams := make([]cameradar.Stream, 0, len(resolvedTargets)*len(resolvedPorts))
for _, addr := range resolvedTargets {
for _, port := range resolvedPorts {
isSecure := port == 322 || port == 8322
streams = append(streams, cameradar.Stream{
Address: addr,
Port: port,
Secure: isSecure,
})
}
}
+9 -2
View File
@@ -16,7 +16,7 @@ func TestNew_ExpandsTargetsAndPorts(t *testing.T) {
"192.0.2.15",
"192.0.2.10-11",
}
ports := []string{"554", "8554-8555"}
ports := []string{"554", "322", "8554-8555"}
scanner := skip.New(targets, ports)
@@ -32,7 +32,7 @@ func TestNew_ExpandsTargetsAndPorts(t *testing.T) {
netip.MustParseAddr("192.0.2.11"),
netip.MustParseAddr("192.0.2.15"),
}
portsExpected := []uint16{554, 8554, 8555}
portsExpected := []uint16{554, 322, 8554, 8555}
var want []string
for _, addr := range addrs {
@@ -44,6 +44,13 @@ func TestNew_ExpandsTargetsAndPorts(t *testing.T) {
var got []string
for _, stream := range streams {
got = append(got, stream.Address.String()+":"+strconv.Itoa(int(stream.Port)))
if stream.Port == 322 || stream.Port == 8322 {
assert.True(t, stream.Secure)
} else {
assert.False(t, stream.Secure)
}
}
assert.ElementsMatch(t, want, got)
+6 -1
View File
@@ -126,7 +126,12 @@ func formatRTSPURL(stream cameradar.Stream) string {
credentials = stream.Username + ":" + stream.Password + "@"
}
return fmt.Sprintf("rtsp://%s%s:%d%s", credentials, stream.Address.String(), stream.Port, path)
scheme := "rtsp"
if stream.Secure {
scheme = "rtsps"
}
return fmt.Sprintf("%s://%s%s:%d%s", scheme, credentials, stream.Address.String(), stream.Port, path)
}
func formatAdminPanelURL(stream cameradar.Stream) string {
+2 -1
View File
@@ -46,6 +46,7 @@ func TestFormatSummary(t *testing.T) {
Device: "Model A",
Address: netip.MustParseAddr("10.0.0.1"),
Port: 8554,
Secure: true,
Available: true,
Routes: []string{"stream1", "stream2"},
RouteFound: true,
@@ -73,7 +74,7 @@ func TestFormatSummary(t *testing.T) {
"Authentication: digest",
"Routes: stream1, stream2",
"Credentials: user:pass",
"RTSP URL: rtsp://user:pass@10.0.0.1:8554/stream1",
"RTSP URL: rtsps://user:pass@10.0.0.1:8554/stream1",
"Admin panel: http://10.0.0.1/",
"Admin panel: http://10.0.0.2/",
},
+2
View File
@@ -24,6 +24,8 @@ type Stream struct {
Address netip.Addr `json:"address" validate:"required"`
Port uint16 `json:"port" validate:"required"`
Secure bool `json:"secure"`
CredentialsFound bool `json:"credentials_found"`
RouteFound bool `json:"route_found"`
Available bool `json:"available"`