Skip to content
119 changes: 114 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, true)
if err != nil {
return target, err
}

return updated, nil
Comment thread
Ullaakut marked this conversation as resolved.
Outdated
}
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, false)
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,88 @@ 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, useCredentials 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):
}
Comment thread
Ullaakut marked this conversation as resolved.

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.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
}
attempts++
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)
Comment thread
Ullaakut marked this conversation as resolved.
Outdated
}

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
192 changes: 192 additions & 0 deletions internal/attack/incremental.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
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 match for the first keyword that yields a hit.
//
// 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)
Comment thread
Ullaakut marked this conversation as resolved.
Comment thread
Ullaakut marked this conversation as resolved.

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 := firstNumberAfterKey(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; {
if !isDigit(route[i]) {
i--
continue
}

end := i + 1
start := i
for start >= 0 && isDigit(route[start]) {
start--
}
start++

num, width, ok := parseNumber(route, start, end)
if !ok {
i = start - 1
continue
}

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
}

// firstNumberAfterKey returns the first numeric token after a keyword, limited to
// the current token and requiring an '=' delimiter (query param or path segment).
func firstNumberAfterKey(route string, after int) (start, end int, ok bool) {
if after < 0 {
after = 0
}

tokenEnd := len(route)
for i := after; i < len(route); i++ {
if isTokenDelimiter(route[i]) {
tokenEnd = i
break
}
}

relEq := strings.IndexByte(route[after:tokenEnd], '=')
searchStart := after
if relEq != -1 {
searchStart = after + relEq + 1
}
for i := searchStart; i < tokenEnd; i++ {
if !isDigit(route[i]) {
if relEq == -1 {
break
}
continue
}

end := i + 1
for end < tokenEnd && 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'
}

func isTokenDelimiter(b byte) bool {
switch b {
case '&', '/', '?', '#':
return true
default:
return false
}
}
Loading
Loading