diff --git a/internal/attack/attacker.go b/internal/attack/attacker.go index be2dcee..0fef28d 100644 --- a/internal/attack/attacker.go +++ b/internal/attack/attacker.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "slices" "time" "github.com/Ullaakut/cameradar/v6" @@ -232,7 +233,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, true) + if err != nil { + return target, err + } + + return updated, nil } time.Sleep(a.attackInterval) } @@ -257,7 +263,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 } @@ -279,8 +285,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, false) + if err != nil { + return target, err + } + target = updated } } @@ -364,6 +376,90 @@ func (a Attacker) routeAttack(stream cameradar.Stream, route string) (bool, erro return access, nil } +func (a Attacker) routeAttackWithCredentials(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)) + return code == base.StatusOK, nil +} + +func (a Attacker) tryIncrementalRoutes(ctx context.Context, + target cameradar.Stream, route string, + emitProgress, useCredentials bool, +) (cameradar.Stream, error) { + match, ok := detectIncrementalRoute(route) + if !ok { + return target, nil + } + + nextNumber := match.number + 1 + for { + select { + case <-ctx.Done(): + return target, ctx.Err() + case <-time.After(a.attackInterval): + } + + nextRoute := buildIncrementedRoute(match, nextNumber) + if slices.Contains(target.Routes, nextRoute) { + if !match.isChannel { + return target, nil + } + nextNumber++ + continue + } + + if emitProgress { + a.reporter.Progress(cameradar.StepAttackRoutes, cameradar.ProgressTickMessage()) + } + + ok, err := a.incrementalRouteAttack(target, nextRoute, useCredentials) + 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 (a Attacker) incrementalRouteAttack(stream cameradar.Stream, route string, useCredentials bool) (bool, error) { + if useCredentials { + return a.routeAttackWithCredentials(stream, route) + } + return a.routeAttack(stream, route) +} + +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) { u, urlStr, err := buildRTSPURL(stream, stream.Route(), username, password) if err != nil { diff --git a/internal/attack/incremental.go b/internal/attack/incremental.go new file mode 100644 index 0000000..527d3ea --- /dev/null +++ b/internal/attack/incremental.go @@ -0,0 +1,162 @@ +package attack + +import ( + "fmt" + "strconv" + "strings" +) + +type incrementalRoute struct { + prefix string + suffix string + number int + width int + isChannel bool +} + +// detectIncrementalRoute identifies routes that can be incremented. +// It prioritizes channel-like patterns to enable sequential scanning when possible. +// +// Examples of supported patterns: +// - /StreamingSetting?ChannelID=01&other=params -> /StreamingSetting?ChannelID=02&other=params +// - /path/to/channel2/stream -> /path/to/channel3/stream +// - /foo/bar12/baz -> /foo/bar13/baz +// +// It returns false if no incrementable pattern is found. +func detectIncrementalRoute(route string) (incrementalRoute, bool) { + if strings.TrimSpace(route) == "" { + return incrementalRoute{}, false + } + + if match, ok := findChannelIncrement(route); ok { + match.isChannel = true + return match, true + } + + match, ok := findLastNumber(route) + if !ok { + return incrementalRoute{}, false + } + return match, true +} + +// findChannelIncrement locates a numeric segment tied to channel-like keywords. +// It returns the last matching segment so we increment the most specific part. +// +// Supported keywords include: channel_id, channelid, channelno, channel, channelname. +func findChannelIncrement(route string) (incrementalRoute, bool) { + patterns := []string{"channel_id", "channelid", "channelno", "channel", "channelname"} + lower := strings.ToLower(route) + + for _, pattern := range patterns { + var lastMatch incrementalRoute + found := false + index := 0 + + for { + pos := strings.Index(lower[index:], pattern) + if pos == -1 { + break + } + pos += index + + start, end, ok := firstNumberAfter(route, pos+len(pattern)) + if ok { + num, width, parseOK := parseNumber(route, start, end) + if parseOK { + lastMatch = incrementalRoute{ + prefix: route[:start], + suffix: route[end:], + number: num, + width: width, + } + found = true + } + } + index = pos + len(pattern) + } + if found { + return lastMatch, true + } + } + + return incrementalRoute{}, false +} + +// findLastNumber finds the last numeric token in the route so it can be incremented. +// This supports routes where the channel number is not the final component. +func findLastNumber(route string) (incrementalRoute, bool) { + for i := len(route) - 1; i >= 0; i-- { + if !isDigit(route[i]) { + continue + } + end := i + 1 + start := i + for start >= 0 && isDigit(route[start]) { + start-- + } + start++ + + num, width, ok := parseNumber(route, start, end) + if !ok { + return incrementalRoute{}, false + } + + return incrementalRoute{ + prefix: route[:start], + suffix: route[end:], + number: num, + width: width, + }, true + } + + return incrementalRoute{}, false +} + +// parseNumber reads the numeric token and returns its integer value and width. +func parseNumber(route string, start, end int) (int, int, bool) { + if start < 0 || end > len(route) || start >= end { + return 0, 0, false + } + + value := route[start:end] + num, err := strconv.Atoi(value) + if err != nil { + return 0, 0, false + } + + return num, len(value), true +} + +// firstNumberAfter returns the first numeric token after a given index. +func firstNumberAfter(route string, after int) (start, end int, ok bool) { + if after < 0 { + after = 0 + } + + for i := after; i < len(route); i++ { + if !isDigit(route[i]) { + continue + } + + end := i + 1 + for end < len(route) && isDigit(route[end]) { + end++ + } + return i, end, true + } + return 0, 0, false +} + +// buildIncrementedRoute formats the route with the new numeric value. +// It preserves zero padding when the original token had a fixed width. +func buildIncrementedRoute(match incrementalRoute, number int) string { + if match.width <= 0 { + return match.prefix + strconv.Itoa(number) + match.suffix + } + return match.prefix + fmt.Sprintf("%0*d", match.width, number) + match.suffix +} + +func isDigit(b byte) bool { + return b >= '0' && b <= '9' +} diff --git a/internal/attack/incremental_test.go b/internal/attack/incremental_test.go new file mode 100644 index 0000000..c22e579 --- /dev/null +++ b/internal/attack/incremental_test.go @@ -0,0 +1,66 @@ +package attack + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDetectIncrementalRoute_ChannelID(t *testing.T) { + route := "/StreamingSetting?version=1.0&action=getRTSPStream&ChannelID=01&ChannelName=Channel1" + + match, ok := detectIncrementalRoute(route) + require.True(t, ok) + assert.True(t, match.isChannel) + assert.Equal(t, 1, match.number) + assert.Equal(t, 2, match.width) + assert.Equal(t, "/StreamingSetting?version=1.0&action=getRTSPStream&ChannelID=", match.prefix) + assert.Equal(t, "&ChannelName=Channel1", match.suffix) + + next := buildIncrementedRoute(match, match.number+1) + assert.Equal(t, "/StreamingSetting?version=1.0&action=getRTSPStream&ChannelID=02&ChannelName=Channel1", next) +} + +func TestDetectIncrementalRoute_ChannelSuffix(t *testing.T) { + route := "/path/to/channel2/stream" + + match, ok := detectIncrementalRoute(route) + require.True(t, ok) + assert.True(t, match.isChannel) + assert.Equal(t, 2, match.number) + assert.Equal(t, "/path/to/channel", match.prefix) + assert.Equal(t, "/stream", match.suffix) +} + +func TestDetectIncrementalRoute_LastNumber(t *testing.T) { + route := "/foo/bar12/baz" + + match, ok := detectIncrementalRoute(route) + require.True(t, ok) + assert.False(t, match.isChannel) + assert.Equal(t, 12, match.number) + assert.Equal(t, 2, match.width) + assert.Equal(t, "/foo/bar", match.prefix) + assert.Equal(t, "/baz", match.suffix) + + next := buildIncrementedRoute(match, 13) + assert.Equal(t, "/foo/bar13/baz", next) +} + +func TestDetectIncrementalRoute_NoNumber(t *testing.T) { + match, ok := detectIncrementalRoute("/no/number/here") + assert.False(t, ok) + assert.Equal(t, incrementalRoute{}, match) +} + +func TestBuildIncrementedRoute_ZeroPadding(t *testing.T) { + match := incrementalRoute{ + prefix: "/channel", + suffix: "/stream", + number: 1, + width: 3, + } + + assert.Equal(t, "/channel002/stream", buildIncrementedRoute(match, 2)) +}