Skip to content
113 changes: 108 additions & 5 deletions internal/attack/attacker.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"slices"
"time"

"github.com/Ullaakut/cameradar/v6"
Expand All @@ -14,6 +15,8 @@ import (
// Route that should never be a constructor default.
const dummyRoute = "/0x8b6c42"

const maxIncrementalRouteAttempts = 32

// Dictionary provides dictionaries for routes, usernames and passwords.
type Dictionary interface {
Routes() []string
Expand Down Expand Up @@ -232,7 +235,12 @@ func (a Attacker) attackCredentialsForStream(ctx context.Context, target camerad
msg := fmt.Sprintf("Credentials found for %s:%d", target.Address.String(), target.Port)
a.reporter.Progress(cameradar.StepAttackCredentials, msg)

return target, nil
updated, err := a.tryIncrementalRoutes(ctx, target, target.Route(), true)
if err != nil {
return target, err
}

return updated, nil
}
time.Sleep(a.attackInterval)
}
Expand All @@ -257,7 +265,7 @@ func (a Attacker) attackRoutesForStream(ctx context.Context, target cameradar.St
}
if ok {
target.RouteFound = true
target.Routes = append(target.Routes, "/")
target.Routes = appendRouteIfMissing(target.Routes, "/")
a.reporter.Progress(cameradar.StepAttackRoutes, fmt.Sprintf("Default route accepted for %s:%d", target.Address.String(), target.Port))
return target, nil
}
Expand All @@ -279,8 +287,14 @@ func (a Attacker) attackRoutesForStream(ctx context.Context, target cameradar.St
}
if ok {
target.RouteFound = true
target.Routes = append(target.Routes, route)
target.Routes = appendRouteIfMissing(target.Routes, route)
a.reporter.Progress(cameradar.StepAttackRoutes, fmt.Sprintf("Route found for %s:%d -> %s", target.Address.String(), target.Port, route))

updated, err := a.tryIncrementalRoutes(ctx, target, route, emitProgress)
if err != nil {
return target, err
}
target = updated
}
}

Expand Down Expand Up @@ -348,7 +362,22 @@ func (a Attacker) detectAuthMethod(ctx context.Context, stream cameradar.Stream)
return stream, nil
}

// When no credentials are used, we expect 200, 401 or 403 status codes, which would mean either that the stream is
// unprotected and this is the correct route, or that it is protected and this is also a correct route.
func (a Attacker) routeAttack(stream cameradar.Stream, route string) (bool, error) {
return a.routeAttackWithStatus(stream, route, func(code base.StatusCode) bool {
return code == base.StatusOK || code == base.StatusUnauthorized || code == base.StatusForbidden
})
}

// When credentials are given, we only expect a 200 status code, which confirms the combination of route and credentials.
func (a Attacker) routeAttackWithCredentials(stream cameradar.Stream, route string) (bool, error) {
return a.routeAttackWithStatus(stream, route, func(code base.StatusCode) bool {
return code == base.StatusOK
})
}

func (a Attacker) routeAttackWithStatus(stream cameradar.Stream, route string, allowed func(base.StatusCode) bool) (bool, error) {
u, urlStr, err := buildRTSPURL(stream, route, stream.Username, stream.Password)
if err != nil {
return false, fmt.Errorf("building rtsp url: %w", err)
Expand All @@ -360,8 +389,82 @@ func (a Attacker) routeAttack(stream cameradar.Stream, route string) (bool, erro
}

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
return allowed(code), nil
}

func (a Attacker) tryIncrementalRoutes(ctx context.Context,
target cameradar.Stream, route string,
emitProgress bool,
) (cameradar.Stream, error) {
match, ok := detectIncrementalRoute(route)
if !ok {
return target, nil
}

nextNumber := match.number + 1
attempts := 0
for {
if attempts >= maxIncrementalRouteAttempts {
a.reporter.Debug(cameradar.StepAttackRoutes, fmt.Sprintf(
"incremental route attempts capped at %d for %s:%d",
maxIncrementalRouteAttempts,
target.Address.String(),
target.Port,
))
return target, nil
}

select {
case <-ctx.Done():
return target, ctx.Err()
case <-time.After(a.attackInterval):
}

attempts++

nextRoute := buildIncrementedRoute(match, nextNumber)
if slices.Contains(target.Routes, nextRoute) {
if !match.isChannel {
return target, nil
}
Comment thread
Ullaakut marked this conversation as resolved.
nextNumber++
continue
}

if emitProgress {
a.reporter.Progress(cameradar.StepAttackRoutes, cameradar.ProgressTickMessage())
}

ok, err := a.routeAttackWithCredentials(target, nextRoute)
if err != nil {
a.reporter.Debug(cameradar.StepAttackRoutes, fmt.Sprintf("incremental route attempt failed for %s:%d (%s): %v",
target.Address.String(),
target.Port,
nextRoute,
err,
))
return target, nil
}
if !ok {
return target, nil
}

target.RouteFound = true
target.Routes = appendRouteIfMissing(target.Routes, nextRoute)
a.reporter.Progress(cameradar.StepAttackRoutes, fmt.Sprintf("Incremental route found for %s:%d -> %s", target.Address.String(), target.Port, nextRoute))

if !match.isChannel {
return target, nil
}
nextNumber++
}
}

func appendRouteIfMissing(routes []string, route string) []string {
if slices.Contains(routes, route) {
return routes
}
return append(routes, route)
}

func (a Attacker) credAttack(stream cameradar.Stream, username, password string) (bool, error) {
Expand Down
141 changes: 109 additions & 32 deletions internal/attack/attacker_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package attack_test

import (
"fmt"
"strings"
"sync"
"testing"
Expand Down Expand Up @@ -50,11 +51,11 @@ func TestNew(t *testing.T) {

func TestAttacker_Attack_BasicAuth(t *testing.T) {
addr, port := startRTSPServer(t, rtspServerConfig{
allowedRoute: "stream",
requireAuth: true,
username: "user",
password: "pass",
authMethod: headers.AuthMethodBasic,
allowRoutes: []string{"stream"},
requireAuth: true,
username: "user",
password: "pass",
authMethod: headers.AuthMethodBasic,
})

dict := testDictionary{
Expand Down Expand Up @@ -101,9 +102,9 @@ func TestAttacker_Attack_AuthVariants(t *testing.T) {
{
name: "no authentication",
config: rtspServerConfig{
allowedRoute: "stream",
requireAuth: false,
authMethod: headers.AuthMethodBasic,
allowRoutes: []string{"stream"},
requireAuth: false,
authMethod: headers.AuthMethodBasic,
},
dict: testDictionary{
routes: []string{"stream"},
Expand All @@ -117,11 +118,11 @@ func TestAttacker_Attack_AuthVariants(t *testing.T) {
{
name: "digest authentication",
config: rtspServerConfig{
allowedRoute: "stream",
requireAuth: true,
username: "user",
password: "pass",
authMethod: headers.AuthMethodDigest,
allowRoutes: []string{"stream"},
requireAuth: true,
username: "user",
password: "pass",
authMethod: headers.AuthMethodDigest,
},
dict: testDictionary{
routes: []string{"stream"},
Expand Down Expand Up @@ -193,9 +194,9 @@ func TestAttacker_Attack_ValidationErrors(t *testing.T) {

func TestAttacker_Attack_ReturnsErrorWhenRouteMissing(t *testing.T) {
addr, port := startRTSPServer(t, rtspServerConfig{
allowedRoute: "stream",
requireAuth: false,
authMethod: headers.AuthMethodBasic,
allowRoutes: []string{"stream"},
requireAuth: false,
authMethod: headers.AuthMethodBasic,
})

dict := testDictionary{
Expand All @@ -221,11 +222,11 @@ func TestAttacker_Attack_ReturnsErrorWhenRouteMissing(t *testing.T) {

func TestAttacker_Attack_ReturnsErrorWhenCredentialsMissing(t *testing.T) {
addr, port := startRTSPServer(t, rtspServerConfig{
allowedRoute: "stream",
requireAuth: true,
username: "user",
password: "pass",
authMethod: headers.AuthMethodBasic,
allowRoutes: []string{"stream"},
requireAuth: true,
username: "user",
password: "pass",
authMethod: headers.AuthMethodBasic,
})

dict := testDictionary{
Expand Down Expand Up @@ -254,12 +255,12 @@ 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,
allowRoutes: []string{"stream"},
requireAuth: true,
username: "user",
password: "pass",
authMethod: headers.AuthMethodBasic,
failOnAuth: true,
})

dict := testDictionary{
Expand Down Expand Up @@ -310,10 +311,10 @@ func TestAttacker_Attack_AllowsDummyRoute(t *testing.T) {

func TestAttacker_Attack_ValidationFailsWhenSetupErrors(t *testing.T) {
addr, port := startRTSPServer(t, rtspServerConfig{
allowedRoute: "stream",
requireAuth: false,
authMethod: headers.AuthMethodBasic,
setupStatus: base.StatusUnsupportedTransport,
allowRoutes: []string{"stream"},
requireAuth: false,
authMethod: headers.AuthMethodBasic,
setupStatus: base.StatusUnsupportedTransport,
})

dict := testDictionary{
Expand All @@ -335,6 +336,71 @@ func TestAttacker_Attack_ValidationFailsWhenSetupErrors(t *testing.T) {
assert.True(t, got[0].RouteFound)
}

func TestAttacker_Attack_IncrementalRoutesStopsOnFirstMissAndAvoidsDuplicates(t *testing.T) {
addr, port := startRTSPServer(t, rtspServerConfig{
allowRoutes: []string{"channel1", "channel2"},
requireAuth: false,
authMethod: headers.AuthMethodBasic,
})

dict := testDictionary{
routes: []string{"channel1", "channel2"},
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.NoError(t, err)
require.Len(t, got, 1)

assert.ElementsMatch(t, []string{"channel1", "channel2"}, got[0].Routes)
assert.Equal(t, 1, countRoute(got[0].Routes, "channel2"))
}

func TestAttacker_Attack_IncrementalRoutesStopsAtCap(t *testing.T) {
allowedRoutes := make([]string, 0, 50)
for i := 1; i <= 50; i++ {
allowedRoutes = append(allowedRoutes, fmt.Sprintf("channel%d", i))
}

addr, port := startRTSPServer(t, rtspServerConfig{
allowRoutes: allowedRoutes,
requireAuth: false,
authMethod: headers.AuthMethodBasic,
})

dict := testDictionary{
routes: []string{"channel1"},
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.NoError(t, err)
require.Len(t, got, 1)

const expectedRoutes = 33 // channel1 + 32 incremental attempts
assert.Len(t, got[0].Routes, expectedRoutes)
assert.Contains(t, got[0].Routes, "channel33")
assert.NotContains(t, got[0].Routes, "channel34")
}

type testDictionary struct {
routes []string
usernames []string
Expand Down Expand Up @@ -376,13 +442,24 @@ func (r *recordingReporter) Summary([]cameradar.Stream, error) {}

func (r *recordingReporter) Close() {}

func (r *recordingReporter) HasDebugContaining(value string) bool {
func (r *recordingReporter) ContainsDebug(value string) bool {
r.mu.Lock()
defer r.mu.Unlock()

for _, message := range r.debugMessages {
if strings.Contains(message, value) {
return true
}
}
return false
}

func countRoute(routes []string, route string) int {
count := 0
for _, value := range routes {
if value == route {
count++
}
}
return count
}
Loading