feat: detect potential incremental routes and bruteforce them

This commit is contained in:
Brendan Le Glaunec
2026-01-28 18:44:11 +01:00
parent 777bd2a488
commit 510a9af2fd
3 changed files with 327 additions and 3 deletions
+99 -3
View File
@@ -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 {
+162
View File
@@ -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'
}
+66
View File
@@ -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))
}