Compare commits

..

1 Commits

Author SHA1 Message Date
Brendan Le Glaunec af41fc6cb8 fix: no longer give up on detecting auth type when getting a 401 (#391) 2026-02-01 20:59:26 +01:00
10 changed files with 468 additions and 669 deletions
+42 -172
View File
@@ -4,18 +4,17 @@ import (
"context"
"errors"
"fmt"
"slices"
"time"
"github.com/Ullaakut/cameradar/v6"
"github.com/bluenviron/gortsplib/v5"
"github.com/bluenviron/gortsplib/v5/pkg/base"
"github.com/bluenviron/gortsplib/v5/pkg/description"
"github.com/bluenviron/gortsplib/v5/pkg/liberrors"
)
// Route that should never be a constructor default.
const dummyRoute = "/0x8b6c42"
const maxIncrementalRouteAttempts = 32
const dummyRoute = "0x8b6c42"
// Dictionary provides dictionaries for routes, usernames and passwords.
type Dictionary interface {
@@ -190,6 +189,7 @@ func (a Attacker) reattackRoutes(ctx context.Context, streams []cameradar.Stream
func needsReattack(streams []cameradar.Stream) bool {
for _, stream := range streams {
if stream.RouteFound && stream.CredentialsFound && stream.Available {
// This stream is fully discovered, no need to re-attack.
continue
}
return true
@@ -235,12 +235,7 @@ 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)
updated, err := a.tryIncrementalRoutes(ctx, target, target.Route(), true)
if err != nil {
return target, err
}
return updated, nil
return target, nil
}
time.Sleep(a.attackInterval)
}
@@ -265,7 +260,7 @@ func (a Attacker) attackRoutesForStream(ctx context.Context, target cameradar.St
}
if ok {
target.RouteFound = true
target.Routes = appendRouteIfMissing(target.Routes, "/")
target.Routes = append(target.Routes, "") // Add empty route for default.
a.reporter.Progress(cameradar.StepAttackRoutes, fmt.Sprintf("Default route accepted for %s:%d", target.Address.String(), target.Port))
return target, nil
}
@@ -287,97 +282,15 @@ func (a Attacker) attackRoutesForStream(ctx context.Context, target cameradar.St
}
if ok {
target.RouteFound = true
target.Routes = appendRouteIfMissing(target.Routes, route)
target.Routes = append(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
}
}
return target, nil
}
func (a Attacker) detectAuthMethods(ctx context.Context, targets []cameradar.Stream) ([]cameradar.Stream, error) {
streams, err := runParallel(ctx, targets, a.detectAuthMethod)
if err != nil {
return streams, err
}
for i := range streams {
a.reporter.Progress(cameradar.StepDetectAuth, cameradar.ProgressTickMessage())
var authMethod string
switch streams[i].AuthenticationType {
case cameradar.AuthNone:
authMethod = "no"
case cameradar.AuthBasic:
authMethod = "basic"
case cameradar.AuthDigest:
authMethod = "digest"
default:
return streams, fmt.Errorf("unknown authentication method %d for %s:%d", streams[i].AuthenticationType, streams[i].Address.String(), streams[i].Port)
}
a.reporter.Progress(cameradar.StepDetectAuth, fmt.Sprintf("Detected %s authentication for %s:%d", authMethod, streams[i].Address.String(), streams[i].Port))
}
return streams, nil
}
func (a Attacker) detectAuthMethod(ctx context.Context, stream cameradar.Stream) (cameradar.Stream, error) {
if ctx.Err() != nil {
return stream, ctx.Err()
}
u, urlStr, err := buildRTSPURL(stream, stream.Route(), "", "")
if err != nil {
return stream, fmt.Errorf("building rtsp url: %w", err)
}
client, err := a.newRTSPClient(u)
if err != nil {
return stream, fmt.Errorf("starting rtsp client: %w", err)
}
defer client.Close()
_, res, err := client.Describe(u)
if err != nil {
var badStatus liberrors.ErrClientBadStatusCode
if errors.As(err, &badStatus) && res != nil && badStatus.Code == base.StatusUnauthorized {
stream.AuthenticationType = authTypeFromHeaders(res.Header["WWW-Authenticate"])
a.reporter.Debug(cameradar.StepDetectAuth, fmt.Sprintf("DESCRIBE %s RTSP/1.0 > %d", urlStr, badStatus.Code))
return stream, nil
}
return stream, fmt.Errorf("performing describe request at %q: %w", urlStr, err)
}
if res != nil {
a.reporter.Debug(cameradar.StepDetectAuth, fmt.Sprintf("DESCRIBE %s RTSP/1.0 > %d", urlStr, res.StatusCode))
}
stream.AuthenticationType = cameradar.AuthNone
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)
@@ -389,82 +302,8 @@ func (a Attacker) routeAttackWithStatus(stream cameradar.Stream, route string, a
}
a.reporter.Debug(cameradar.StepAttackRoutes, fmt.Sprintf("DESCRIBE %s RTSP/1.0 > %d", urlStr, code))
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
}
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)
access := code == base.StatusOK || code == base.StatusUnauthorized || code == base.StatusForbidden
return access, nil
}
func (a Attacker) credAttack(stream cameradar.Stream, username, password string) (bool, error) {
@@ -502,7 +341,7 @@ func (a Attacker) validateStream(ctx context.Context, stream cameradar.Stream, e
}
defer client.Close()
desc, res, err := client.Describe(u)
desc, res, err := a.describeWithRetry(ctx, client, u, urlStr)
if err != nil {
return a.handleDescribeError(stream, urlStr, err)
}
@@ -516,7 +355,6 @@ func (a Attacker) validateStream(ctx context.Context, stream cameradar.Stream, e
if err != nil {
return a.handleSetupError(stream, urlStr, err)
}
a.logSetupResponse(urlStr, res)
stream.Available = res != nil && res.StatusCode == base.StatusOK
@@ -527,9 +365,39 @@ func (a Attacker) validateStream(ctx context.Context, stream cameradar.Stream, e
return stream, nil
}
func (a Attacker) describeWithRetry(ctx context.Context, client *gortsplib.Client, u *base.URL, urlStr string) (*description.Session, *base.Response, error) {
var (
desc *description.Session
res *base.Response
err error
)
for range 5 {
desc, res, err = client.Describe(u)
if err == nil {
return desc, res, nil
}
var badStatus liberrors.ErrClientBadStatusCode
if errors.As(err, &badStatus) && badStatus.Code == base.StatusServiceUnavailable {
a.reporter.Debug(cameradar.StepValidateStreams, fmt.Sprintf("DESCRIBE %s RTSP/1.0 > %d (retrying)", urlStr, badStatus.Code))
select {
case <-ctx.Done():
return nil, nil, ctx.Err()
case <-time.After(time.Second):
}
continue
}
return nil, nil, err
}
return nil, nil, fmt.Errorf("describe retries exhausted for %q: %w", urlStr, err)
}
func (a Attacker) handleDescribeError(stream cameradar.Stream, urlStr string, err error) (cameradar.Stream, error) {
var badStatus liberrors.ErrClientBadStatusCode
if errors.As(err, &badStatus) && badStatus.Code == base.StatusServiceUnavailable {
a.reporter.Debug(cameradar.StepValidateStreams, fmt.Sprintf("DESCRIBE %s RTSP/1.0 > %d", urlStr, badStatus.Code))
a.reporter.Progress(cameradar.StepValidateStreams, fmt.Sprintf("Stream unavailable for %s:%d (RTSP %d)",
stream.Address.String(),
stream.Port,
@@ -539,6 +407,8 @@ func (a Attacker) handleDescribeError(stream cameradar.Stream, urlStr string, er
return stream, nil
}
a.reporter.Debug(cameradar.StepValidateStreams, fmt.Sprintf("DESCRIBE %s RTSP/1.0 > error: %v", urlStr, err))
return stream, fmt.Errorf("performing describe request at %q: %w", urlStr, err)
}
+34 -111
View File
@@ -1,7 +1,6 @@
package attack_test
import (
"fmt"
"strings"
"sync"
"testing"
@@ -51,11 +50,11 @@ func TestNew(t *testing.T) {
func TestAttacker_Attack_BasicAuth(t *testing.T) {
addr, port := startRTSPServer(t, rtspServerConfig{
allowRoutes: []string{"stream"},
requireAuth: true,
username: "user",
password: "pass",
authMethod: headers.AuthMethodBasic,
allowedRoute: "stream",
requireAuth: true,
username: "user",
password: "pass",
authMethod: headers.AuthMethodBasic,
})
dict := testDictionary{
@@ -102,9 +101,9 @@ func TestAttacker_Attack_AuthVariants(t *testing.T) {
{
name: "no authentication",
config: rtspServerConfig{
allowRoutes: []string{"stream"},
requireAuth: false,
authMethod: headers.AuthMethodBasic,
allowedRoute: "stream",
requireAuth: false,
authMethod: headers.AuthMethodBasic,
},
dict: testDictionary{
routes: []string{"stream"},
@@ -118,11 +117,11 @@ func TestAttacker_Attack_AuthVariants(t *testing.T) {
{
name: "digest authentication",
config: rtspServerConfig{
allowRoutes: []string{"stream"},
requireAuth: true,
username: "user",
password: "pass",
authMethod: headers.AuthMethodDigest,
allowedRoute: "stream",
requireAuth: true,
username: "user",
password: "pass",
authMethod: headers.AuthMethodDigest,
},
dict: testDictionary{
routes: []string{"stream"},
@@ -194,9 +193,9 @@ func TestAttacker_Attack_ValidationErrors(t *testing.T) {
func TestAttacker_Attack_ReturnsErrorWhenRouteMissing(t *testing.T) {
addr, port := startRTSPServer(t, rtspServerConfig{
allowRoutes: []string{"stream"},
requireAuth: false,
authMethod: headers.AuthMethodBasic,
allowedRoute: "stream",
requireAuth: false,
authMethod: headers.AuthMethodBasic,
})
dict := testDictionary{
@@ -215,18 +214,18 @@ func TestAttacker_Attack_ReturnsErrorWhenRouteMissing(t *testing.T) {
got, err := attacker.Attack(t.Context(), streams)
require.Error(t, err)
assert.ErrorContains(t, err, "detecting authentication methods")
assert.ErrorContains(t, err, "validating streams")
require.Len(t, got, 1)
assert.False(t, got[0].RouteFound)
}
func TestAttacker_Attack_ReturnsErrorWhenCredentialsMissing(t *testing.T) {
addr, port := startRTSPServer(t, rtspServerConfig{
allowRoutes: []string{"stream"},
requireAuth: true,
username: "user",
password: "pass",
authMethod: headers.AuthMethodBasic,
allowedRoute: "stream",
requireAuth: true,
username: "user",
password: "pass",
authMethod: headers.AuthMethodBasic,
})
dict := testDictionary{
@@ -255,12 +254,12 @@ func TestAttacker_Attack_CredentialAttemptFails(t *testing.T) {
reporter := &recordingReporter{}
addr, port := startRTSPServer(t, rtspServerConfig{
allowRoutes: []string{"stream"},
requireAuth: true,
username: "user",
password: "pass",
authMethod: headers.AuthMethodBasic,
failOnAuth: true,
allowedRoute: "stream",
requireAuth: true,
username: "user",
password: "pass",
authMethod: headers.AuthMethodBasic,
failOnAuth: true,
})
dict := testDictionary{
@@ -305,16 +304,16 @@ func TestAttacker_Attack_AllowsDummyRoute(t *testing.T) {
require.NoError(t, err)
require.Len(t, got, 1)
assert.True(t, got[0].RouteFound)
assert.Equal(t, []string{"/"}, got[0].Routes)
assert.Equal(t, []string{""}, got[0].Routes)
assert.True(t, got[0].Available)
}
func TestAttacker_Attack_ValidationFailsWhenSetupErrors(t *testing.T) {
addr, port := startRTSPServer(t, rtspServerConfig{
allowRoutes: []string{"stream"},
requireAuth: false,
authMethod: headers.AuthMethodBasic,
setupStatus: base.StatusUnsupportedTransport,
allowedRoute: "stream",
requireAuth: false,
authMethod: headers.AuthMethodBasic,
setupStatus: base.StatusUnsupportedTransport,
})
dict := testDictionary{
@@ -336,71 +335,6 @@ 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
@@ -442,10 +376,9 @@ func (r *recordingReporter) Summary([]cameradar.Stream, error) {}
func (r *recordingReporter) Close() {}
func (r *recordingReporter) ContainsDebug(value string) bool {
func (r *recordingReporter) HasDebugContaining(value string) bool {
r.mu.Lock()
defer r.mu.Unlock()
for _, message := range r.debugMessages {
if strings.Contains(message, value) {
return true
@@ -453,13 +386,3 @@ func (r *recordingReporter) ContainsDebug(value string) bool {
}
return false
}
func countRoute(routes []string, route string) int {
count := 0
for _, value := range routes {
if value == route {
count++
}
}
return count
}
+68
View File
@@ -0,0 +1,68 @@
package attack
import (
"context"
"fmt"
"github.com/Ullaakut/cameradar/v6"
"github.com/bluenviron/gortsplib/v5/pkg/base"
)
func (a Attacker) detectAuthMethods(ctx context.Context, targets []cameradar.Stream) ([]cameradar.Stream, error) {
streams, err := runParallel(ctx, targets, a.detectAuthMethod)
if err != nil {
return streams, err
}
for i := range streams {
a.reporter.Progress(cameradar.StepDetectAuth, cameradar.ProgressTickMessage())
var authMethod string
switch streams[i].AuthenticationType {
case cameradar.AuthNone:
authMethod = "no"
case cameradar.AuthBasic:
authMethod = "basic"
case cameradar.AuthDigest:
authMethod = "digest"
case cameradar.AuthUnknown:
authMethod = "unknown"
default:
authMethod = fmt.Sprintf("unknown (%d)", streams[i].AuthenticationType)
}
a.reporter.Progress(cameradar.StepDetectAuth, fmt.Sprintf("Detected %s authentication for %s:%d", authMethod, streams[i].Address.String(), streams[i].Port))
}
return streams, nil
}
func (a Attacker) detectAuthMethod(ctx context.Context, stream cameradar.Stream) (cameradar.Stream, error) {
if ctx.Err() != nil {
return stream, ctx.Err()
}
u, urlStr, err := buildRTSPURL(stream, stream.Route(), "", "")
if err != nil {
return stream, fmt.Errorf("building rtsp url: %w", err)
}
statusCode, headers, err := a.probeDescribeHeaders(ctx, u, urlStr)
if err != nil {
a.reporter.Debug(cameradar.StepDetectAuth, fmt.Sprintf("DESCRIBE %s RTSP/1.0 > error: %v", urlStr, err))
stream.AuthenticationType = cameradar.AuthUnknown
return stream, fmt.Errorf("performing describe request at %q: %w", urlStr, err)
}
a.reporter.Debug(cameradar.StepDetectAuth, fmt.Sprintf("DESCRIBE %s RTSP/1.0 > %d", urlStr, statusCode))
values := headerValues(headers, "WWW-Authenticate")
switch statusCode {
case base.StatusOK:
stream.AuthenticationType = cameradar.AuthNone
case base.StatusUnauthorized:
stream.AuthenticationType = authTypeFromHeaders(values)
default:
stream.AuthenticationType = cameradar.AuthUnknown
}
return stream, nil
}
+207
View File
@@ -0,0 +1,207 @@
package attack
import (
"bufio"
"fmt"
"net"
"net/netip"
"strings"
"testing"
"time"
"github.com/Ullaakut/cameradar/v6"
"github.com/Ullaakut/cameradar/v6/internal/ui"
"github.com/bluenviron/gortsplib/v5/pkg/base"
"github.com/bluenviron/gortsplib/v5/pkg/headers"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type testDictionary struct {
routes []string
usernames []string
passwords []string
}
func (d testDictionary) Routes() []string {
return d.routes
}
func (d testDictionary) Usernames() []string {
return d.usernames
}
func (d testDictionary) Passwords() []string {
return d.passwords
}
func TestAuthTypeFromHeaders(t *testing.T) {
tests := []struct {
name string
values base.HeaderValue
want cameradar.AuthType
}{
{
name: "digest wins over basic",
values: base.HeaderValue{
headers.Authenticate{Method: headers.AuthMethodBasic, Realm: "cam"}.Marshal()[0],
headers.Authenticate{Method: headers.AuthMethodDigest, Realm: "cam", Nonce: "nonce"}.Marshal()[0],
},
want: cameradar.AuthDigest,
},
{
name: "basic auth",
values: headers.Authenticate{Method: headers.AuthMethodBasic, Realm: "cam"}.Marshal(),
want: cameradar.AuthBasic,
},
{
name: "digest auth",
values: headers.Authenticate{Method: headers.AuthMethodDigest, Realm: "cam", Nonce: "nonce"}.Marshal(),
want: cameradar.AuthDigest,
},
{
name: "unknown with empty values",
values: nil,
want: cameradar.AuthUnknown,
},
{
name: "unknown with unsupported header",
values: base.HeaderValue{"Bearer abc"},
want: cameradar.AuthUnknown,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
assert.Equal(t, test.want, authTypeFromHeaders(test.values))
})
}
}
func TestDetectAuthMethod(t *testing.T) {
tests := []struct {
name string
statusCode base.StatusCode
headers base.Header
want cameradar.AuthType
}{
{
name: "no auth when status ok",
statusCode: base.StatusOK,
headers: base.Header{
"WWW-Authenticate": headers.Authenticate{Method: headers.AuthMethodBasic, Realm: "cam"}.Marshal(),
},
want: cameradar.AuthNone,
},
{
name: "basic auth on unauthorized",
statusCode: base.StatusUnauthorized,
headers: base.Header{
"WWW-Authenticate": headers.Authenticate{Method: headers.AuthMethodBasic, Realm: "cam"}.Marshal(),
},
want: cameradar.AuthBasic,
},
{
name: "digest auth on unauthorized",
statusCode: base.StatusUnauthorized,
headers: base.Header{
"WWW-Authenticate": headers.Authenticate{Method: headers.AuthMethodDigest, Realm: "cam", Nonce: "nonce"}.Marshal(),
},
want: cameradar.AuthDigest,
},
{
name: "unknown auth on unauthorized without www-authenticate",
statusCode: base.StatusUnauthorized,
headers: nil,
want: cameradar.AuthUnknown,
},
{
name: "unknown auth on other status",
statusCode: base.StatusNotFound,
headers: nil,
want: cameradar.AuthUnknown,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
addr, port := startRTSPProbeServer(t, test.statusCode, test.headers)
attacker, err := New(testDictionary{}, 0, time.Second, ui.NopReporter{})
require.NoError(t, err)
stream := cameradar.Stream{
Address: addr,
Port: port,
}
got, err := attacker.detectAuthMethod(t.Context(), stream)
require.NoError(t, err)
assert.Equal(t, test.want, got.AuthenticationType)
})
}
}
func startRTSPProbeServer(t *testing.T, statusCode base.StatusCode, headers base.Header) (netip.Addr, uint16) {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
t.Cleanup(func() {
_ = listener.Close()
})
go func() {
conn, err := listener.Accept()
if err != nil {
return
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(time.Second))
reader := bufio.NewReader(conn)
for {
line, err := reader.ReadString('\n')
if err != nil {
return
}
if strings.TrimSpace(line) == "" {
break
}
}
statusText := statusTextFromCode(statusCode)
var builder strings.Builder
_, _ = fmt.Fprintf(&builder, "RTSP/1.0 %d %s\r\n", statusCode, statusText)
builder.WriteString("CSeq: 1\r\n")
for key, values := range headers {
for _, value := range values {
_, _ = fmt.Fprintf(&builder, "%s: %s\r\n", key, value)
}
}
builder.WriteString("Content-Length: 0\r\n\r\n")
_, _ = conn.Write([]byte(builder.String()))
}()
tcpAddr, ok := listener.Addr().(*net.TCPAddr)
require.True(t, ok)
return netip.MustParseAddr("127.0.0.1"), uint16(tcpAddr.Port)
}
func statusTextFromCode(code base.StatusCode) string {
switch code {
case base.StatusOK:
return "OK"
case base.StatusUnauthorized:
return "Unauthorized"
case base.StatusNotFound:
return "Not Found"
default:
return "Unknown"
}
}
-192
View File
@@ -1,192 +0,0 @@
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
@@ -1,151 +0,0 @@
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))
}
+87 -6
View File
@@ -1,10 +1,16 @@
package attack
import (
"bufio"
"context"
"errors"
"fmt"
"net"
"net/textproto"
"net/url"
"strconv"
"strings"
"time"
"github.com/Ullaakut/cameradar/v6"
"github.com/bluenviron/gortsplib/v5"
@@ -39,7 +45,7 @@ func (a Attacker) describeStatus(u *base.URL) (base.StatusCode, error) {
_, res, err := client.Describe(u)
if err != nil {
var badStatus liberrors.ErrClientBadStatusCode
if errors.As(err, &badStatus) && res != nil {
if errors.As(err, &badStatus) {
return badStatus.Code, nil
}
return 0, err
@@ -51,9 +57,69 @@ func (a Attacker) describeStatus(u *base.URL) (base.StatusCode, error) {
return res.StatusCode, nil
}
// probeDescribeHeaders performs a manual DESCRIBE request and returns the status code and headers.
//
// NOTE: We do not use gortsplib here because it does not expose response headers when the status code is 401 Unauthorized,
// which is exactly what we need in order to detect authentication methods.
func (a Attacker) probeDescribeHeaders(ctx context.Context, u *base.URL, urlStr string) (base.StatusCode, base.Header, error) {
dialer := &net.Dialer{Timeout: a.timeout}
conn, err := dialer.DialContext(ctx, "tcp", u.Host)
if err != nil {
return 0, nil, err
}
defer conn.Close()
deadline, ok := ctx.Deadline()
if !ok {
deadline = time.Now().Add(a.timeout)
}
err = conn.SetDeadline(deadline)
if err != nil {
return 0, nil, err
}
request := fmt.Sprintf(
"DESCRIBE %s RTSP/1.0\r\nCSeq: 1\r\nUser-Agent: cameradar\r\nAccept: application/sdp\r\nHost: %s\r\n\r\n",
urlStr,
u.Host,
)
_, err = conn.Write([]byte(request))
if err != nil {
return 0, nil, err
}
reader := textproto.NewReader(bufio.NewReader(conn))
statusLine, err := reader.ReadLine()
if err != nil {
return 0, nil, err
}
fields := strings.Fields(statusLine)
if len(fields) < 2 {
return 0, nil, fmt.Errorf("invalid RTSP status line: %q", statusLine)
}
code, err := strconv.Atoi(fields[1])
if err != nil {
return 0, nil, fmt.Errorf("parsing RTSP status code %q: %w", fields[1], err)
}
mimeHeader, err := reader.ReadMIMEHeader()
if err != nil {
return 0, nil, err
}
headers := make(base.Header)
for key, values := range mimeHeader {
headers[key] = append(base.HeaderValue(nil), values...)
}
return base.StatusCode(code), headers, nil
}
func authTypeFromHeaders(values base.HeaderValue) cameradar.AuthType {
if len(values) == 0 {
return cameradar.AuthNone
return cameradar.AuthUnknown
}
var hasBasic bool
@@ -63,6 +129,9 @@ func authTypeFromHeaders(values base.HeaderValue) cameradar.AuthType {
var authHeader headers.Authenticate
err := authHeader.Unmarshal(base.HeaderValue{value})
if err != nil {
lower := strings.ToLower(value)
hasDigest = hasDigest || strings.Contains(lower, "digest")
hasBasic = hasBasic || strings.Contains(lower, "basic")
continue
}
@@ -80,14 +149,26 @@ func authTypeFromHeaders(values base.HeaderValue) cameradar.AuthType {
if hasBasic {
return cameradar.AuthBasic
}
return cameradar.AuthType(-1)
return cameradar.AuthUnknown
}
func headerValues(header base.Header, name string) base.HeaderValue {
if header == nil {
return nil
}
for key, values := range header {
if strings.EqualFold(key, name) {
return values
}
}
return nil
}
func buildRTSPURL(stream cameradar.Stream, route, username, password string) (*base.URL, string, error) {
host := net.JoinHostPort(stream.Address.String(), strconv.Itoa(int(stream.Port)))
path := "/" + route
if route == "" {
path = "/"
path := strings.TrimSpace(route)
if path != "" && !strings.HasPrefix(path, "/") {
path = "/" + path
}
u := &url.URL{
+26 -35
View File
@@ -18,27 +18,27 @@ import (
)
type rtspServerConfig struct {
allowAll bool
allowRoutes []string
requireAuth bool
username string
password string
authMethod headers.AuthMethod
authHeader base.HeaderValue
failOnAuth bool
setupStatus base.StatusCode
allowAll bool
allowedRoute string
requireAuth bool
username string
password string
authMethod headers.AuthMethod
authHeader base.HeaderValue
failOnAuth bool
setupStatus base.StatusCode
}
type testServerHandler struct {
stream *gortsplib.ServerStream
allowAll bool
allowRoutes []string
requireAuth bool
username string
password string
authHeader base.HeaderValue
failOnAuth bool
setupStatus base.StatusCode
stream *gortsplib.ServerStream
allowAll bool
allowedRoute string
requireAuth bool
username string
password string
authHeader base.HeaderValue
failOnAuth bool
setupStatus base.StatusCode
}
func (h *testServerHandler) OnDescribe(ctx *gortsplib.ServerHandlerOnDescribeCtx) (*base.Response, *gortsplib.ServerStream, error) {
@@ -86,29 +86,20 @@ func (h *testServerHandler) OnSetup(ctx *gortsplib.ServerHandlerOnSetupCtx) (*ba
func (h *testServerHandler) routeAllowed(path string) bool {
path = strings.TrimLeft(path, "/")
if h.allowAll {
return true
}
for _, route := range h.allowRoutes {
if path == route {
return true
}
}
return false
return h.allowAll || path == h.allowedRoute
}
func startRTSPServer(t *testing.T, cfg rtspServerConfig) (netip.Addr, uint16) {
t.Helper()
handler := &testServerHandler{
allowAll: cfg.allowAll,
allowRoutes: cfg.allowRoutes,
requireAuth: cfg.requireAuth,
username: cfg.username,
password: cfg.password,
failOnAuth: cfg.failOnAuth,
setupStatus: cfg.setupStatus,
allowAll: cfg.allowAll,
allowedRoute: cfg.allowedRoute,
requireAuth: cfg.requireAuth,
username: cfg.username,
password: cfg.password,
failOnAuth: cfg.failOnAuth,
setupStatus: cfg.setupStatus,
}
if len(cfg.authHeader) > 0 {
+2 -1
View File
@@ -1,4 +1,5 @@
/live/ch01_0
live/ch01_0
0/1:1/main
0/usrnm:pwd/main
0/video1
+2 -1
View File
@@ -9,7 +9,8 @@ type AuthType int
// Supported authentication methods.
const (
AuthNone AuthType = iota
AuthUnknown AuthType = iota
AuthNone
AuthBasic
AuthDigest
)