8531c006d4
* enhancement: supporting http tunneled rtsp * refactor: simplify HTTP tunnel support per review feedback - Extract streamCandidate() for nmap port classification - Add isCommonHTTPPort() for masscan and nmap fallback - Move URL building to Stream.String() and Stream.URL() - Pass Stream directly to attack methods instead of individual args - Add TLS config for HTTPS tunnel support - Make auth detection non-fatal for tunneled streams - Rename HTTPTunnel to UseHTTPTunnel * - Testing the auth workflow for the tunneled streams is not blocking the rest of the pipeline since I changed the return values to Auth unknown and nil - added extra ports in the test according to suggestions * fixing some lint errors * removing the unused buildrtspurl * delayed the urlstream call for clarity removed error messages refactored the test that used the deprecated buildTRSPurl to use stream.URL and stream.String() methods * extracting iscommonHTTP port to pkg/ports (package ports) switching on u.scheme to create proper schemes for http and https * refactor: replace HTTP tunnel bool with scheme-based detection; enable TLS only for HTTPS-tunneled streams * chore: simnplify InferTunnelScheme and newRTSPClient * fix: remove rendundant check in streamCandidate * fix: typo in parseScheme * tests: coverage for new schemes * fix: use RTSP and not RTSPS for HTTPS URLs * fix: tunneled RTSP scheme handling and auth detection fallback * ui: render empty credentials as none in summary and TUI * chore: ignore duplicate-string warning for none literal * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix: rtsps probe headers --------- Co-authored-by: Brendan Le Glaunec <brendan@glaulabs.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
218 lines
4.9 KiB
Go
218 lines
4.9 KiB
Go
package attack
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"crypto/tls"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/textproto"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/Ullaakut/cameradar/v6"
|
|
"github.com/bluenviron/gortsplib/v5"
|
|
"github.com/bluenviron/gortsplib/v5/pkg/base"
|
|
"github.com/bluenviron/gortsplib/v5/pkg/headers"
|
|
"github.com/bluenviron/gortsplib/v5/pkg/liberrors"
|
|
)
|
|
|
|
const (
|
|
schemeRTSP = "rtsp"
|
|
schemeRTSPS = "rtsps"
|
|
schemeHTTP = "http"
|
|
schemeHTTPS = "https"
|
|
)
|
|
|
|
func (a Attacker) newRTSPClient(stream cameradar.Stream) (*gortsplib.Client, error) {
|
|
u, err := stream.URL()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("building rtsp url: %w", err)
|
|
}
|
|
if u.Scheme != schemeRTSP && u.Scheme != schemeRTSPS {
|
|
return nil, fmt.Errorf("unsupported rtsp url scheme: %q", u.Scheme)
|
|
}
|
|
|
|
client := &gortsplib.Client{
|
|
ReadTimeout: a.timeout,
|
|
WriteTimeout: a.timeout,
|
|
Scheme: u.Scheme,
|
|
Host: u.Host,
|
|
}
|
|
|
|
switch stream.Scheme {
|
|
case "":
|
|
// No explicit transport was requested. Use plain RTSP/RTSPS from the URL.
|
|
case schemeRTSP, schemeRTSPS:
|
|
// Nothing to do.
|
|
case schemeHTTP:
|
|
client.Scheme = schemeRTSP
|
|
client.Tunnel = gortsplib.TunnelHTTP
|
|
case schemeHTTPS:
|
|
client.Scheme = schemeRTSPS
|
|
client.Tunnel = gortsplib.TunnelHTTP
|
|
client.TLSConfig = &tls.Config{InsecureSkipVerify: true}
|
|
default:
|
|
return nil, fmt.Errorf("unsupported stream transport scheme: %q", stream.Scheme)
|
|
}
|
|
|
|
err = client.Start()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return client, nil
|
|
}
|
|
|
|
func (a Attacker) describeStatus(stream cameradar.Stream) (base.StatusCode, error) {
|
|
u, err := stream.URL()
|
|
if err != nil {
|
|
return 0, fmt.Errorf("building rtsp url: %w", err)
|
|
}
|
|
|
|
client, err := a.newRTSPClient(stream)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer client.Close()
|
|
|
|
_, res, err := client.Describe(u)
|
|
if err != nil {
|
|
var badStatus liberrors.ErrClientBadStatusCode
|
|
if errors.As(err, &badStatus) {
|
|
return badStatus.Code, nil
|
|
}
|
|
return 0, err
|
|
}
|
|
if res == nil {
|
|
return 0, errors.New("no response received")
|
|
}
|
|
|
|
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) (base.StatusCode, base.Header, error) {
|
|
dialer := &net.Dialer{Timeout: a.timeout}
|
|
|
|
var (
|
|
conn net.Conn
|
|
err error
|
|
)
|
|
switch u.Scheme {
|
|
case schemeRTSP:
|
|
conn, err = dialer.DialContext(ctx, "tcp", u.Host)
|
|
case schemeRTSPS:
|
|
tlsDialer := &tls.Dialer{
|
|
NetDialer: dialer,
|
|
Config: &tls.Config{InsecureSkipVerify: true},
|
|
}
|
|
conn, err = tlsDialer.DialContext(ctx, "tcp", u.Host)
|
|
default:
|
|
return 0, nil, fmt.Errorf("unsupported rtsp url scheme: %q", u.Scheme)
|
|
}
|
|
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",
|
|
u,
|
|
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.AuthUnknown
|
|
}
|
|
|
|
var hasBasic bool
|
|
var hasDigest bool
|
|
|
|
for _, value := range values {
|
|
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
|
|
}
|
|
|
|
switch authHeader.Method {
|
|
case headers.AuthMethodDigest:
|
|
hasDigest = true
|
|
case headers.AuthMethodBasic:
|
|
hasBasic = true
|
|
}
|
|
}
|
|
|
|
if hasDigest {
|
|
return cameradar.AuthDigest
|
|
}
|
|
if hasBasic {
|
|
return cameradar.AuthBasic
|
|
}
|
|
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
|
|
}
|