Compare commits

...

12 Commits

Author SHA1 Message Date
Brendan Le Glaunec d589b610b2 fix: increment limit, added integration tests 2026-01-28 21:22:57 +01:00
Brendan Le Glaunec 1f06a075e4 fix: incremental attacks always use credentials 2026-01-28 21:09:29 +01:00
Brendan Le Glaunec fb7b3fd9af test: add test cases for more edge cases 2026-01-28 20:31:05 +01:00
Brendan Le Glaunec a867a606b2 fix: prevent incorrect binding 2026-01-28 20:25:33 +01:00
Brendan Le Glaunec 77a2eac262 docs: fix erroneous function comment 2026-01-28 20:17:11 +01:00
Brendan Le Glaunec dc4d6c9489 fix: overflow fallback when detecting incremental route, regression test 2026-01-28 20:06:05 +01:00
Brendan Le Glaunec cb832179a2 fix: improve attackRoute methods 2026-01-28 19:56:01 +01:00
Brendan Le Glaunec 10bf1b59e8 fix: add max attempts to incremental routes to prevent infinite loop 2026-01-28 19:50:36 +01:00
Brendan Le Glaunec 510a9af2fd feat: detect potential incremental routes and bruteforce them 2026-01-28 18:44:11 +01:00
Brendan Le Glaunec 777bd2a488 chore: resize cameradar logo (#387) 2026-01-28 14:35:30 +01:00
Brendan Le Glaunec 99c2e55ec4 docs: add GIF of new TUI (#386) 2026-01-27 23:27:01 +01:00
Brendan Le Glaunec bf720fcea2 fix: build and test workflows 2026-01-27 23:19:21 +01:00
12 changed files with 642 additions and 88 deletions
+26 -13
View File
@@ -14,30 +14,43 @@ jobs:
id-token: write id-token: write
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
# We need to set a cache marker to ensure that the cache is individual for each job.
- name: Add Cache Marker
run: echo "go-build" > env.txt
- name: Install Go - name: Install Go
id: install-go id: install-go
uses: actions/setup-go@v5 uses: actions/setup-go@v6
with: with:
go-version-file: 'go.mod' go-version-file: 'go.mod'
cache-dependency-path: | cache: false
go.sum
env.txt - name: Cache Go mod
id: gomod
uses: actions/cache@v5
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-mod-
- name: Cache Go build
uses: actions/cache@v5
with:
path: ~/.cache/go-build
key: ${{ runner.os }}-go-build-${{ github.ref_name }}
restore-keys: |
${{ runner.os }}-go-build-
- name: Download dependencies - name: Download dependencies
run: go mod download run: go mod download
if: steps.install-go.outputs.cache-hit != 'true' if: steps.gomod.outputs.cache-hit != 'true'
- name: Run GoReleaser - name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6 uses: goreleaser/goreleaser-action@v6
env: env:
GORELEASER_CURRENT_TAG: ${{ github.ref_name }} GITHUB_TOKEN: ${{ secrets.GORELEASER_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
DOCKER_REPOSITORY: ullaakut/cameradar with:
GITHUB_TOKEN: ${{ secrets.GORELEASER_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} distribution: goreleaser
version: ${{ inputs.GORELEASER_VERSION }}
args: release --clean --snapshot --skip=docker
+9 -1
View File
@@ -42,7 +42,15 @@ jobs:
- name: Run Linter - name: Run Linter
uses: golangci/golangci-lint-action@v8 uses: golangci/golangci-lint-action@v8
with: with:
version: v2.10.2 version: v2.7.2
- name: Setup gotestsum
uses: gertd/action-gotestsum@v3.0.0
with:
gotestsum_version: v1.13.0
- name: Download nmap
run: sudo apt-get install -y nmap
- name: Run Tests - name: Run Tests
env: env:
+6 -3
View File
@@ -8,7 +8,7 @@
<img src="https://img.shields.io/docker/pulls/ullaakut/cameradar.svg?style=flat" /> <img src="https://img.shields.io/docker/pulls/ullaakut/cameradar.svg?style=flat" />
</a> </a>
<a href="https://github.com/Ullaakut/cameradar/actions"> <a href="https://github.com/Ullaakut/cameradar/actions">
<img src="https://img.shields.io/github/actions/workflow/status/Ullaakut/cameradar/build" /> <img src="https://img.shields.io/github/actions/workflow/status/Ullaakut/cameradar/build.yaml" />
</a> </a>
<a href='https://coveralls.io/github/Ullaakut/cameradar?branch=master'> <a href='https://coveralls.io/github/Ullaakut/cameradar?branch=master'>
<img src='https://coveralls.io/repos/github/Ullaakut/cameradar/badge.svg?branch=master' alt='Coverage Status' /> <img src='https://coveralls.io/repos/github/Ullaakut/cameradar/badge.svg?branch=master' alt='Coverage Status' />
@@ -24,10 +24,9 @@
</a> </a>
</p> </p>
## An RTSP stream access tool with a Go library ## RTSP stream access tool
Cameradar scans RTSP endpoints on authorized targets, and uses dictionary attacks to bruteforce their credentials and routes. Cameradar scans RTSP endpoints on authorized targets, and uses dictionary attacks to bruteforce their credentials and routes.
Use the CLI for end-to-end scanning or import the library in Go code.
### What Cameradar does ### What Cameradar does
@@ -56,6 +55,10 @@ Use the CLI for end-to-end scanning or import the library in Go code.
- [Examples](#examples) - [Examples](#examples)
- [License](#license) - [License](#license)
---
<p align="center"><img src="images/example.gif"/></p>
## Quick start with Docker ## Quick start with Docker
Install [Docker](https://docs.docker.com/engine/installation/) and run: Install [Docker](https://docs.docker.com/engine/installation/) and run:
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 MiB

After

Width:  |  Height:  |  Size: 746 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

+108 -5
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"slices"
"time" "time"
"github.com/Ullaakut/cameradar/v6" "github.com/Ullaakut/cameradar/v6"
@@ -14,6 +15,8 @@ import (
// Route that should never be a constructor default. // Route that should never be a constructor default.
const dummyRoute = "/0x8b6c42" const dummyRoute = "/0x8b6c42"
const maxIncrementalRouteAttempts = 32
// Dictionary provides dictionaries for routes, usernames and passwords. // Dictionary provides dictionaries for routes, usernames and passwords.
type Dictionary interface { type Dictionary interface {
Routes() []string Routes() []string
@@ -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) msg := fmt.Sprintf("Credentials found for %s:%d", target.Address.String(), target.Port)
a.reporter.Progress(cameradar.StepAttackCredentials, msg) 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) time.Sleep(a.attackInterval)
} }
@@ -257,7 +265,7 @@ func (a Attacker) attackRoutesForStream(ctx context.Context, target cameradar.St
} }
if ok { if ok {
target.RouteFound = true 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)) a.reporter.Progress(cameradar.StepAttackRoutes, fmt.Sprintf("Default route accepted for %s:%d", target.Address.String(), target.Port))
return target, nil return target, nil
} }
@@ -279,8 +287,14 @@ func (a Attacker) attackRoutesForStream(ctx context.Context, target cameradar.St
} }
if ok { if ok {
target.RouteFound = true 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)) 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
} }
} }
@@ -348,7 +362,22 @@ func (a Attacker) detectAuthMethod(ctx context.Context, stream cameradar.Stream)
return stream, nil 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) { 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) u, urlStr, err := buildRTSPURL(stream, route, stream.Username, stream.Password)
if err != nil { if err != nil {
return false, fmt.Errorf("building rtsp url: %w", err) return false, fmt.Errorf("building rtsp url: %w", err)
@@ -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)) 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 allowed(code), nil
return access, 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
}
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) { func (a Attacker) credAttack(stream cameradar.Stream, username, password string) (bool, error) {
+109 -32
View File
@@ -1,6 +1,7 @@
package attack_test package attack_test
import ( import (
"fmt"
"strings" "strings"
"sync" "sync"
"testing" "testing"
@@ -50,11 +51,11 @@ func TestNew(t *testing.T) {
func TestAttacker_Attack_BasicAuth(t *testing.T) { func TestAttacker_Attack_BasicAuth(t *testing.T) {
addr, port := startRTSPServer(t, rtspServerConfig{ addr, port := startRTSPServer(t, rtspServerConfig{
allowedRoute: "stream", allowRoutes: []string{"stream"},
requireAuth: true, requireAuth: true,
username: "user", username: "user",
password: "pass", password: "pass",
authMethod: headers.AuthMethodBasic, authMethod: headers.AuthMethodBasic,
}) })
dict := testDictionary{ dict := testDictionary{
@@ -101,9 +102,9 @@ func TestAttacker_Attack_AuthVariants(t *testing.T) {
{ {
name: "no authentication", name: "no authentication",
config: rtspServerConfig{ config: rtspServerConfig{
allowedRoute: "stream", allowRoutes: []string{"stream"},
requireAuth: false, requireAuth: false,
authMethod: headers.AuthMethodBasic, authMethod: headers.AuthMethodBasic,
}, },
dict: testDictionary{ dict: testDictionary{
routes: []string{"stream"}, routes: []string{"stream"},
@@ -117,11 +118,11 @@ func TestAttacker_Attack_AuthVariants(t *testing.T) {
{ {
name: "digest authentication", name: "digest authentication",
config: rtspServerConfig{ config: rtspServerConfig{
allowedRoute: "stream", allowRoutes: []string{"stream"},
requireAuth: true, requireAuth: true,
username: "user", username: "user",
password: "pass", password: "pass",
authMethod: headers.AuthMethodDigest, authMethod: headers.AuthMethodDigest,
}, },
dict: testDictionary{ dict: testDictionary{
routes: []string{"stream"}, routes: []string{"stream"},
@@ -193,9 +194,9 @@ func TestAttacker_Attack_ValidationErrors(t *testing.T) {
func TestAttacker_Attack_ReturnsErrorWhenRouteMissing(t *testing.T) { func TestAttacker_Attack_ReturnsErrorWhenRouteMissing(t *testing.T) {
addr, port := startRTSPServer(t, rtspServerConfig{ addr, port := startRTSPServer(t, rtspServerConfig{
allowedRoute: "stream", allowRoutes: []string{"stream"},
requireAuth: false, requireAuth: false,
authMethod: headers.AuthMethodBasic, authMethod: headers.AuthMethodBasic,
}) })
dict := testDictionary{ dict := testDictionary{
@@ -221,11 +222,11 @@ func TestAttacker_Attack_ReturnsErrorWhenRouteMissing(t *testing.T) {
func TestAttacker_Attack_ReturnsErrorWhenCredentialsMissing(t *testing.T) { func TestAttacker_Attack_ReturnsErrorWhenCredentialsMissing(t *testing.T) {
addr, port := startRTSPServer(t, rtspServerConfig{ addr, port := startRTSPServer(t, rtspServerConfig{
allowedRoute: "stream", allowRoutes: []string{"stream"},
requireAuth: true, requireAuth: true,
username: "user", username: "user",
password: "pass", password: "pass",
authMethod: headers.AuthMethodBasic, authMethod: headers.AuthMethodBasic,
}) })
dict := testDictionary{ dict := testDictionary{
@@ -254,12 +255,12 @@ func TestAttacker_Attack_CredentialAttemptFails(t *testing.T) {
reporter := &recordingReporter{} reporter := &recordingReporter{}
addr, port := startRTSPServer(t, rtspServerConfig{ addr, port := startRTSPServer(t, rtspServerConfig{
allowedRoute: "stream", allowRoutes: []string{"stream"},
requireAuth: true, requireAuth: true,
username: "user", username: "user",
password: "pass", password: "pass",
authMethod: headers.AuthMethodBasic, authMethod: headers.AuthMethodBasic,
failOnAuth: true, failOnAuth: true,
}) })
dict := testDictionary{ dict := testDictionary{
@@ -310,10 +311,10 @@ func TestAttacker_Attack_AllowsDummyRoute(t *testing.T) {
func TestAttacker_Attack_ValidationFailsWhenSetupErrors(t *testing.T) { func TestAttacker_Attack_ValidationFailsWhenSetupErrors(t *testing.T) {
addr, port := startRTSPServer(t, rtspServerConfig{ addr, port := startRTSPServer(t, rtspServerConfig{
allowedRoute: "stream", allowRoutes: []string{"stream"},
requireAuth: false, requireAuth: false,
authMethod: headers.AuthMethodBasic, authMethod: headers.AuthMethodBasic,
setupStatus: base.StatusUnsupportedTransport, setupStatus: base.StatusUnsupportedTransport,
}) })
dict := testDictionary{ dict := testDictionary{
@@ -335,6 +336,71 @@ func TestAttacker_Attack_ValidationFailsWhenSetupErrors(t *testing.T) {
assert.True(t, got[0].RouteFound) 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 { type testDictionary struct {
routes []string routes []string
usernames []string usernames []string
@@ -376,9 +442,10 @@ func (r *recordingReporter) Summary([]cameradar.Stream, error) {}
func (r *recordingReporter) Close() {} func (r *recordingReporter) Close() {}
func (r *recordingReporter) HasDebugContaining(value string) bool { func (r *recordingReporter) ContainsDebug(value string) bool {
r.mu.Lock() r.mu.Lock()
defer r.mu.Unlock() defer r.mu.Unlock()
for _, message := range r.debugMessages { for _, message := range r.debugMessages {
if strings.Contains(message, value) { if strings.Contains(message, value) {
return true return true
@@ -386,3 +453,13 @@ func (r *recordingReporter) HasDebugContaining(value string) bool {
} }
return false return false
} }
func countRoute(routes []string, route string) int {
count := 0
for _, value := range routes {
if value == route {
count++
}
}
return count
}
+192
View File
@@ -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)
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
}
}
+151
View File
@@ -0,0 +1,151 @@
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 TestDetectIncrementalRoute_OverflowAtEndFallsBack(t *testing.T) {
// The trailing token overflows strconv.Atoi, so we fall back to earlier numbers.
route := "/foo1/bar999999999999999999999999999999"
match, ok := detectIncrementalRoute(route)
require.True(t, ok)
assert.False(t, match.isChannel)
assert.Equal(t, 1, match.number)
assert.Equal(t, "/foo", match.prefix)
assert.Equal(t, "/bar999999999999999999999999999999", match.suffix)
}
func TestDetectIncrementalRoute_ChannelKeywordShouldNotBindAcrossParams(t *testing.T) {
// The channel keyword should not bind to digits in other query parameters.
route := "/path?channelname=foo&version=12"
match, ok := detectIncrementalRoute(route)
require.True(t, ok)
assert.False(t, match.isChannel)
assert.Equal(t, 12, match.number)
assert.Equal(t, "/path?channelname=foo&version=", match.prefix)
assert.Equal(t, "", match.suffix)
}
func TestDetectIncrementalRoute_ChannelKeywordStopsAtDelimiter(t *testing.T) {
// Digits after a delimiter should not be associated with a channel keyword.
route := "/path/channel?channel=foo/7"
match, ok := detectIncrementalRoute(route)
require.True(t, ok)
assert.False(t, match.isChannel)
assert.Equal(t, 7, match.number)
assert.Equal(t, "/path/channel?channel=foo/", match.prefix)
assert.Equal(t, "", match.suffix)
}
func TestDetectIncrementalRoute_ChannelKeywordWithoutDigitsFallsBack(t *testing.T) {
// channel keyword without digits should fall back to last numeric token.
route := "/path/channel?channel=foo&stream=9"
match, ok := detectIncrementalRoute(route)
require.True(t, ok)
assert.False(t, match.isChannel)
assert.Equal(t, 9, match.number)
assert.Equal(t, "/path/channel?channel=foo&stream=", match.prefix)
assert.Equal(t, "", match.suffix)
}
func TestDetectIncrementalRoute_ChannelKeywordKeepsQueryDigits(t *testing.T) {
// channel keyword with query param digits should be detected as channel.
route := "/path?channel=03&other=1"
match, ok := detectIncrementalRoute(route)
require.True(t, ok)
assert.True(t, match.isChannel)
assert.Equal(t, 3, match.number)
assert.Equal(t, 2, match.width)
assert.Equal(t, "/path?channel=", match.prefix)
assert.Equal(t, "&other=1", match.suffix)
}
func TestDetectIncrementalRoute_ChannelKeywordMultipleMatchesUsesKeywordPriority(t *testing.T) {
// Keyword priority should win even if another keyword appears earlier in the route.
route := "/path?channel=1&channelid=9"
match, ok := detectIncrementalRoute(route)
require.True(t, ok)
assert.True(t, match.isChannel)
assert.Equal(t, 9, match.number)
assert.Equal(t, "/path?channel=1&channelid=", match.prefix)
assert.Equal(t, "", match.suffix)
}
func TestDetectIncrementalRoute_ChannelKeywordSelectsLastMatchWithinKeyword(t *testing.T) {
// The last match for a given keyword should be selected.
route := "/path?channel=1&foo=bar&channel=4"
match, ok := detectIncrementalRoute(route)
require.True(t, ok)
assert.True(t, match.isChannel)
assert.Equal(t, 4, match.number)
assert.Equal(t, "/path?channel=1&foo=bar&channel=", match.prefix)
assert.Equal(t, "", match.suffix)
}
func TestBuildIncrementedRoute_ZeroPadding(t *testing.T) {
match := incrementalRoute{
prefix: "/channel",
suffix: "/stream",
number: 1,
width: 3,
}
assert.Equal(t, "/channel002/stream", buildIncrementedRoute(match, 2))
}
+35 -26
View File
@@ -18,27 +18,27 @@ import (
) )
type rtspServerConfig struct { type rtspServerConfig struct {
allowAll bool allowAll bool
allowedRoute string allowRoutes []string
requireAuth bool requireAuth bool
username string username string
password string password string
authMethod headers.AuthMethod authMethod headers.AuthMethod
authHeader base.HeaderValue authHeader base.HeaderValue
failOnAuth bool failOnAuth bool
setupStatus base.StatusCode setupStatus base.StatusCode
} }
type testServerHandler struct { type testServerHandler struct {
stream *gortsplib.ServerStream stream *gortsplib.ServerStream
allowAll bool allowAll bool
allowedRoute string allowRoutes []string
requireAuth bool requireAuth bool
username string username string
password string password string
authHeader base.HeaderValue authHeader base.HeaderValue
failOnAuth bool failOnAuth bool
setupStatus base.StatusCode setupStatus base.StatusCode
} }
func (h *testServerHandler) OnDescribe(ctx *gortsplib.ServerHandlerOnDescribeCtx) (*base.Response, *gortsplib.ServerStream, error) { func (h *testServerHandler) OnDescribe(ctx *gortsplib.ServerHandlerOnDescribeCtx) (*base.Response, *gortsplib.ServerStream, error) {
@@ -86,20 +86,29 @@ func (h *testServerHandler) OnSetup(ctx *gortsplib.ServerHandlerOnSetupCtx) (*ba
func (h *testServerHandler) routeAllowed(path string) bool { func (h *testServerHandler) routeAllowed(path string) bool {
path = strings.TrimLeft(path, "/") path = strings.TrimLeft(path, "/")
return h.allowAll || path == h.allowedRoute if h.allowAll {
return true
}
for _, route := range h.allowRoutes {
if path == route {
return true
}
}
return false
} }
func startRTSPServer(t *testing.T, cfg rtspServerConfig) (netip.Addr, uint16) { func startRTSPServer(t *testing.T, cfg rtspServerConfig) (netip.Addr, uint16) {
t.Helper() t.Helper()
handler := &testServerHandler{ handler := &testServerHandler{
allowAll: cfg.allowAll, allowAll: cfg.allowAll,
allowedRoute: cfg.allowedRoute, allowRoutes: cfg.allowRoutes,
requireAuth: cfg.requireAuth, requireAuth: cfg.requireAuth,
username: cfg.username, username: cfg.username,
password: cfg.password, password: cfg.password,
failOnAuth: cfg.failOnAuth, failOnAuth: cfg.failOnAuth,
setupStatus: cfg.setupStatus, setupStatus: cfg.setupStatus,
} }
if len(cfg.authHeader) > 0 { if len(cfg.authHeader) > 0 {
+6 -3
View File
@@ -13,7 +13,6 @@ import (
func TestNew_ExpandsTargetsAndPorts(t *testing.T) { func TestNew_ExpandsTargetsAndPorts(t *testing.T) {
targets := []string{ targets := []string{
"192.0.2.0/30", "192.0.2.0/30",
"localhost",
"192.0.2.15", "192.0.2.15",
"192.0.2.10-11", "192.0.2.10-11",
} }
@@ -25,7 +24,6 @@ func TestNew_ExpandsTargetsAndPorts(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
addrs := []netip.Addr{ addrs := []netip.Addr{
netip.MustParseAddr("127.0.0.1"),
netip.MustParseAddr("192.0.2.0"), netip.MustParseAddr("192.0.2.0"),
netip.MustParseAddr("192.0.2.1"), netip.MustParseAddr("192.0.2.1"),
netip.MustParseAddr("192.0.2.2"), netip.MustParseAddr("192.0.2.2"),
@@ -92,7 +90,12 @@ func TestNew_ResolvesHostnames(t *testing.T) {
streams, err := scanner.Scan(t.Context()) streams, err := scanner.Scan(t.Context())
require.NoError(t, err) require.NoError(t, err)
require.NotEmpty(t, streams) require.NotEmpty(t, streams)
assert.Equal(t, netip.MustParseAddr("127.0.0.1"), streams[0].Address) addr := streams[0].Address
assert.True(t,
addr == netip.MustParseAddr("127.0.0.1") || addr == netip.MustParseAddr("::1"),
"expected localhost to resolve to 127.0.0.1 or ::1, got %s",
addr.String(),
)
} }
func TestNew_ReturnsErrorOnHostnameLookupFailure(t *testing.T) { func TestNew_ReturnsErrorOnHostnameLookupFailure(t *testing.T) {
-5
View File
@@ -1,5 +0,0 @@
#EXTM3U
#EXTINF:-1,127.0.0.1:8554 (GStreamer rtspd)
rtsp://admin:12345@127.0.0.1:8554/live.sdp
#EXTINF:-1,127.0.0.1:8555 (GStreamer rtspd)
rtsp://admin:12345@127.0.0.1:8555/live.sdp