feat: v6 rewrite

This commit is contained in:
Brendan Le Glaunec
2025-07-08 17:36:48 +02:00
parent f586940b6c
commit e81eeb0c4d
81 changed files with 7430 additions and 4099 deletions
+465
View File
@@ -0,0 +1,465 @@
package attack
import (
"context"
"errors"
"fmt"
"time"
"github.com/Ullaakut/cameradar/v6"
"github.com/bluenviron/gortsplib/v5/pkg/base"
"github.com/bluenviron/gortsplib/v5/pkg/liberrors"
)
// Route that should never be a constructor default.
const dummyRoute = "/0x8b6c42"
// Dictionary provides dictionaries for routes, usernames and passwords.
type Dictionary interface {
Routes() []string
Usernames() []string
Passwords() []string
}
// Reporter reports progress and results of the attacks.
type Reporter interface {
Start(step cameradar.Step, message string)
Done(step cameradar.Step, message string)
Progress(step cameradar.Step, message string)
Error(step cameradar.Step, err error)
Debug(step cameradar.Step, message string)
}
// Attacker attempts to discover routes and credentials for RTSP streams.
type Attacker struct {
dictionary Dictionary
reporter Reporter
attackInterval time.Duration
timeout time.Duration
}
// New builds an Attacker with the provided dependencies.
func New(dict Dictionary, attackInterval, timeout time.Duration, reporter Reporter) (Attacker, error) {
if dict == nil {
return Attacker{}, errors.New("dictionary is required")
}
return Attacker{
dictionary: dict,
attackInterval: attackInterval,
timeout: timeout,
reporter: reporter,
}, nil
}
// Attack attacks the given targets and returns the accessed streams.
func (a Attacker) Attack(ctx context.Context, targets []cameradar.Stream) ([]cameradar.Stream, error) {
if len(targets) == 0 {
return nil, errors.New("no stream found")
}
streams, err := a.attackRoutesPhase(ctx, targets)
if err != nil {
return streams, err
}
streams, err = a.detectAuthPhase(ctx, streams)
if err != nil {
return streams, err
}
streams, err = a.attackCredentialsPhase(ctx, streams)
if err != nil {
return streams, err
}
streams, err = a.validateStreamsPhase(ctx, streams)
if err != nil {
return streams, err
}
// Some cameras run an inaccurate version of the RTSP protocol which prioritizes 401 over 404.
// For these cameras, running another route attack solves the problem.
if !needsReattack(streams) {
return streams, nil
}
streams, err = a.reattackRoutes(ctx, streams)
if err != nil {
return streams, err
}
return streams, nil
}
func (a Attacker) attackRoutesPhase(ctx context.Context, targets []cameradar.Stream) ([]cameradar.Stream, error) {
a.reporter.Start(cameradar.StepAttackRoutes, "Attacking RTSP routes")
routeAttempts := (len(a.dictionary.Routes()) + 1) * len(targets)
if routeAttempts > 0 {
a.reporter.Progress(cameradar.StepAttackRoutes, cameradar.ProgressTotalMessage(routeAttempts))
}
streams, err := runParallel(ctx, targets, func(ctx context.Context, target cameradar.Stream) (cameradar.Stream, error) {
return a.attackRoutesForStream(ctx, target, true)
})
if err != nil {
a.reporter.Error(cameradar.StepAttackRoutes, err)
return streams, fmt.Errorf("attacking routes: %w", err)
}
updateSummary(a.reporter, streams)
a.reporter.Done(cameradar.StepAttackRoutes, "Finished route attacks")
return streams, nil
}
func (a Attacker) detectAuthPhase(ctx context.Context, streams []cameradar.Stream) ([]cameradar.Stream, error) {
a.reporter.Start(cameradar.StepDetectAuth, "Detecting authentication methods")
if len(streams) > 0 {
a.reporter.Progress(cameradar.StepDetectAuth, cameradar.ProgressTotalMessage(len(streams)))
}
streams, err := a.detectAuthMethods(ctx, streams)
if err != nil {
a.reporter.Error(cameradar.StepDetectAuth, err)
return streams, fmt.Errorf("detecting authentication methods: %w", err)
}
updateSummary(a.reporter, streams)
a.reporter.Done(cameradar.StepDetectAuth, "Authentication detection complete")
return streams, nil
}
func (a Attacker) attackCredentialsPhase(ctx context.Context, streams []cameradar.Stream) ([]cameradar.Stream, error) {
a.reporter.Start(cameradar.StepAttackCredentials, "Attacking credentials")
credentialsAttempts := len(streams) * len(a.dictionary.Usernames()) * len(a.dictionary.Passwords())
if credentialsAttempts > 0 {
a.reporter.Progress(cameradar.StepAttackCredentials, cameradar.ProgressTotalMessage(credentialsAttempts))
}
streams, err := runParallel(ctx, streams, a.attackCredentialsForStream)
if err != nil {
a.reporter.Error(cameradar.StepAttackCredentials, err)
return streams, fmt.Errorf("attacking credentials: %w", err)
}
updateSummary(a.reporter, streams)
a.reporter.Done(cameradar.StepAttackCredentials, "Credential attacks complete")
return streams, nil
}
func (a Attacker) validateStreamsPhase(ctx context.Context, streams []cameradar.Stream) ([]cameradar.Stream, error) {
a.reporter.Start(cameradar.StepValidateStreams, "Validating streams")
if len(streams) > 0 {
a.reporter.Progress(cameradar.StepValidateStreams, cameradar.ProgressTotalMessage(len(streams)))
}
streams, err := runParallel(ctx, streams, func(ctx context.Context, target cameradar.Stream) (cameradar.Stream, error) {
return a.validateStream(ctx, target, true)
})
if err != nil {
a.reporter.Error(cameradar.StepValidateStreams, err)
return streams, fmt.Errorf("validating streams: %w", err)
}
updateSummary(a.reporter, streams)
a.reporter.Done(cameradar.StepValidateStreams, "Stream validation complete")
return streams, nil
}
func (a Attacker) reattackRoutes(ctx context.Context, streams []cameradar.Stream) ([]cameradar.Stream, error) {
a.reporter.Progress(cameradar.StepAttackRoutes, "Re-attacking routes for partial results")
updated, err := runParallel(ctx, streams, func(ctx context.Context, target cameradar.Stream) (cameradar.Stream, error) {
return a.attackRoutesForStream(ctx, target, false)
})
if err != nil {
a.reporter.Error(cameradar.StepAttackRoutes, err)
return streams, fmt.Errorf("attacking routes: %w", err)
}
updated, err = runParallel(ctx, updated, func(ctx context.Context, target cameradar.Stream) (cameradar.Stream, error) {
return a.validateStream(ctx, target, false)
})
if err != nil {
a.reporter.Error(cameradar.StepValidateStreams, err)
return updated, fmt.Errorf("validating streams: %w", err)
}
updateSummary(a.reporter, updated)
return updated, nil
}
func needsReattack(streams []cameradar.Stream) bool {
for _, stream := range streams {
if stream.RouteFound && stream.CredentialsFound && stream.Available {
continue
}
return true
}
return false
}
type summaryUpdater interface {
UpdateSummary(streams []cameradar.Stream)
}
func updateSummary(reporter Reporter, streams []cameradar.Stream) {
updater, ok := reporter.(summaryUpdater)
if !ok {
return
}
updater.UpdateSummary(streams)
}
func (a Attacker) attackCredentialsForStream(ctx context.Context, target cameradar.Stream) (cameradar.Stream, error) {
for _, username := range a.dictionary.Usernames() {
for _, password := range a.dictionary.Passwords() {
if ctx.Err() != nil {
return target, ctx.Err()
}
a.reporter.Progress(cameradar.StepAttackCredentials, cameradar.ProgressTickMessage())
ok, err := a.credAttack(target, username, password)
if err != nil {
target.CredentialsFound = false
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
}
if ok {
target.CredentialsFound = true
target.Username = username
target.Password = password
msg := fmt.Sprintf("Credentials found for %s:%d", target.Address.String(), target.Port)
a.reporter.Progress(cameradar.StepAttackCredentials, msg)
return target, nil
}
time.Sleep(a.attackInterval)
}
}
target.CredentialsFound = false
return target, nil
}
func (a Attacker) attackRoutesForStream(ctx context.Context, target cameradar.Stream, emitProgress bool) (cameradar.Stream, error) {
if target.RouteFound {
return target, nil
}
if emitProgress {
a.reporter.Progress(cameradar.StepAttackRoutes, cameradar.ProgressTickMessage())
}
ok, err := a.routeAttack(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
}
if ok {
target.RouteFound = true
target.Routes = append(target.Routes, "/")
a.reporter.Progress(cameradar.StepAttackRoutes, fmt.Sprintf("Default route accepted for %s:%d", target.Address.String(), target.Port))
return target, nil
}
for _, route := range a.dictionary.Routes() {
select {
case <-ctx.Done():
return target, ctx.Err()
case <-time.After(a.attackInterval):
}
if emitProgress {
a.reporter.Progress(cameradar.StepAttackRoutes, cameradar.ProgressTickMessage())
}
ok, err := a.routeAttack(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
}
if ok {
target.RouteFound = true
target.Routes = append(target.Routes, route)
a.reporter.Progress(cameradar.StepAttackRoutes, fmt.Sprintf("Route found for %s:%d -> %s", target.Address.String(), target.Port, route))
}
}
return target, nil
}
func (a Attacker) detectAuthMethods(ctx context.Context, targets []cameradar.Stream) ([]cameradar.Stream, error) {
streams, err := runParallel(ctx, targets, a.detectAuthMethod)
if err != nil {
return streams, err
}
for i := range streams {
a.reporter.Progress(cameradar.StepDetectAuth, cameradar.ProgressTickMessage())
var authMethod string
switch streams[i].AuthenticationType {
case cameradar.AuthNone:
authMethod = "no"
case cameradar.AuthBasic:
authMethod = "basic"
case cameradar.AuthDigest:
authMethod = "digest"
default:
return streams, fmt.Errorf("unknown authentication method %d for %s:%d", streams[i].AuthenticationType, streams[i].Address.String(), streams[i].Port)
}
a.reporter.Progress(cameradar.StepDetectAuth, fmt.Sprintf("Detected %s authentication for %s:%d", authMethod, streams[i].Address.String(), streams[i].Port))
}
return streams, nil
}
func (a Attacker) detectAuthMethod(ctx context.Context, stream cameradar.Stream) (cameradar.Stream, error) {
if ctx.Err() != nil {
return stream, ctx.Err()
}
u, urlStr, err := buildRTSPURL(stream, stream.Route(), "", "")
if err != nil {
return stream, fmt.Errorf("building rtsp url: %w", err)
}
client, err := a.newRTSPClient(u)
if err != nil {
return stream, fmt.Errorf("starting rtsp client: %w", err)
}
defer client.Close()
_, res, err := client.Describe(u)
if err != nil {
var badStatus liberrors.ErrClientBadStatusCode
if errors.As(err, &badStatus) && res != nil && badStatus.Code == base.StatusUnauthorized {
stream.AuthenticationType = authTypeFromHeaders(res.Header["WWW-Authenticate"])
a.reporter.Debug(cameradar.StepDetectAuth, fmt.Sprintf("DESCRIBE %s RTSP/1.0 > %d", urlStr, badStatus.Code))
return stream, nil
}
return stream, fmt.Errorf("performing describe request at %q: %w", urlStr, err)
}
if res != nil {
a.reporter.Debug(cameradar.StepDetectAuth, fmt.Sprintf("DESCRIBE %s RTSP/1.0 > %d", urlStr, res.StatusCode))
}
stream.AuthenticationType = cameradar.AuthNone
return stream, nil
}
func (a Attacker) routeAttack(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)
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))
access := code == base.StatusOK || code == base.StatusUnauthorized || code == base.StatusForbidden
return access, nil
}
func (a Attacker) credAttack(stream cameradar.Stream, username, password string) (bool, error) {
u, urlStr, err := buildRTSPURL(stream, stream.Route(), username, password)
if err != nil {
return false, fmt.Errorf("building rtsp url: %w", err)
}
code, err := a.describeStatus(u)
if err != nil {
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
}
func (a Attacker) validateStream(ctx context.Context, stream cameradar.Stream, emitProgress bool) (cameradar.Stream, error) {
if emitProgress {
defer a.reporter.Progress(cameradar.StepValidateStreams, cameradar.ProgressTickMessage())
}
if ctx.Err() != nil {
return stream, ctx.Err()
}
u, urlStr, err := buildRTSPURL(stream, stream.Route(), stream.Username, stream.Password)
if err != nil {
return stream, fmt.Errorf("building rtsp url: %w", err)
}
client, err := a.newRTSPClient(u)
if err != nil {
return stream, fmt.Errorf("starting rtsp client: %w", err)
}
defer client.Close()
desc, res, err := client.Describe(u)
if err != nil {
return a.handleDescribeError(stream, urlStr, err)
}
a.logDescribeResponse(urlStr, res)
if desc == nil || len(desc.Medias) == 0 {
return stream, fmt.Errorf("no media tracks found for %q", urlStr)
}
res, err = client.Setup(desc.BaseURL, desc.Medias[0], 0, 0)
if err != nil {
return a.handleSetupError(stream, urlStr, err)
}
a.logSetupResponse(urlStr, res)
stream.Available = res != nil && res.StatusCode == base.StatusOK
if stream.Available {
a.reporter.Progress(cameradar.StepValidateStreams, fmt.Sprintf("Stream validated for %s:%d", stream.Address.String(), stream.Port))
}
return stream, nil
}
func (a Attacker) handleDescribeError(stream cameradar.Stream, urlStr string, err error) (cameradar.Stream, error) {
var badStatus liberrors.ErrClientBadStatusCode
if errors.As(err, &badStatus) && badStatus.Code == base.StatusServiceUnavailable {
a.reporter.Progress(cameradar.StepValidateStreams, fmt.Sprintf("Stream unavailable for %s:%d (RTSP %d)",
stream.Address.String(),
stream.Port,
badStatus.Code,
))
stream.Available = false
return stream, nil
}
return stream, fmt.Errorf("performing describe request at %q: %w", urlStr, err)
}
func (a Attacker) handleSetupError(stream cameradar.Stream, urlStr string, err error) (cameradar.Stream, error) {
var badStatus liberrors.ErrClientBadStatusCode
if errors.As(err, &badStatus) {
a.reporter.Debug(cameradar.StepValidateStreams, fmt.Sprintf("SETUP %s RTSP/1.0 > %d", urlStr, badStatus.Code))
stream.Available = badStatus.Code == base.StatusOK
return stream, nil
}
return stream, fmt.Errorf("performing setup request at %q: %w", urlStr, err)
}
func (a Attacker) logDescribeResponse(urlStr string, res *base.Response) {
if res == nil {
return
}
a.reporter.Debug(cameradar.StepValidateStreams, fmt.Sprintf("DESCRIBE %s RTSP/1.0 > %d", urlStr, res.StatusCode))
}
func (a Attacker) logSetupResponse(urlStr string, res *base.Response) {
if res == nil {
return
}
a.reporter.Debug(cameradar.StepValidateStreams, fmt.Sprintf("SETUP %s RTSP/1.0 > %d", urlStr, res.StatusCode))
}
+388
View File
@@ -0,0 +1,388 @@
package attack_test
import (
"strings"
"sync"
"testing"
"time"
"github.com/Ullaakut/cameradar/v6"
"github.com/Ullaakut/cameradar/v6/internal/attack"
"github.com/Ullaakut/cameradar/v6/internal/ui"
"github.com/bluenviron/gortsplib/v5/pkg/base"
"github.com/bluenviron/gortsplib/v5/pkg/headers"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNew(t *testing.T) {
tests := []struct {
name string
dict attack.Dictionary
wantErr require.ErrorAssertionFunc
}{
{
name: "rejects nil dictionary",
dict: nil,
wantErr: require.Error,
},
{
name: "accepts dictionary",
dict: testDictionary{
routes: []string{"stream"},
usernames: []string{"user"},
passwords: []string{"pass"},
},
wantErr: require.NoError,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
attacker, err := attack.New(test.dict, 10*time.Millisecond, time.Second, ui.NopReporter{})
test.wantErr(t, err)
if err != nil {
assert.NotNil(t, attacker)
}
})
}
}
func TestAttacker_Attack_BasicAuth(t *testing.T) {
addr, port := startRTSPServer(t, rtspServerConfig{
allowedRoute: "stream",
requireAuth: true,
username: "user",
password: "pass",
authMethod: headers.AuthMethodBasic,
})
dict := testDictionary{
routes: []string{"stream"},
usernames: []string{"user", "other"},
passwords: []string{"pass", "bad"},
}
testInterval := time.Millisecond
testRequestTimeout := time.Second
attacker, err := attack.New(dict, testInterval, testRequestTimeout, ui.NopReporter{})
require.NoError(t, err)
streams := []cameradar.Stream{{
Address: addr,
Port: port,
}}
got, err := attacker.Attack(t.Context(), streams)
require.NoError(t, err)
require.Len(t, got, 1)
assert.True(t, got[0].RouteFound)
assert.True(t, got[0].CredentialsFound)
assert.True(t, got[0].Available)
assert.Equal(t, cameradar.AuthBasic, got[0].AuthenticationType)
assert.Equal(t, "user", got[0].Username)
assert.Equal(t, "pass", got[0].Password)
assert.Contains(t, got[0].Routes, "stream")
}
func TestAttacker_Attack_AuthVariants(t *testing.T) {
tests := []struct {
name string
config rtspServerConfig
dict testDictionary
wantAuthType cameradar.AuthType
wantRoute bool
wantCreds bool
wantAvail bool
wantErr require.ErrorAssertionFunc
errContains string
}{
{
name: "no authentication",
config: rtspServerConfig{
allowedRoute: "stream",
requireAuth: false,
authMethod: headers.AuthMethodBasic,
},
dict: testDictionary{
routes: []string{"stream"},
},
wantAuthType: cameradar.AuthNone,
wantRoute: true,
wantCreds: false,
wantAvail: true,
wantErr: require.NoError,
},
{
name: "digest authentication",
config: rtspServerConfig{
allowedRoute: "stream",
requireAuth: true,
username: "user",
password: "pass",
authMethod: headers.AuthMethodDigest,
},
dict: testDictionary{
routes: []string{"stream"},
usernames: []string{"user"},
passwords: []string{"pass"},
},
wantAuthType: cameradar.AuthDigest,
wantRoute: true,
wantCreds: true,
wantAvail: true,
wantErr: require.NoError,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
addr, port := startRTSPServer(t, test.config)
attacker, err := attack.New(test.dict, 0, time.Second, ui.NopReporter{})
require.NoError(t, err)
streams := []cameradar.Stream{{
Address: addr,
Port: port,
}}
got, err := attacker.Attack(t.Context(), streams)
test.wantErr(t, err)
if test.errContains != "" {
assert.ErrorContains(t, err, test.errContains)
}
require.Len(t, got, 1)
assert.Equal(t, test.wantAuthType, got[0].AuthenticationType)
assert.Equal(t, test.wantRoute, got[0].RouteFound)
assert.Equal(t, test.wantCreds, got[0].CredentialsFound)
assert.Equal(t, test.wantAvail, got[0].Available)
})
}
}
func TestAttacker_Attack_ValidationErrors(t *testing.T) {
attacker, err := attack.New(testDictionary{routes: []string{"stream"}}, 0, time.Second, ui.NopReporter{})
require.NoError(t, err)
tests := []struct {
name string
attacker attack.Attacker
targets []cameradar.Stream
wantErr string
}{
{
name: "fails with no targets",
attacker: attacker,
targets: nil,
wantErr: "no stream found",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := test.attacker.Attack(t.Context(), test.targets)
require.Error(t, err)
assert.ErrorContains(t, err, test.wantErr)
})
}
}
func TestAttacker_Attack_ReturnsErrorWhenRouteMissing(t *testing.T) {
addr, port := startRTSPServer(t, rtspServerConfig{
allowedRoute: "stream",
requireAuth: false,
authMethod: headers.AuthMethodBasic,
})
dict := testDictionary{
routes: []string{"missing"},
usernames: []string{"user"},
passwords: []string{"pass"},
}
attacker, err := attack.New(dict, 0, time.Second, ui.NopReporter{})
require.NoError(t, err)
streams := []cameradar.Stream{{
Address: addr,
Port: port,
}}
got, err := attacker.Attack(t.Context(), streams)
require.Error(t, err)
assert.ErrorContains(t, err, "detecting authentication methods")
require.Len(t, got, 1)
assert.False(t, got[0].RouteFound)
}
func TestAttacker_Attack_ReturnsErrorWhenCredentialsMissing(t *testing.T) {
addr, port := startRTSPServer(t, rtspServerConfig{
allowedRoute: "stream",
requireAuth: true,
username: "user",
password: "pass",
authMethod: headers.AuthMethodBasic,
})
dict := testDictionary{
routes: []string{"stream"},
usernames: []string{"user"},
passwords: []string{"wrong"},
}
attacker, err := attack.New(dict, 0, time.Second, ui.NopReporter{})
require.NoError(t, err)
streams := []cameradar.Stream{{
Address: addr,
Port: port,
}}
got, err := attacker.Attack(t.Context(), streams)
require.Error(t, err)
assert.ErrorContains(t, err, "validating streams")
require.Len(t, got, 1)
assert.Equal(t, cameradar.AuthBasic, got[0].AuthenticationType)
assert.False(t, got[0].CredentialsFound)
}
func TestAttacker_Attack_CredentialAttemptFails(t *testing.T) {
reporter := &recordingReporter{}
addr, port := startRTSPServer(t, rtspServerConfig{
allowedRoute: "stream",
requireAuth: true,
username: "user",
password: "pass",
authMethod: headers.AuthMethodBasic,
failOnAuth: true,
})
dict := testDictionary{
routes: []string{"stream"},
usernames: []string{"user"},
passwords: []string{"pass"},
}
attacker, err := attack.New(dict, 0, time.Second, reporter)
require.NoError(t, err)
streams := []cameradar.Stream{{
Address: addr,
Port: port,
}}
got, err := attacker.Attack(t.Context(), streams)
require.Error(t, err)
assert.ErrorContains(t, err, "validating streams")
require.Len(t, got, 1)
assert.False(t, got[0].CredentialsFound)
}
func TestAttacker_Attack_AllowsDummyRoute(t *testing.T) {
addr, port := startRTSPServer(t, rtspServerConfig{
allowAll: true,
requireAuth: false,
authMethod: headers.AuthMethodBasic,
})
dict := testDictionary{}
attacker, err := attack.New(dict, 0, time.Second, ui.NopReporter{})
require.NoError(t, err)
streams := []cameradar.Stream{{
Address: addr,
Port: port,
}}
got, err := attacker.Attack(t.Context(), streams)
require.NoError(t, err)
require.Len(t, got, 1)
assert.True(t, got[0].RouteFound)
assert.Equal(t, []string{"/"}, got[0].Routes)
assert.True(t, got[0].Available)
}
func TestAttacker_Attack_ValidationFailsWhenSetupErrors(t *testing.T) {
addr, port := startRTSPServer(t, rtspServerConfig{
allowedRoute: "stream",
requireAuth: false,
authMethod: headers.AuthMethodBasic,
setupStatus: base.StatusUnsupportedTransport,
})
dict := testDictionary{
routes: []string{"stream"},
}
attacker, err := attack.New(dict, 0, time.Second, ui.NopReporter{})
require.NoError(t, err)
streams := []cameradar.Stream{{
Address: addr,
Port: port,
}}
got, err := attacker.Attack(t.Context(), streams)
require.NoError(t, err)
require.Len(t, got, 1)
assert.False(t, got[0].Available)
assert.True(t, got[0].RouteFound)
}
type testDictionary struct {
routes []string
usernames []string
passwords []string
}
func (d testDictionary) Routes() []string {
return d.routes
}
func (d testDictionary) Usernames() []string {
return d.usernames
}
func (d testDictionary) Passwords() []string {
return d.passwords
}
type recordingReporter struct {
mu sync.Mutex
debugMessages []string
}
func (r *recordingReporter) Start(cameradar.Step, string) {}
func (r *recordingReporter) Done(cameradar.Step, string) {}
func (r *recordingReporter) Progress(cameradar.Step, string) {}
func (r *recordingReporter) Debug(_ cameradar.Step, message string) {
r.mu.Lock()
defer r.mu.Unlock()
r.debugMessages = append(r.debugMessages, message)
}
func (r *recordingReporter) Error(cameradar.Step, error) {}
func (r *recordingReporter) Summary([]cameradar.Stream, error) {}
func (r *recordingReporter) Close() {}
func (r *recordingReporter) HasDebugContaining(value string) bool {
r.mu.Lock()
defer r.mu.Unlock()
for _, message := range r.debugMessages {
if strings.Contains(message, value) {
return true
}
}
return false
}
+109
View File
@@ -0,0 +1,109 @@
package attack
import (
"errors"
"net"
"net/url"
"strconv"
"github.com/Ullaakut/cameradar/v6"
"github.com/bluenviron/gortsplib/v5"
"github.com/bluenviron/gortsplib/v5/pkg/base"
"github.com/bluenviron/gortsplib/v5/pkg/headers"
"github.com/bluenviron/gortsplib/v5/pkg/liberrors"
)
func (a Attacker) newRTSPClient(u *base.URL) (*gortsplib.Client, error) {
client := &gortsplib.Client{
ReadTimeout: a.timeout,
WriteTimeout: a.timeout,
}
client.Scheme = u.Scheme
client.Host = u.Host
err := client.Start()
if err != nil {
return nil, err
}
return client, nil
}
func (a Attacker) describeStatus(u *base.URL) (base.StatusCode, error) {
client, err := a.newRTSPClient(u)
if err != nil {
return 0, err
}
defer client.Close()
_, res, err := client.Describe(u)
if err != nil {
var badStatus liberrors.ErrClientBadStatusCode
if errors.As(err, &badStatus) && res != nil {
return badStatus.Code, nil
}
return 0, err
}
if res == nil {
return 0, errors.New("no response received")
}
return res.StatusCode, nil
}
func authTypeFromHeaders(values base.HeaderValue) cameradar.AuthType {
if len(values) == 0 {
return cameradar.AuthNone
}
var hasBasic bool
var hasDigest bool
for _, value := range values {
var authHeader headers.Authenticate
err := authHeader.Unmarshal(base.HeaderValue{value})
if err != nil {
continue
}
switch authHeader.Method {
case headers.AuthMethodDigest:
hasDigest = true
case headers.AuthMethodBasic:
hasBasic = true
}
}
if hasDigest {
return cameradar.AuthDigest
}
if hasBasic {
return cameradar.AuthBasic
}
return cameradar.AuthType(-1)
}
func buildRTSPURL(stream cameradar.Stream, route, username, password string) (*base.URL, string, error) {
host := net.JoinHostPort(stream.Address.String(), strconv.Itoa(int(stream.Port)))
path := "/" + route
if route == "" {
path = "/"
}
u := &url.URL{
Scheme: "rtsp",
Host: host,
Path: path,
}
if username != "" || password != "" {
u.User = url.UserPassword(username, password)
}
urlStr := u.String()
parsed, err := base.ParseURL(urlStr)
if err != nil {
return nil, "", err
}
return parsed, urlStr, nil
}
+166
View File
@@ -0,0 +1,166 @@
package attack_test
import (
"errors"
"net"
"net/netip"
"strings"
"testing"
"github.com/bluenviron/gortsplib/v5"
"github.com/bluenviron/gortsplib/v5/pkg/auth"
"github.com/bluenviron/gortsplib/v5/pkg/base"
"github.com/bluenviron/gortsplib/v5/pkg/description"
"github.com/bluenviron/gortsplib/v5/pkg/format"
"github.com/bluenviron/gortsplib/v5/pkg/headers"
"github.com/bluenviron/gortsplib/v5/pkg/liberrors"
"github.com/stretchr/testify/require"
)
type rtspServerConfig struct {
allowAll bool
allowedRoute string
requireAuth bool
username string
password string
authMethod headers.AuthMethod
authHeader base.HeaderValue
failOnAuth bool
setupStatus base.StatusCode
}
type testServerHandler struct {
stream *gortsplib.ServerStream
allowAll bool
allowedRoute string
requireAuth bool
username string
password string
authHeader base.HeaderValue
failOnAuth bool
setupStatus base.StatusCode
}
func (h *testServerHandler) OnDescribe(ctx *gortsplib.ServerHandlerOnDescribeCtx) (*base.Response, *gortsplib.ServerStream, error) {
if !h.routeAllowed(ctx.Path) {
return &base.Response{StatusCode: base.StatusNotFound}, nil, nil
}
if h.failOnAuth && len(ctx.Request.Header["Authorization"]) > 0 {
return &base.Response{StatusCode: base.StatusBadRequest}, nil, errors.New("forced auth failure")
}
if h.requireAuth && !ctx.Conn.VerifyCredentials(ctx.Request, h.username, h.password) {
return &base.Response{
StatusCode: base.StatusUnauthorized,
Header: base.Header{
"WWW-Authenticate": h.authHeader,
},
}, nil, liberrors.ErrServerAuth{}
}
return &base.Response{StatusCode: base.StatusOK}, h.stream, nil
}
func (h *testServerHandler) OnSetup(ctx *gortsplib.ServerHandlerOnSetupCtx) (*base.Response, *gortsplib.ServerStream, error) {
if !h.routeAllowed(ctx.Path) {
return &base.Response{StatusCode: base.StatusNotFound}, nil, nil
}
if h.requireAuth && !ctx.Conn.VerifyCredentials(ctx.Request, h.username, h.password) {
return &base.Response{
StatusCode: base.StatusUnauthorized,
Header: base.Header{
"WWW-Authenticate": h.authHeader,
},
}, nil, liberrors.ErrServerAuth{}
}
status := base.StatusOK
if h.setupStatus != 0 {
status = h.setupStatus
}
return &base.Response{StatusCode: status}, h.stream, nil
}
func (h *testServerHandler) routeAllowed(path string) bool {
path = strings.TrimLeft(path, "/")
return h.allowAll || path == h.allowedRoute
}
func startRTSPServer(t *testing.T, cfg rtspServerConfig) (netip.Addr, uint16) {
t.Helper()
handler := &testServerHandler{
allowAll: cfg.allowAll,
allowedRoute: cfg.allowedRoute,
requireAuth: cfg.requireAuth,
username: cfg.username,
password: cfg.password,
failOnAuth: cfg.failOnAuth,
setupStatus: cfg.setupStatus,
}
if len(cfg.authHeader) > 0 {
handler.authHeader = cfg.authHeader
} else {
authHeader := headers.Authenticate{
Method: cfg.authMethod,
Realm: "cameradar",
}
if cfg.authMethod == headers.AuthMethodDigest {
authHeader.Nonce = "nonce"
}
handler.authHeader = authHeader.Marshal()
}
server := &gortsplib.Server{
Handler: handler,
RTSPAddress: "127.0.0.1:0",
AuthMethods: authMethods(cfg.authMethod),
}
err := server.Start()
require.NoError(t, err)
t.Cleanup(server.Close)
desc := &description.Session{
Medias: []*description.Media{{
Type: description.MediaTypeVideo,
Formats: []format.Format{&format.H264{
PayloadTyp: 96,
PacketizationMode: 1,
}},
}},
}
stream := &gortsplib.ServerStream{
Server: server,
Desc: desc,
}
err = stream.Initialize()
require.NoError(t, err)
t.Cleanup(stream.Close)
handler.stream = stream
listener := server.NetListener()
require.NotNil(t, listener)
tcpAddr, ok := listener.Addr().(*net.TCPAddr)
require.True(t, ok)
return netip.MustParseAddr("127.0.0.1"), uint16(tcpAddr.Port)
}
func authMethods(method headers.AuthMethod) []auth.VerifyMethod {
switch method {
case headers.AuthMethodDigest:
return []auth.VerifyMethod{auth.VerifyMethodDigestMD5}
case headers.AuthMethodBasic:
return []auth.VerifyMethod{auth.VerifyMethodBasic}
default:
return nil
}
}
+105
View File
@@ -0,0 +1,105 @@
package attack
import (
"context"
"runtime"
"sync"
"github.com/Ullaakut/cameradar/v6"
)
type attackFn func(context.Context, cameradar.Stream) (cameradar.Stream, error)
func runParallel(ctx context.Context, targets []cameradar.Stream, fn attackFn) ([]cameradar.Stream, error) {
if len(targets) == 0 {
return targets, nil
}
workerCount := parallelWorkerCount(len(targets))
if workerCount == 0 {
return targets, nil
}
errCh := make(chan error, 1)
jobs := make(chan attackJob)
updated := make([]cameradar.Stream, len(targets))
copy(updated, targets)
ctx, cancel := context.WithCancel(ctx)
defer cancel()
var wg sync.WaitGroup
for range workerCount {
wg.Go(func() {
runWorker(ctx, jobs, cancel, fn, updated, errCh)
})
}
queueJobs(ctx, jobs, targets)
close(jobs)
wg.Wait()
select {
case err := <-errCh:
return updated, err
default:
}
return updated, nil
}
type attackJob struct {
index int
stream cameradar.Stream
}
func queueJobs(ctx context.Context, jobs chan<- attackJob, targets []cameradar.Stream) {
for i, stream := range targets {
select {
case <-ctx.Done():
return
case jobs <- attackJob{index: i, stream: stream}:
}
}
}
func runWorker(ctx context.Context, jobs <-chan attackJob, cancelFn func(), fn attackFn, updated []cameradar.Stream, errCh chan error) {
for {
select {
case <-ctx.Done():
return
case job, ok := <-jobs:
if !ok {
return
}
stream, err := fn(ctx, job.stream)
if err != nil {
select {
case errCh <- err:
default:
}
cancelFn()
return
}
updated[job.index] = stream
}
}
}
func parallelWorkerCount(targetCount int) int {
if targetCount <= 0 {
return 0
}
workers := max(runtime.GOMAXPROCS(0), 1)
if targetCount < workers {
return targetCount
}
return workers
}