diff --git a/internal/attack/attacker.go b/internal/attack/attacker.go index be2dcee..2c35a51 100644 --- a/internal/attack/attacker.go +++ b/internal/attack/attacker.go @@ -7,12 +7,14 @@ import ( "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 dummyRoute = "0x8b6c42" // Dictionary provides dictionaries for routes, usernames and passwords. type Dictionary interface { @@ -187,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 @@ -257,7 +260,7 @@ func (a Attacker) attackRoutesForStream(ctx context.Context, target cameradar.St } if ok { target.RouteFound = true - target.Routes = append(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,67 +290,6 @@ func (a Attacker) attackRoutesForStream(ctx context.Context, target cameradar.St 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 -} - func (a Attacker) routeAttack(stream cameradar.Stream, route string) (bool, error) { u, urlStr, err := buildRTSPURL(stream, route, stream.Username, stream.Password) if err != nil { @@ -399,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) } @@ -413,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 @@ -424,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, @@ -436,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) } diff --git a/internal/attack/attacker_test.go b/internal/attack/attacker_test.go index 5fbd43a..6bba26e 100644 --- a/internal/attack/attacker_test.go +++ b/internal/attack/attacker_test.go @@ -214,7 +214,7 @@ 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) } @@ -304,7 +304,7 @@ 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) } diff --git a/internal/attack/detect_auth.go b/internal/attack/detect_auth.go new file mode 100644 index 0000000..12cbdef --- /dev/null +++ b/internal/attack/detect_auth.go @@ -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 +} diff --git a/internal/attack/detect_auth_test.go b/internal/attack/detect_auth_test.go new file mode 100644 index 0000000..495b21b --- /dev/null +++ b/internal/attack/detect_auth_test.go @@ -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" + } +} diff --git a/internal/attack/rtsp.go b/internal/attack/rtsp.go index 5a5e60f..64c4b73 100644 --- a/internal/attack/rtsp.go +++ b/internal/attack/rtsp.go @@ -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{ diff --git a/internal/dict/assets/routes b/internal/dict/assets/routes index 880a49c..d61d67f 100644 --- a/internal/dict/assets/routes +++ b/internal/dict/assets/routes @@ -1,4 +1,5 @@ -/live/ch01_0 + +live/ch01_0 0/1:1/main 0/usrnm:pwd/main 0/video1 diff --git a/stream.go b/stream.go index 886fddb..c0142b9 100644 --- a/stream.go +++ b/stream.go @@ -9,7 +9,8 @@ type AuthType int // Supported authentication methods. const ( - AuthNone AuthType = iota + AuthUnknown AuthType = iota + AuthNone AuthBasic AuthDigest )