diff --git a/.travis.yml b/.travis.yml index ccf337e..96da8b6 100644 --- a/.travis.yml +++ b/.travis.yml @@ -29,20 +29,22 @@ script: # Run unit tests - go test -v -covermode=count -coverprofile=coverage.out - $HOME/gopath/bin/goveralls -coverprofile=coverage.out -service=travis-ci -repotoken=$COVERALLS_TOKEN -# Launch a fake camera to check if cameradar is able to access it -- docker run -d --name=fake_camera -e RTSP_USERNAME=admin -e RTSP_PASSWORD=12345 -p 8554:8554 ullaakut/rtspatt +# Launch fake cameras to check if cameradar is able to access them +- docker run -d --name="fake_camera_digest" -e RTSP_ROUTE="/live.sdp" -e RTSP_USERNAME="admin" -e RTSP_PASSWORD="12345" -e RTSP_AUTHENTICATION_METHOD="digest" -p 8554:8554 ullaakut/rtspatt +- docker run -d --name="fake_camera_basic" -e RTSP_ROUTE="/live.sdp" -e RTSP_USERNAME="root" -e RTSP_PASSWORD="root" -e RTSP_AUTHENTICATION_METHOD="digest" -p 5554:5554 ullaakut/rtspatt # Launch cameradar on the local machine -- docker run --net=host -t cameradar -t 0.0.0.0 -l > logs.txt -- docker logs fake_camera > camera_logs.txt -# Stop the fake camera -- docker stop fake_camera +- docker run --net=host -t cameradar -t 0.0.0.0 -p 8554,5554 -v > logs.txt +# Gather the logs from the cameras +- docker logs fake_camera_digest > camera_digest_logs.txt +- docker logs fake_camera_basic > camera_basic_logs.txt +# Stop the fake cameras +- docker stop fake_camera_basic +- docker stop fake_camera_digest # Print logs -- cat camera_logs.txt +- cat camera_digest_logs.txt +- cat camera_basic_logs.txt - cat logs.txt -# check if file contains more than one line -# 1 line: Error message because no streams were found -# More lines: Logs for all found cameras -- if [[ $(wc -l ## Issue -### What was expected? +### What was expected -### What happened? +### What happened ### Logs -If your issue is with Cameradar's binary or docker image, please run it with `-l` to print logs, and paste them here: +If your issue is with Cameradar's binary or docker image, please run it with `-v` to print verbose logs, and paste them here: ``` diff --git a/README.md b/README.md index 5ed4946..b3dd658 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Cameradar

- +

@@ -41,7 +41,7 @@ * Launch automated dictionary attacks to get the **username and password** of the cameras * Retrieve a complete and user-friendly report of the results -

+

## Table of content @@ -49,13 +49,15 @@ * [Configuration](#configuration) * [Output](#output) * [Check camera access](#check-camera-access) -* [Command line options](#command-line-options) +* [Command-line options](#command-line-options) * [Contribution](#contribution) * [Frequently Asked Questions](#frequently-asked-questions) * [License](#license) ## Docker Image for Cameradar +

+ Install [docker](https://docs.docker.com/engine/installation/) on your machine, and run the following command: ```bash @@ -64,7 +66,7 @@ docker run -t ullaakut/cameradar -t [See command-line options](#command-line-options). -e.g.: `docker run -t ullaakut/cameradar -t 192.168.100.0/24 -l` will scan the ports 554, 5554 and 8554 of hosts on the 192.168.100.0/24 subnetwork and attack the discovered RTSP streams and will output debug logs. +e.g.: `docker run -t ullaakut/cameradar -t 192.168.100.0/24` will scan the ports 554, 5554 and 8554 of hosts on the 192.168.100.0/24 subnetwork and attack the discovered RTSP streams and will output debug logs. * `YOUR_TARGET` can be a subnet (e.g.: `172.16.100.0/24`), an IP (e.g.: `172.16.100.10`), or a range of IPs (e.g.: `172.16.100.10-20`). * If you want to get the precise results of the nmap scan in the form of an XML file, you can add `-v /your/path:/tmp/cameradar_scan.xml` to the docker run command, before `ullaakut/cameradar`. @@ -89,56 +91,13 @@ Make sure you installed the dependencies mentionned above, and that you have Go The `cameradar` binary is now in your `$GOPATH/bin` ready to be used. See command line options [here](#command-line-options). -## Library - -### Dependencies of the library - -* `curl-dev` / `libcurl` (depending on your OS) -* `github.com/ullaakut/nmap` -* `github.com/pkg/errors` -* `gopkg.in/go-playground/validator.v9` -* `github.com/ullaakut/go-curl` - -#### Installing the library - -`go get github.com/ullaakut/cameradar` - -After this command, the _cameradar_ library is ready to use. Its source will be in: - - $GOPATH/src/pkg/github.com/ullaakut/cameradar - -You can use `go get -u` to update the package. - -Here is an overview of the exposed functions of this library: - -#### Discovery - -You can use the cameradar library for simple discovery purposes if you don't need to access the cameras but just to be aware of their existence. - -

-This describes the nmap time presets. You can pass a value between 1 and 5 as described in this table, to the NmapRun function. - -#### Attack - -If you already know which hosts and ports you want to attack, you can also skip the discovery part and use directly the attack functions. The attack functions also take a timeout value as a parameter. - -#### Data models - -Here are the different data models useful to use the exposed functions of the cameradar library. - -

- -#### Dictionary loaders - -The cameradar library also provides two functions that take file paths as inputs and return the appropriate data models filled. - ## Configuration The **RTSP port used for most cameras is 554**, so you should probably specify 554 as one of the ports you scan. Not specifying any ports to the cameradar application will scan the 554, 5554 and 8554 ports. -`docker run -t --net=host ullaakut/cameradar -p "18554,19000-19010" -t localhost` will scan the ports 18554, and the range of ports between 19000 and 19010 on localhost. +`docker run -t --net=host ullaakut/cameradar -p "18554,19000-19010" -t localhost` will scan the ports `18554`, and the range of ports between `19000` and `19010` on `localhost`. -You **can use your own files for the ids and routes dictionaries** used to attack the cameras, but the Cameradar repository already gives you a good base that works with most cameras, in the `/dictionaries` folder. +You **can use your own files for the credentials and routes dictionaries** used to attack the cameras, but the Cameradar repository already gives you a good base that works with most cameras, in the `/dictionaries` folder. ```bash docker run -t -v /my/folder/with/dictionaries:/tmp/dictionaries \ @@ -152,11 +111,9 @@ This will put the contents of your folder containing dictionaries in the docker ## Check camera access -If you have [VLC Media Player](http://www.videolan.org/vlc/), you should be able to use the GUI or the command-line to connect to the RTSP stream using this format : `rtsp://username:password@address:port/route` +If you have [VLC Media Player](http://www.videolan.org/vlc/), you should be able to use the GUI or the command-line to connect to the RTSP stream using this format: `rtsp://username:password@address:port/route` -With the above result, the RTSP URL would be `rtsp://admin:12345@173.16.100.45:554/live.sdp` - -## Command line options +## Command-line options * **"-t, --targets"**: Set target. Required. Target can be a file (see [instructions on how to format the file](#format-input-file)), an IP, an IP range, a subnetwork, or a combination of those. Example: `--targets="192.168.1.72,192.168.1.74"` * **"-p, --ports"**: (Default: `554,5554,8554`) Set custom ports. @@ -165,8 +122,9 @@ With the above result, the RTSP URL would be `rtsp://admin:12345@173.16.100.45:5 * **"-r, --custom-routes"**: (Default: `/dictionaries/routes`) Set custom dictionary path for routes * **"-c, --custom-credentials"**: (Default: `/dictionaries/credentials.json`) Set custom dictionary path for credentials * **"-o, --nmap-output"**: (Default: `/tmp/cameradar_scan.xml`) Set custom nmap output path -* **"-l, --log"**: Enable debug logs (nmap requests, curl describe requests, etc.) -* **"-h"** : Display the usage information +* **"-d, --debug"**: Enable debug logs +* **"-v, --verbose"**: Enable verbose curl logs (not recommended for most use) +* **"-h"**: Display the usage information ## Format input file @@ -263,7 +221,7 @@ See [the contribution document](/CONTRIBUTING.md) to get started. > Cameradar does not detect any camera! -That means that either your cameras are not streaming in RTSP or that they are not on the target you are scanning. In most cases, CCTV cameras will be on a private subnetwork, isolated from the internet. Use the `-t` option to specify your target. +That means that either your cameras are not streaming in RTSP or that they are not on the target you are scanning. In most cases, CCTV cameras will be on a private subnetwork, isolated from the internet. Use the `-t` option to specify your target. If you are sure you did everything right but it still does not work, please open an issue with details on the device you are trying to access 🙏 > Cameradar detects my cameras, but does not manage to access them at all! @@ -271,23 +229,23 @@ Maybe your cameras have been configured and the credentials / URL have been chan > What happened to the C++ version? -You can still find it under the 1.1.4 tag on this repo, however it was less performant and stable than the current version written in Golang. +You can still find it under the 1.1.4 tag on this repo, however it was less performant and stable than the current version written in Golang. It is not recommended to use it. > How to use the Cameradar library for my own project? -See the example in `/cameradar`. You just need to run `go get github.com/ullaakut/cameradar` and to use the `cmrdr` package in your code. You can find the documentation on [godoc](https://godoc.org/github.com/ullaakut/cameradar). +See the example in `/cmd/cameradar`. You just need to run `go get github.com/ullaakut/cameradar` and to use the `cameradar` package in your code. You can find the documentation on [godoc](https://godoc.org/github.com/ullaakut/cameradar). > I want to scan my own localhost for some reason and it does not work! What's going on? -Use the `--net=host` flag when launching the cameradar image, or use the binary by running `go run cameradar/cameradar.go` or [installing it](#installing-the-binary) +Use the `--net=host` flag when launching the cameradar image, or use the binary by running `go run cameradar/cameradar.go` or [installing it](#installing-the-binary). -> I don't see a colored output :( +> I don't see a colored output:( You forgot the `-t` flag before `ullaakut/cameradar` in your command-line. This tells docker to allocate a pseudo-tty for cameradar, which makes it able to use colors. > I don't have a camera but I'd like to try Cameradar! -Simply run `docker run -p 8554:8554 -e RTSP_USERNAME=admin -e RTSP_PASSWORD=12345 -e RTSP_PORT=8554 ullaakut/rtspatt` and then run cameradar and it should guess that the username is admin and the password is 12345. You can try this with any default constructor credentials (they can be found [here](dictionaries/credentials.json)) +Simply run `docker run -p 8554:8554 -e RTSP_USERNAME=admin -e RTSP_PASSWORD=12345 -e RTSP_PORT=8554 ullaakut/rtspatt` and then run cameradar and it should guess that the username is admin and the password is 12345. You can try this with any default constructor credentials (they can be found [here](dictionaries/credentials.json)). > What authentication types does Cameradar support? @@ -301,7 +259,11 @@ Cameradar supports both basic and digest authentication. > Running cameradar with an input file, logs enabled on port 8554 -`docker run -v /tmp:/tmp --net=host -t ullaakut/cameradar -t /tmp/test.txt -p 8554 -l` +`docker run -v /tmp:/tmp --net=host -t ullaakut/cameradar -t /tmp/test.txt -p 8554` + +> Running cameradar on a subnetwork with custom dictionaries, on ports 554, 5554 and 8554 + +`docker run -v /tmp:/tmp --net=host -t ullaakut/cameradar -t 192.168.0.0/24 --custom-credentials="/tmp/dictionaries/credentials.json" --custom-routes="/tmp/dictionaries/routes" -p 554,5554,8554` ## License diff --git a/attack.go b/attack.go index d76a94c..dd4f99f 100644 --- a/attack.go +++ b/attack.go @@ -1,15 +1,13 @@ -package cmrdr +package cameradar import ( "fmt" "time" - "github.com/pkg/errors" curl "github.com/ullaakut/go-curl" - v "gopkg.in/go-playground/validator.v9" ) -// HTTP responses +// HTTP responses. const ( httpOK = 200 httpUnauthorized = 401 @@ -17,134 +15,166 @@ const ( httpNotFound = 404 ) -// CURL RTSP request types +// CURL RTSP request types. const ( rtspDescribe = 2 rtspSetup = 4 ) -// ValidateStreams tries to setup the stream to validate whether or not it is available -func ValidateStreams(c Curler, targets []Stream, timeout time.Duration, log bool) ([]Stream, error) { - for i := range targets { - targets[i].Available = validateStream(c, targets[i], timeout, log) +// Attack attacks the given targets and returns the accessed streams. +func (s *Scanner) Attack(targets []Stream) ([]Stream, error) { + if len(targets) == 0 { + return nil, fmt.Errorf("unable to attack empty list of targets") } - return targets, nil + // Most cameras will be accessed successfully with these two attacks. + s.term.StartStepf("Attacking routes of %d streams", len(targets)) + streams := s.AttackRoute(targets) + + s.term.StartStepf("Attempting to detect authentication methods of %d streams", len(targets)) + streams = s.DetectAuthMethods(streams) + + s.term.StartStepf("Attacking credentials of %d streams", len(targets)) + streams = s.AttackCredentials(streams) + + // But some cameras run GST RTSP Server which prioritizes 401 over 404 contrary to most cameras. + // For these cameras, running another route attack will solve the problem. + for _, stream := range streams { + if !stream.RouteFound || !stream.CredentialsFound { + s.term.StartStepf("Second round of attacks") + streams = s.AttackRoute(streams) + + break + } + } + + s.term.StartStep("Validating that streams are accessible") + streams = s.ValidateStreams(streams) + + s.term.EndStep() + + return streams, nil +} + +// ValidateStreams tries to setup the stream to validate whether or not it is available. +func (s *Scanner) ValidateStreams(targets []Stream) []Stream { + for i := range targets { + targets[i].Available = s.validateStream(targets[i]) + } + + return targets } // AttackCredentials attempts to guess the provided targets' credentials using the given // dictionary or the default dictionary if none was provided by the user. -func AttackCredentials(c Curler, targets []Stream, credentials Credentials, timeout time.Duration, log bool) ([]Stream, error) { - attacks := make(chan Stream) - defer close(attacks) - - validate := v.New() - for _, target := range targets { - err := validate.Struct(target) - if err != nil { - return targets, errors.Wrap(err, "invalid targets") - } +func (s *Scanner) AttackCredentials(targets []Stream) []Stream { + resChan := make(chan Stream) + defer close(resChan) + for i := range targets { // TODO: Perf Improvement: Skip cameras with no auth type detected, and set their // CredentialsFound value to true. - - go attackCameraCredentials(c, target, credentials, attacks, timeout, log) + go s.attackCameraCredentials(targets[i], resChan) } attackResults := []Stream{} + // TODO: Change this into a for+select and make a successful result close the chan. for range targets { - attackResults = append(attackResults, <-attacks) + attackResults = append(attackResults, <-resChan) } - for _, result := range attackResults { - if result.CredentialsFound { - targets = replace(targets, result) + for i := range attackResults { + if attackResults[i].CredentialsFound { + targets = replace(targets, attackResults[i]) } } - return targets, nil + return targets } // AttackRoute attempts to guess the provided targets' streaming routes using the given // dictionary or the default dictionary if none was provided by the user. -func AttackRoute(c Curler, targets []Stream, routes Routes, timeout time.Duration, log bool) ([]Stream, error) { - attacks := make(chan Stream) - defer close(attacks) +func (s *Scanner) AttackRoute(targets []Stream) []Stream { + resChan := make(chan Stream) + defer close(resChan) - validate := v.New() - for _, target := range targets { - err := validate.Struct(target) - if err != nil { - return targets, errors.Wrap(err, "invalid targets") - } - - go attackCameraRoute(c, target, routes, attacks, timeout, log) + for i := range targets { + go s.attackCameraRoute(targets[i], resChan) } attackResults := []Stream{} + // TODO: Change this into a for+select and make a successful result close the chan. for range targets { - attackResults = append(attackResults, <-attacks) + attackResults = append(attackResults, <-resChan) } - for _, result := range attackResults { - if result.RouteFound { - targets = replace(targets, result) + for i := range attackResults { + if attackResults[i].RouteFound { + targets = replace(targets, attackResults[i]) } } - return targets, nil + return targets } // DetectAuthMethods attempts to guess the provided targets' authentication types, between // digest, basic auth or none at all. -func DetectAuthMethods(c Curler, targets []Stream, timeout time.Duration, log bool) ([]Stream, error) { - attacks := make(chan Stream) - defer close(attacks) - +func (s *Scanner) DetectAuthMethods(targets []Stream) []Stream { for i := range targets { - targets[i].AuthenticationType = detectAuthMethod(c, targets[i], timeout, log) + targets[i].AuthenticationType = s.detectAuthMethod(targets[i]) + + var authMethod string + switch targets[i].AuthenticationType { + case 0: + authMethod = "no" + case 1: + authMethod = "basic" + case 2: + authMethod = "digest" + } + + s.term.Debugf("Stream %s uses %s authentication method\n", GetCameraRTSPURL(targets[i]), authMethod) } - return targets, nil + return targets } -func attackCameraCredentials(c Curler, target Stream, credentials Credentials, resultsChan chan<- Stream, timeout time.Duration, log bool) { - for _, username := range credentials.Usernames { - for _, password := range credentials.Passwords { - ok := credAttack(c.Duphandle(), target, username, password, timeout, log) +func (s *Scanner) attackCameraCredentials(target Stream, resChan chan<- Stream) { + for _, username := range s.credentials.Usernames { + for _, password := range s.credentials.Passwords { + ok := s.credAttack(target, username, password) if ok { target.CredentialsFound = true target.Username = username target.Password = password - resultsChan <- target + resChan <- target return } } } + target.CredentialsFound = false - resultsChan <- target + resChan <- target } -func attackCameraRoute(c Curler, target Stream, routes Routes, resultsChan chan<- Stream, timeout time.Duration, log bool) { - for _, route := range routes { - ok := routeAttack(c.Duphandle(), target, route, timeout, log) +func (s *Scanner) attackCameraRoute(target Stream, resChan chan<- Stream) { + for _, route := range s.routes { + ok := s.routeAttack(target, route) if ok { target.RouteFound = true target.Route = route - resultsChan <- target + resChan <- target return } } + target.RouteFound = false - resultsChan <- target + resChan <- target } -// HACK: See https://stackoverflow.com/questions/3572397/lib-curl-in-c-disable-printing -func doNotWrite([]uint8, interface{}) bool { - return true -} +func (s *Scanner) detectAuthMethod(stream Stream) int { + c := s.curl.Duphandle() -func detectAuthMethod(c Curler, stream Stream, timeout time.Duration, enableLogs bool) int { attackURL := fmt.Sprintf( "rtsp://%s:%d/%s", stream.Address, @@ -152,42 +182,38 @@ func detectAuthMethod(c Curler, stream Stream, timeout time.Duration, enableLogs stream.Route, ) - if enableLogs { - // Debug logs when logs are enabled - c.Setopt(curl.OPT_VERBOSE, 1) - } else { - // Do not write sdp in stdout - c.Setopt(curl.OPT_WRITEFUNCTION, doNotWrite) - } + s.setCurlOptions(c) - // Do not use signals (would break multithreading) - c.Setopt(curl.OPT_NOSIGNAL, 1) - // Do not send a body in the describe request - c.Setopt(curl.OPT_NOBODY, 1) - // Send a request to the URL of the stream we want to attack - c.Setopt(curl.OPT_URL, attackURL) - // Set the RTSP STREAM URI as the stream URL - c.Setopt(curl.OPT_RTSP_STREAM_URI, attackURL) - // 2 is CURL_RTSPREQ_DESCRIBE - c.Setopt(curl.OPT_RTSP_REQUEST, 2) - // Set custom timeout - c.Setopt(curl.OPT_TIMEOUT_MS, int(timeout/time.Millisecond)) + // Send a request to the URL of the stream we want to attack. + _ = c.Setopt(curl.OPT_URL, attackURL) + // Set the RTSP STREAM URI as the stream URL. + _ = c.Setopt(curl.OPT_RTSP_STREAM_URI, attackURL) + // 2 is CURL_RTSPREQ_DESCRIBE. + _ = c.Setopt(curl.OPT_RTSP_REQUEST, 2) - // Perform the request + // Perform the request. err := c.Perform() if err != nil { + s.term.Debugf("Perform failed: %v", err) return -1 } authType, err := c.Getinfo(curl.INFO_HTTPAUTH_AVAIL) if err != nil { + s.term.Debugf("Getinfo failed: %v", err) return -1 } + if s.verbose { + s.term.Debugln("DESCRIBE", attackURL, "RTSP/1.0 >", authType) + } + return authType.(int) } -func routeAttack(c Curler, stream Stream, route string, timeout time.Duration, enableLogs bool) bool { +func (s *Scanner) routeAttack(stream Stream, route string) bool { + c := s.curl.Duphandle() + attackURL := fmt.Sprintf( "rtsp://%s:%s@%s:%d/%s", stream.Username, @@ -197,52 +223,47 @@ func routeAttack(c Curler, stream Stream, route string, timeout time.Duration, e route, ) - if enableLogs { - // Debug logs when logs are enabled - c.Setopt(curl.OPT_VERBOSE, 1) - } else { - // Do not write sdp in stdout - c.Setopt(curl.OPT_WRITEFUNCTION, doNotWrite) - } + s.setCurlOptions(c) // Set proper authentication type. - c.Setopt(curl.OPT_HTTPAUTH, stream.AuthenticationType) - c.Setopt(curl.OPT_USERPWD, fmt.Sprint(stream.Username, ":", stream.Password)) + _ = c.Setopt(curl.OPT_HTTPAUTH, stream.AuthenticationType) + _ = c.Setopt(curl.OPT_USERPWD, fmt.Sprint(stream.Username, ":", stream.Password)) - // Do not use signals (would break multithreading) - c.Setopt(curl.OPT_NOSIGNAL, 1) - // Do not send a body in the describe request - c.Setopt(curl.OPT_NOBODY, 1) - // Send a request to the URL of the stream we want to attack - c.Setopt(curl.OPT_URL, attackURL) - // Set the RTSP STREAM URI as the stream URL - c.Setopt(curl.OPT_RTSP_STREAM_URI, attackURL) - // 2 is CURL_RTSPREQ_DESCRIBE - c.Setopt(curl.OPT_RTSP_REQUEST, rtspDescribe) - // Set custom timeout - c.Setopt(curl.OPT_TIMEOUT_MS, int(timeout/time.Millisecond)) + // Send a request to the URL of the stream we want to attack. + _ = c.Setopt(curl.OPT_URL, attackURL) + // Set the RTSP STREAM URI as the stream URL. + _ = c.Setopt(curl.OPT_RTSP_STREAM_URI, attackURL) + // 2 is CURL_RTSPREQ_DESCRIBE. + _ = c.Setopt(curl.OPT_RTSP_REQUEST, rtspDescribe) - // Perform the request + // Perform the request. err := c.Perform() if err != nil { + s.term.Debugf("Perform failed: %v", err) return false } - // Get return code for the request + // Get return code for the request. rc, err := c.Getinfo(curl.INFO_RESPONSE_CODE) if err != nil { + s.term.Debugf("Getinfo failed: %v", err) return false } - // If it's a 401 or 403, it means that the credentials are wrong but the route might be okay - // If it's a 200, the stream is accessed successfully + if s.verbose { + s.term.Debugln("DESCRIBE", attackURL, "RTSP/1.0 >", rc) + } + // If it's a 401 or 403, it means that the credentials are wrong but the route might be okay. + // If it's a 200, the stream is accessed successfully. if rc == httpOK || rc == httpUnauthorized || rc == httpForbidden { return true } return false } -func credAttack(c Curler, stream Stream, username string, password string, timeout time.Duration, enableLogs bool) bool { +func (s *Scanner) credAttack(stream Stream, username string, password string) bool { + c := s.curl.Duphandle() + attackURL := fmt.Sprintf( "rtsp://%s:%s@%s:%d/%s", username, @@ -252,52 +273,48 @@ func credAttack(c Curler, stream Stream, username string, password string, timeo stream.Route, ) - if enableLogs { - // Debug logs when logs are enabled - c.Setopt(curl.OPT_VERBOSE, 1) - } else { - // Do not write sdp in stdout - c.Setopt(curl.OPT_WRITEFUNCTION, doNotWrite) - } + s.setCurlOptions(c) // Set proper authentication type. - c.Setopt(curl.OPT_HTTPAUTH, stream.AuthenticationType) - c.Setopt(curl.OPT_USERPWD, fmt.Sprint(username, ":", password)) + _ = c.Setopt(curl.OPT_HTTPAUTH, stream.AuthenticationType) + _ = c.Setopt(curl.OPT_USERPWD, fmt.Sprint(username, ":", password)) - // Do not use signals (would break multithreading) - c.Setopt(curl.OPT_NOSIGNAL, 1) - // Do not send a body in the describe request - c.Setopt(curl.OPT_NOBODY, 1) - // Send a request to the URL of the stream we want to attack - c.Setopt(curl.OPT_URL, attackURL) - // Set the RTSP STREAM URI as the stream URL - c.Setopt(curl.OPT_RTSP_STREAM_URI, attackURL) - // 2 is CURL_RTSPREQ_DESCRIBE - c.Setopt(curl.OPT_RTSP_REQUEST, 2) - // Set custom timeout - c.Setopt(curl.OPT_TIMEOUT_MS, int(timeout/time.Millisecond)) + // Send a request to the URL of the stream we want to attack. + _ = c.Setopt(curl.OPT_URL, attackURL) + // Set the RTSP STREAM URI as the stream URL. + _ = c.Setopt(curl.OPT_RTSP_STREAM_URI, attackURL) + // 2 is CURL_RTSPREQ_DESCRIBE. + _ = c.Setopt(curl.OPT_RTSP_REQUEST, 2) - // Perform the request + // Perform the request. err := c.Perform() if err != nil { + s.term.Debugf("Perform failed: %v", err) return false } - // Get return code for the request + // Get return code for the request. rc, err := c.Getinfo(curl.INFO_RESPONSE_CODE) if err != nil { + s.term.Debugf("Getinfo failed: %v", err) return false } - // If it's a 404, it means that the route is incorrect but the credentials might be okay - // If it's a 200, the stream is accessed successfully + if s.verbose { + s.term.Debugln("DESCRIBE", attackURL, "RTSP/1.0 >", rc) + } + + // If it's a 404, it means that the route is incorrect but the credentials might be okay. + // If it's a 200, the stream is accessed successfully. if rc == httpOK || rc == httpNotFound { return true } return false } -func validateStream(c Curler, stream Stream, timeout time.Duration, enableLogs bool) bool { +func (s *Scanner) validateStream(stream Stream) bool { + c := s.curl.Duphandle() + attackURL := fmt.Sprintf( "rtsp://%s:%s@%s:%d/%s", stream.Username, @@ -307,48 +324,57 @@ func validateStream(c Curler, stream Stream, timeout time.Duration, enableLogs b stream.Route, ) - if enableLogs { - // Debug logs when logs are enabled - c.Setopt(curl.OPT_VERBOSE, 1) - } else { - // Do not write sdp in stdout - c.Setopt(curl.OPT_WRITEFUNCTION, doNotWrite) - } + s.setCurlOptions(c) // Set proper authentication type. - c.Setopt(curl.OPT_HTTPAUTH, stream.AuthenticationType) - c.Setopt(curl.OPT_USERPWD, fmt.Sprint(stream.Username, ":", stream.Password)) + _ = c.Setopt(curl.OPT_HTTPAUTH, stream.AuthenticationType) + _ = c.Setopt(curl.OPT_USERPWD, fmt.Sprint(stream.Username, ":", stream.Password)) - // Do not use signals (would break multithreading) - c.Setopt(curl.OPT_NOSIGNAL, 1) - // Do not send a body in the describe request - c.Setopt(curl.OPT_NOBODY, 1) - // Send a request to the URL of the stream we want to attack - c.Setopt(curl.OPT_URL, attackURL) - // Set the RTSP STREAM URI as the stream URL - c.Setopt(curl.OPT_RTSP_STREAM_URI, attackURL) - // 2 is CURL_RTSPREQ_SETUP - c.Setopt(curl.OPT_RTSP_REQUEST, rtspSetup) - // Set custom timeout - c.Setopt(curl.OPT_TIMEOUT_MS, int(timeout/time.Millisecond)) + // Send a request to the URL of the stream we want to attack. + _ = c.Setopt(curl.OPT_URL, attackURL) + // Set the RTSP STREAM URI as the stream URL. + _ = c.Setopt(curl.OPT_RTSP_STREAM_URI, attackURL) + // 2 is CURL_RTSPREQ_SETUP. + _ = c.Setopt(curl.OPT_RTSP_REQUEST, rtspSetup) - c.Setopt(curl.OPT_RTSP_TRANSPORT, "RTP/AVP;unicast;client_port=33332-33333") + _ = c.Setopt(curl.OPT_RTSP_TRANSPORT, "RTP/AVP;unicast;client_port=33332-33333") - // Perform the request + // Perform the request. err := c.Perform() if err != nil { + s.term.Debugf("Perform failed: %v", err) return false } - // Get return code for the request + // Get return code for the request. rc, err := c.Getinfo(curl.INFO_RESPONSE_CODE) if err != nil { + s.term.Debugf("Getinfo failed: %v", err) return false } - // If it's a 200, the stream is accessed successfully + if s.verbose { + s.term.Debugln("SETUP", attackURL, "RTSP/1.0 >", rc) + } + // If it's a 200, the stream is accessed successfully. if rc == httpOK { return true } return false } + +func (s *Scanner) setCurlOptions(c Curler) { + // Do not write sdp in stdout + _ = c.Setopt(curl.OPT_WRITEFUNCTION, doNotWrite) + // Do not use signals (would break multithreading). + _ = c.Setopt(curl.OPT_NOSIGNAL, 1) + // Do not send a body in the describe request. + _ = c.Setopt(curl.OPT_NOBODY, 1) + // Set custom timeout. + _ = c.Setopt(curl.OPT_TIMEOUT_MS, int(s.timeout/time.Millisecond)) +} + +// HACK: See https://stackoverflow.com/questions/3572397/lib-curl-in-c-disable-printing +func doNotWrite([]uint8, interface{}) bool { + return true +} diff --git a/attack_test.go b/attack_test.go index f2e0062..fbb271e 100644 --- a/attack_test.go +++ b/attack_test.go @@ -1,12 +1,14 @@ -package cmrdr +package cameradar import ( "errors" + "io/ioutil" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" + "github.com/ullaakut/disgo" curl "github.com/ullaakut/go-curl" ) @@ -33,35 +35,126 @@ func (m *CurlerMock) Duphandle() Curler { return m } +func TestAttack(t *testing.T) { + var ( + stream1 = Stream{ + Device: "fakeDevice", + Address: "fakeAddress", + Port: 1337, + } + + stream2 = Stream{ + Device: "fakeDevice", + Address: "differentFakeAddress", + Port: 1337, + } + + fakeTargets = []Stream{stream1, stream2} + fakeRoutes = Routes{"live.sdp", "media.amp"} + fakeCredentials = Credentials{ + Usernames: []string{"admin", "root"}, + Passwords: []string{"12345", "root"}, + } + ) + + tests := []struct { + description string + + targets []Stream + + performErr error + + expectedStreams []Stream + expectedErr error + }{ + { + description: "inverted RTSP RFC", + + targets: fakeTargets, + + performErr: errors.New("dummy error"), + + expectedStreams: fakeTargets, + }, + { + description: "attack works", + + targets: fakeTargets, + + expectedStreams: fakeTargets, + }, + { + description: "no targets", + + targets: nil, + + expectedStreams: nil, + expectedErr: errors.New("unable to attack empty list of targets"), + }, + } + + for _, test := range tests { + t.Run(test.description, func(t *testing.T) { + curlerMock := &CurlerMock{} + + if len(test.targets) != 0 { + curlerMock.On("Setopt", mock.Anything, mock.Anything).Return(nil) + curlerMock.On("Perform").Return(test.performErr) + if test.performErr == nil { + curlerMock.On("Getinfo", mock.Anything).Return(200, nil) + } + } + + scanner := &Scanner{ + term: disgo.NewTerminal(disgo.WithDefaultOutput(ioutil.Discard)), + curl: curlerMock, + timeout: time.Millisecond, + verbose: false, + credentials: fakeCredentials, + routes: fakeRoutes, + } + + results, err := scanner.Attack(test.targets) + + assert.Equal(t, test.expectedErr, err) + + assert.Len(t, results, len(test.expectedStreams)) + + curlerMock.AssertExpectations(t) + }) + } +} + func TestAttackCredentials(t *testing.T) { - validStream1 := Stream{ - Device: "fakeDevice", - Address: "fakeAddress", - Port: 1337, - } + var ( + stream1 = Stream{ + Device: "fakeDevice", + Address: "fakeAddress", + Port: 1337, + Available: true, + } - validStream2 := Stream{ - Device: "fakeDevice", - Address: "differentFakeAddress", - Port: 1337, - } + stream2 = Stream{ + Device: "fakeDevice", + Address: "differentFakeAddress", + Port: 1337, + Available: true, + } - invalidStream := Stream{ - Device: "InvalidDevice", - } + fakeTargets = []Stream{stream1, stream2} + fakeCredentials = Credentials{ + Usernames: []string{"admin", "root"}, + Passwords: []string{"12345", "root"}, + } + ) - fakeTargets := []Stream{validStream1, validStream2} - invalidTargets := []Stream{invalidStream} - fakeCredentials := Credentials{ - Usernames: []string{"admin", "root"}, - Passwords: []string{"12345", "root"}, - } + tests := []struct { + description string - testCases := []struct { targets []Stream credentials Credentials timeout time.Duration - log bool + verbose bool status int @@ -70,10 +163,10 @@ func TestAttackCredentials(t *testing.T) { invalidTargets bool expectedStreams []Stream - expectedErrMsg string }{ - // Credentials found { + description: "Credentials found", + targets: fakeTargets, credentials: fakeCredentials, timeout: 1 * time.Millisecond, @@ -82,8 +175,9 @@ func TestAttackCredentials(t *testing.T) { expectedStreams: fakeTargets, }, - // Camera accessed { + description: "Camera accessed", + targets: fakeTargets, credentials: fakeCredentials, timeout: 1 * time.Millisecond, @@ -92,19 +186,9 @@ func TestAttackCredentials(t *testing.T) { expectedStreams: fakeTargets, }, - // Invalid targets { - targets: invalidTargets, - credentials: fakeCredentials, - timeout: 1 * time.Millisecond, + description: "curl perform fails", - invalidTargets: true, - - expectedErrMsg: "invalid targets", - expectedStreams: invalidTargets, - }, - // curl perform fails - { targets: fakeTargets, credentials: fakeCredentials, timeout: 1 * time.Millisecond, @@ -113,8 +197,9 @@ func TestAttackCredentials(t *testing.T) { expectedStreams: fakeTargets, }, - // curl getinfo fails { + description: "curl getinfo fails", + targets: fakeTargets, credentials: fakeCredentials, timeout: 1 * time.Millisecond, @@ -123,92 +208,88 @@ func TestAttackCredentials(t *testing.T) { expectedStreams: fakeTargets, }, - // Logging disabled { + description: "Verbose mode disabled", + targets: fakeTargets, credentials: fakeCredentials, timeout: 1 * time.Millisecond, - log: false, + verbose: false, status: 403, expectedStreams: fakeTargets, }, - // Logging enabled { + description: "Verbose mode enabled", + targets: fakeTargets, credentials: fakeCredentials, timeout: 1 * time.Millisecond, - log: true, + verbose: true, status: 403, expectedStreams: fakeTargets, }, } - for i, test := range testCases { - curlerMock := &CurlerMock{} - if !test.invalidTargets { - curlerMock.On("Setopt", mock.Anything, mock.Anything).Return(nil) - curlerMock.On("Perform").Return(test.performErr) - if test.performErr == nil { - curlerMock.On("Getinfo", mock.Anything).Return(test.status, test.getInfoErr) - } - } + for _, test := range tests { + t.Run(test.description, func(t *testing.T) { + curlerMock := &CurlerMock{} - results, err := AttackCredentials(curlerMock, test.targets, test.credentials, test.timeout, test.log) - - if len(test.expectedErrMsg) > 0 { - if err == nil { - t.Errorf("unexpected success in AttackCredentials test, iteration %d. expected error: %s\n", i, test.expectedErrMsg) - } - assert.Contains(t, err.Error(), test.expectedErrMsg, "wrong error message") - } else { - if err != nil { - t.Errorf("unexpected error in AttackCredentials test, iteration %d: %v\n", i, err) - } - for _, stream := range test.expectedStreams { - foundStream := false - for _, result := range results { - if result.Address == stream.Address && result.Device == stream.Device && result.Port == stream.Port { - foundStream = true - } + if !test.invalidTargets { + curlerMock.On("Setopt", mock.Anything, mock.Anything).Return(nil) + curlerMock.On("Perform").Return(test.performErr) + if test.performErr == nil { + curlerMock.On("Getinfo", mock.Anything).Return(test.status, test.getInfoErr) } - assert.Equal(t, true, foundStream, "wrong streams parsed") } - } - assert.Equal(t, len(test.expectedStreams), len(results), "wrong streams parsed") - curlerMock.AssertExpectations(t) + + scanner := &Scanner{ + term: disgo.NewTerminal(disgo.WithDefaultOutput(ioutil.Discard)), + curl: curlerMock, + timeout: test.timeout, + verbose: test.verbose, + credentials: test.credentials, + } + + results := scanner.AttackCredentials(test.targets) + + assert.Len(t, results, len(test.expectedStreams)) + + curlerMock.AssertExpectations(t) + }) } } func TestAttackRoute(t *testing.T) { - validStream1 := Stream{ - Device: "fakeDevice", - Address: "fakeAddress", - Port: 1337, - } + var ( + stream1 = Stream{ + Device: "fakeDevice", + Address: "fakeAddress", + Port: 1337, + Available: true, + } - validStream2 := Stream{ - Device: "fakeDevice", - Address: "differentFakeAddress", - Port: 1337, - } + stream2 = Stream{ + Device: "fakeDevice", + Address: "differentFakeAddress", + Port: 1337, + Available: true, + } - invalidStream := Stream{ - Device: "InvalidDevice", - } + fakeTargets = []Stream{stream1, stream2} + fakeRoutes = Routes{"live.sdp", "media.amp"} + ) - fakeTargets := []Stream{validStream1, validStream2} - fakeRoutes := Routes{"live.sdp", "media.amp"} - invalidTargets := []Stream{invalidStream} + tests := []struct { + description string - testCases := []struct { targets []Stream routes Routes timeout time.Duration - log bool + verbose bool status int @@ -217,10 +298,11 @@ func TestAttackRoute(t *testing.T) { invalidTargets bool expectedStreams []Stream - expectedErrMsg string + expectedErr error }{ - // Route found { + description: "Route found", + targets: fakeTargets, routes: fakeRoutes, timeout: 1 * time.Millisecond, @@ -229,8 +311,9 @@ func TestAttackRoute(t *testing.T) { expectedStreams: fakeTargets, }, - // Route found { + description: "Route found", + targets: fakeTargets, routes: fakeRoutes, timeout: 1 * time.Millisecond, @@ -239,8 +322,9 @@ func TestAttackRoute(t *testing.T) { expectedStreams: fakeTargets, }, - // Camera accessed { + description: "Camera accessed", + targets: fakeTargets, routes: fakeRoutes, timeout: 1 * time.Millisecond, @@ -249,18 +333,9 @@ func TestAttackRoute(t *testing.T) { expectedStreams: fakeTargets, }, - // Invalid targets { - targets: invalidTargets, - routes: fakeRoutes, - timeout: 1 * time.Millisecond, - invalidTargets: true, + description: "curl perform fails", - expectedErrMsg: "invalid targets", - expectedStreams: invalidTargets, - }, - // curl perform fails - { targets: fakeTargets, routes: fakeRoutes, timeout: 1 * time.Millisecond, @@ -269,8 +344,9 @@ func TestAttackRoute(t *testing.T) { expectedStreams: fakeTargets, }, - // curl getinfo fails { + description: "curl getinfo fails", + targets: fakeTargets, routes: fakeRoutes, timeout: 1 * time.Millisecond, @@ -279,97 +355,82 @@ func TestAttackRoute(t *testing.T) { expectedStreams: fakeTargets, }, - // Logs disabled { + description: "verbose mode disabled", + targets: fakeTargets, routes: fakeRoutes, timeout: 1 * time.Millisecond, - log: false, + verbose: false, expectedStreams: fakeTargets, }, - // Logs enabled { + description: "verbose mode enabled", + targets: fakeTargets, routes: fakeRoutes, timeout: 1 * time.Millisecond, - log: true, + verbose: true, expectedStreams: fakeTargets, }, } - for i, test := range testCases { - curlerMock := &CurlerMock{} + for _, test := range tests { + t.Run(test.description, func(t *testing.T) { + curlerMock := &CurlerMock{} - if !test.invalidTargets { - curlerMock.On("Setopt", mock.Anything, mock.Anything).Return(nil) - curlerMock.On("Perform").Return(test.performErr) - if test.performErr == nil { - curlerMock.On("Getinfo", mock.Anything).Return(test.status, test.getInfoErr) - } - } - - results, err := AttackRoute(curlerMock, test.targets, test.routes, test.timeout, test.log) - - if len(test.expectedErrMsg) > 0 { - if err == nil { - t.Errorf("unexpected success in AttackRoute test, iteration %d. expected error: %s\n", i, test.expectedErrMsg) - } - - assert.Contains(t, err.Error(), test.expectedErrMsg, "wrong error message") - } else { - if err != nil { - t.Errorf("unexpected error in AttackRoute test, iteration %d: %v\n", i, err) - } - - for _, stream := range test.expectedStreams { - foundStream := false - for _, result := range results { - if result.Address == stream.Address && result.Device == stream.Device && result.Port == stream.Port { - foundStream = true - } + if !test.invalidTargets { + curlerMock.On("Setopt", mock.Anything, mock.Anything).Return(nil) + curlerMock.On("Perform").Return(test.performErr) + if test.performErr == nil { + curlerMock.On("Getinfo", mock.Anything).Return(test.status, test.getInfoErr) } - - assert.Equal(t, true, foundStream, "wrong streams parsed") } - } - assert.Equal(t, len(test.expectedStreams), len(results), "wrong streams parsed") + scanner := &Scanner{ + term: disgo.NewTerminal(disgo.WithDefaultOutput(ioutil.Discard)), + curl: curlerMock, + timeout: test.timeout, + verbose: test.verbose, + routes: test.routes, + } - curlerMock.AssertExpectations(t) + results := scanner.AttackRoute(test.targets) + + assert.Len(t, results, len(test.expectedStreams)) + + curlerMock.AssertExpectations(t) + }) } } func TestValidateStreams(t *testing.T) { - validStream1 := Stream{ - Device: "fakeDevice", - Address: "fakeAddress", - Port: 1337, - Available: true, - } + var ( + stream1 = Stream{ + Device: "fakeDevice", + Address: "fakeAddress", + Port: 1337, + Available: true, + } - validStream2 := Stream{ - Device: "fakeDevice", - Address: "differentFakeAddress", - Port: 1337, - Available: true, - } + stream2 = Stream{ + Device: "fakeDevice", + Address: "differentFakeAddress", + Port: 1337, + Available: true, + } - unavailableStream := Stream{ - Device: "fakeDevice", - Available: false, - } + fakeTargets = []Stream{stream1, stream2} + ) - fakeTargets := []Stream{validStream1, validStream2} - unavailableTargets := []Stream{unavailableStream} - - testCases := []struct { - desc string + tests := []struct { + description string targets []Stream timeout time.Duration - log bool + verbose bool status int @@ -377,11 +438,9 @@ func TestValidateStreams(t *testing.T) { getInfoErr error expectedStreams []Stream - expectedErrMsg string }{ - // Route found { - desc: "route found", + description: "route found", targets: fakeTargets, timeout: 1 * time.Millisecond, @@ -390,9 +449,8 @@ func TestValidateStreams(t *testing.T) { expectedStreams: fakeTargets, }, - // Route found { - desc: "route found", + description: "route found", targets: fakeTargets, timeout: 1 * time.Millisecond, @@ -401,9 +459,8 @@ func TestValidateStreams(t *testing.T) { expectedStreams: fakeTargets, }, - // Camera accessed { - desc: "camera accessed", + description: "camera accessed", targets: fakeTargets, timeout: 1 * time.Millisecond, @@ -412,20 +469,18 @@ func TestValidateStreams(t *testing.T) { expectedStreams: fakeTargets, }, - // Unavailable stream { - desc: "unavailable stream", + description: "unavailable stream", - targets: unavailableTargets, + targets: fakeTargets, timeout: 1 * time.Millisecond, status: 400, - expectedStreams: unavailableTargets, + expectedStreams: fakeTargets, }, - // curl perform fails { - desc: "curl perform fails", + description: "curl perform fails", targets: fakeTargets, timeout: 1 * time.Millisecond, @@ -434,9 +489,8 @@ func TestValidateStreams(t *testing.T) { expectedStreams: fakeTargets, }, - // curl getinfo fails { - desc: "curl getinfo fails", + description: "curl getinfo fails", targets: fakeTargets, timeout: 1 * time.Millisecond, @@ -445,63 +499,50 @@ func TestValidateStreams(t *testing.T) { expectedStreams: fakeTargets, }, - // Logs disabled { - desc: "logs disabled", + description: "verbose disabled", targets: fakeTargets, timeout: 1 * time.Millisecond, - log: false, + verbose: false, expectedStreams: fakeTargets, }, - // Logs enabled { - desc: "logs enabled", + description: "verbose enabled", targets: fakeTargets, timeout: 1 * time.Millisecond, - log: true, + verbose: true, expectedStreams: fakeTargets, }, } - for i, tC := range testCases { - t.Run(tC.desc, func(t *testing.T) { + + for _, test := range tests { + t.Run(test.description, func(t *testing.T) { curlerMock := &CurlerMock{} curlerMock.On("Setopt", mock.Anything, mock.Anything).Return(nil) - curlerMock.On("Perform").Return(tC.performErr) - if tC.performErr == nil { - curlerMock.On("Getinfo", mock.Anything).Return(tC.status, tC.getInfoErr) + curlerMock.On("Perform").Return(test.performErr) + if test.performErr == nil { + curlerMock.On("Getinfo", mock.Anything).Return(test.status, test.getInfoErr) } - results, err := ValidateStreams(curlerMock, tC.targets, tC.timeout, tC.log) - - if len(tC.expectedErrMsg) > 0 { - if err == nil { - t.Errorf("unexpected success in ValidateStream test, iteration %d. expected error: %s\n", i, tC.expectedErrMsg) - } - - assert.Contains(t, err.Error(), tC.expectedErrMsg, "wrong error message") - } else { - if err != nil { - t.Errorf("unexpected error in ValidateStream test, iteration %d: %v\n", i, err) - } - - for _, stream := range tC.expectedStreams { - foundStream := false - for _, result := range results { - if result.Address == stream.Address && result.Device == stream.Device && result.Port == stream.Port { - foundStream = true - } - } - - assert.Equal(t, true, foundStream, "wrong streams parsed") - } + scanner := &Scanner{ + term: disgo.NewTerminal(disgo.WithDefaultOutput(ioutil.Discard)), + curl: curlerMock, + timeout: test.timeout, + verbose: test.verbose, } - assert.Equal(t, len(tC.expectedStreams), len(results), "wrong streams parsed") + results := scanner.ValidateStreams(test.targets) + + assert.Equal(t, len(test.expectedStreams), len(results)) + + for _, expectedStream := range test.expectedStreams { + assert.Contains(t, results, expectedStream) + } curlerMock.AssertExpectations(t) }) @@ -509,28 +550,30 @@ func TestValidateStreams(t *testing.T) { } func TestDetectAuthenticationType(t *testing.T) { - validStream1 := Stream{ - Device: "fakeDevice", - Address: "fakeAddress", - Port: 1337, - Available: true, - } + var ( + stream1 = Stream{ + Device: "fakeDevice", + Address: "fakeAddress", + Port: 1337, + Available: true, + } - validStream2 := Stream{ - Device: "fakeDevice", - Address: "differentFakeAddress", - Port: 1337, - Available: true, - } + stream2 = Stream{ + Device: "fakeDevice", + Address: "differentFakeAddress", + Port: 1337, + Available: true, + } - fakeTargets := []Stream{validStream1, validStream2} + fakeTargets = []Stream{stream1, stream2} + ) - testCases := []struct { - desc string + tests := []struct { + description string targets []Stream timeout time.Duration - log bool + verbose bool status int @@ -538,11 +581,39 @@ func TestDetectAuthenticationType(t *testing.T) { getInfoErr error expectedStreams []Stream - expectedErrMsg string }{ - // curl getinfo fails { - desc: "curl getinfo fails", + description: "no auth enabled", + + targets: fakeTargets, + timeout: 1 * time.Millisecond, + + status: 0, + + expectedStreams: fakeTargets, + }, + { + description: "basic auth enabled", + + targets: fakeTargets, + timeout: 1 * time.Millisecond, + + status: 1, + + expectedStreams: fakeTargets, + }, + { + description: "digest auth enabled", + + targets: fakeTargets, + timeout: 1 * time.Millisecond, + + status: 2, + + expectedStreams: fakeTargets, + }, + { + description: "curl getinfo fails", targets: fakeTargets, timeout: 1 * time.Millisecond, @@ -551,9 +622,8 @@ func TestDetectAuthenticationType(t *testing.T) { expectedStreams: fakeTargets, }, - // curl perform fails { - desc: "curl perform fails", + description: "curl perform fails", targets: fakeTargets, timeout: 1 * time.Millisecond, @@ -562,63 +632,50 @@ func TestDetectAuthenticationType(t *testing.T) { expectedStreams: fakeTargets, }, - // Logs disabled { - desc: "logs disabled", + description: "verbose disabled", targets: fakeTargets, timeout: 1 * time.Millisecond, - log: false, + verbose: false, expectedStreams: fakeTargets, }, - // Logs enabled { - desc: "logs enabled", + description: "verbose enabled", targets: fakeTargets, timeout: 1 * time.Millisecond, - log: true, + verbose: true, expectedStreams: fakeTargets, }, } - for i, tC := range testCases { - t.Run(tC.desc, func(t *testing.T) { + + for _, test := range tests { + t.Run(test.description, func(t *testing.T) { curlerMock := &CurlerMock{} curlerMock.On("Setopt", mock.Anything, mock.Anything).Return(nil) - curlerMock.On("Perform").Return(tC.performErr) - if tC.performErr == nil { - curlerMock.On("Getinfo", mock.Anything).Return(tC.status, tC.getInfoErr) + curlerMock.On("Perform").Return(test.performErr) + if test.performErr == nil { + curlerMock.On("Getinfo", mock.Anything).Return(test.status, test.getInfoErr) } - results, err := DetectAuthMethods(curlerMock, tC.targets, tC.timeout, tC.log) - - if len(tC.expectedErrMsg) > 0 { - if err == nil { - t.Errorf("unexpected success in DetectAuthMethods test, iteration %d. expected error: %s\n", i, tC.expectedErrMsg) - } - - assert.Contains(t, err.Error(), tC.expectedErrMsg, "wrong error message") - } else { - if err != nil { - t.Errorf("unexpected error in DetectAuthMethods test, iteration %d: %v\n", i, err) - } - - for _, stream := range tC.expectedStreams { - foundStream := false - for _, result := range results { - if result.Address == stream.Address && result.Device == stream.Device && result.Port == stream.Port { - foundStream = true - } - } - - assert.Equal(t, true, foundStream, "wrong streams parsed") - } + scanner := &Scanner{ + term: disgo.NewTerminal(disgo.WithDefaultOutput(ioutil.Discard)), + curl: curlerMock, + timeout: test.timeout, + verbose: test.verbose, } - assert.Equal(t, len(tC.expectedStreams), len(results), "wrong streams parsed") + results := scanner.DetectAuthMethods(test.targets) + + assert.Equal(t, len(test.expectedStreams), len(results)) + + for _, expectedStream := range test.expectedStreams { + assert.Contains(t, results, expectedStream) + } curlerMock.AssertExpectations(t) }) diff --git a/cmrdr.go b/cameradar.go similarity index 86% rename from cmrdr.go rename to cameradar.go index d6ac420..491b795 100644 --- a/cmrdr.go +++ b/cameradar.go @@ -1,4 +1,4 @@ -// Package cmrdr provides methods to be able to discover and +// Package cameradar provides methods to be able to discover and // attack RTSP streams easily. RTSP streams are used by most // IP Cameras, often for surveillance. // @@ -11,4 +11,4 @@ // access cameras, or running their own network scan, this // library allows to use simple and performant methods to // attack streams. -package cmrdr +package cameradar diff --git a/cameradar/cameradar.go b/cameradar/cameradar.go deleted file mode 100644 index 491a3e3..0000000 --- a/cameradar/cameradar.go +++ /dev/null @@ -1,255 +0,0 @@ -package main - -import ( - "errors" - "fmt" - "os" - "strings" - "time" - - "github.com/gernest/wow" - "github.com/gernest/wow/spin" - "github.com/spf13/pflag" - "github.com/spf13/viper" - cmrdr "github.com/ullaakut/cameradar" - log "github.com/ullaakut/disgo" - "github.com/ullaakut/disgo/style" - curl "github.com/ullaakut/go-curl" -) - -type options struct { - Targets []string - Ports []string - Routes string - Credentials string - Speed int - Timeout int - EnableLogs bool -} - -func parseArguments() error { - viper.SetEnvPrefix("cameradar") - viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) - - pflag.StringSliceP("targets", "t", []string{}, "The targets on which to scan for open RTSP streams - required (ex: 172.16.100.0/24)") - pflag.StringSliceP("ports", "p", []string{"554", "5554", "8554"}, "The ports on which to search for RTSP streams") - pflag.StringP("custom-routes", "r", "/src/github.com/ullaakut/cameradar/dictionaries/routes", "The path on which to load a custom routes dictionary") - pflag.StringP("custom-credentials", "c", "/src/github.com/ullaakut/cameradar/dictionaries/credentials.json", "The path on which to load a custom credentials JSON dictionary") - pflag.IntP("speed", "s", 4, "The nmap speed preset to use for discovery") - pflag.IntP("timeout", "T", 2000, "The timeout in miliseconds to use for attack attempts") - pflag.BoolP("log", "l", false, "Enable the logs for nmap's output to stdout") - pflag.BoolP("help", "h", false, "displays this help message") - - viper.AutomaticEnv() - - pflag.Parse() - - err := viper.BindPFlags(pflag.CommandLine) - if err != nil { - return err - } - - if viper.GetBool("help") { - pflag.Usage() - fmt.Println("\nExamples of usage:") - fmt.Println("\tScanning your home network for RTSP streams:\tcameradar -t 192.168.0.0/24") - fmt.Println("\tScanning a remote camera on a specific port:\tcameradar -t 172.178.10.14 -p 18554 -s 2") - fmt.Println("\tScanning an unstable remote network: \t\tcameradar -t 172.178.10.14/24 -s 1 --timeout 10000 -l") - os.Exit(0) - } - - if viper.GetStringSlice("targets") == nil { - return errors.New("targets (-t, --targets) argument required\n examples:\n - 172.16.100.0/24\n - localhost\n - 8.8.8.8") - } - - return nil -} - -func main() { - var options options - term := log.NewTerminal() - - err := parseArguments() - if err != nil { - printErr(term, err) - } - - options.Credentials = viper.GetString("custom-credentials") - options.EnableLogs = viper.GetBool("log") || viper.GetBool("logging") - options.Ports = viper.GetStringSlice("ports") - options.Routes = viper.GetString("custom-routes") - options.Speed = viper.GetInt("speed") - options.Timeout = viper.GetInt("timeout") - options.Targets = viper.GetStringSlice("targets") - - w := startSpinner(options.EnableLogs) - - if len(options.Targets) == 1 { - options.Targets, err = cmrdr.ParseTargetsFile(options.Targets[0]) - if err != nil { - printErr(term, err) - } - } - - err = curl.GlobalInit(curl.GLOBAL_ALL) - handle := curl.EasyInit() - if err != nil || handle == nil { - printErr(term, errors.New("libcurl initialization failed")) - } - - c := &cmrdr.Curl{CURL: handle} - defer curl.GlobalCleanup() - - updateSpinner(w, "Loading dictionaries...", options.EnableLogs) - gopath := os.Getenv("GOPATH") - options.Credentials = strings.Replace(options.Credentials, "", gopath, 1) - options.Routes = strings.Replace(options.Routes, "", gopath, 1) - - credentials, err := cmrdr.LoadCredentials(options.Credentials) - if err != nil { - printErr(term, fmt.Errorf("Invalid credentials dictionary %q: %v", options.Credentials, err)) - return - } - - routes, err := cmrdr.LoadRoutes(options.Routes) - if err != nil { - printErr(term, fmt.Errorf("Invalid routes dictionary %q: %v", options.Routes, err)) - return - } - - updateSpinner(w, "Scanning the network...", options.EnableLogs) - streams, err := cmrdr.Discover(options.Targets, options.Ports, options.Speed) - if err != nil && len(streams) > 0 { - printErr(term, err) - } - - // Most cameras will be accessed successfully with these two attacks - updateSpinner(w, "Found "+fmt.Sprint(len(streams))+" streams. Attacking their routes...", options.EnableLogs) - streams, err = cmrdr.AttackRoute(c, streams, routes, time.Duration(options.Timeout)*time.Millisecond, options.EnableLogs) - if err != nil && len(streams) > 0 { - printErr(term, err) - } - - updateSpinner(w, "Found "+fmt.Sprint(len(streams))+" streams. Detecting their authentication methods...", options.EnableLogs) - streams, err = cmrdr.DetectAuthMethods(c, streams, time.Duration(options.Timeout)*time.Millisecond, options.EnableLogs) - - updateSpinner(w, "Found "+fmt.Sprint(len(streams))+" streams. Attacking their credentials...", options.EnableLogs) - streams, err = cmrdr.AttackCredentials(c, streams, credentials, time.Duration(options.Timeout)*time.Millisecond, options.EnableLogs) - if err != nil && len(streams) > 0 { - printErr(term, err) - } - - // But some cameras run GST RTSP Server which prioritizes 401 over 404 contrary to most cameras. - // For these cameras, running another route attack will solve the problem. - for _, stream := range streams { - if !stream.RouteFound || !stream.CredentialsFound { - updateSpinner(w, "Found "+fmt.Sprint(len(streams))+" streams. Final attack...", options.EnableLogs) - streams, err = cmrdr.AttackRoute(c, streams, routes, time.Duration(options.Timeout)*time.Millisecond, options.EnableLogs) - if err != nil && len(streams) > 0 { - printErr(term, err) - } - - break - } - } - - updateSpinner(w, "Found "+fmt.Sprint(len(streams))+" streams. Validating their availability...", options.EnableLogs) - streams, err = cmrdr.ValidateStreams(c, streams, time.Duration(options.Timeout)*time.Millisecond, options.EnableLogs) - if err != nil && len(streams) > 0 { - printErr(term, err) - } - - clearOutput(w, options.EnableLogs) - - prettyPrint(term, streams) -} - -func prettyPrint(term *log.Terminal, streams []cmrdr.Stream) { - success := 0 - if len(streams) == 0 { - term.Infof("%s No streams were found. Please make sure that your target is on an accessible network.\n", style.Failure(style.SymbolCross)) - } - - for _, stream := range streams { - if stream.CredentialsFound && stream.RouteFound && stream.Available { - term.Infof("%s\tDevice RTSP URL:\t%s\n", style.Success(style.SymbolRightTriangle), style.Link(cmrdr.GetCameraRTSPURL(stream))) - success++ - } else { - term.Infof("%s\tAdmin panel URL:\t%s You can use this URL to try attacking the camera's admin panel instead.\n", style.Failure(style.SymbolCross), style.Link(cmrdr.GetCameraAdminPanelURL(stream))) - } - - if len(stream.Device) > 0 { - term.Infof("\tDevice model:\t\t%s\n\n", stream.Device) - } - - if stream.Available { - term.Infof("\tAvailable:\t\t%s\n", style.Success(style.SymbolCheck)) - } else { - term.Infof("\tAvailable:\t\t%s\n", style.Failure(style.SymbolCross)) - } - - term.Infof("\tIP address:\t\t%s\n", stream.Address) - term.Infof("\tRTSP port:\t\t%d\n", stream.Port) - - switch stream.AuthenticationType { - case curl.AUTH_NONE: - term.Infoln("\tThis camera does not require authentication") - case curl.AUTH_BASIC: - term.Infoln("\tAuth type:\t\tbasic") - case curl.AUTH_DIGEST: - term.Infoln("\tAuth type:\t\tdigest") - } - - if stream.CredentialsFound { - term.Infof("\tUsername:\t\t%s\n", style.Success(stream.Username)) - term.Infof("\tPassword:\t\t%s\n", style.Success(stream.Password)) - } else { - term.Infof("\tUsername:\t\t%s\n", style.Failure("not found")) - term.Infof("\tPassword:\t\t%s\n", style.Failure("not found")) - } - - if stream.RouteFound { - term.Infof("\tRTSP route:\t\t%s\n\n\n", style.Success("/"+stream.Route)) - } else { - term.Infof("\tRTSP route:\t\t%s\n\n\n", style.Failure("not found")) - } - } - - if success > 1 { - term.Infof("%s Successful attack: %s devices were accessed", style.Success(style.SymbolCheck), style.Success(len(streams))) - } else if success == 1 { - term.Infof("%s Successful attack: %s device was accessed", style.Success(style.SymbolCheck), style.Success(len(streams))) - } else { - term.Infof("%s Streams were found but none were accessed. They are most likely configured with secure credentials and routes. You can try adding entries to the dictionary or generating your own in order to attempt a bruteforce attack on the cameras.\n", style.Failure("\xE2\x9C\x96")) - } -} - -func printErr(term *log.Terminal, err error) { - term.Errorln(style.Failure(style.SymbolCross), err) - os.Exit(1) -} - -func updateSpinner(w *wow.Wow, text string, disabled bool) { - if !disabled { - w.Text(" " + text) - } -} - -func startSpinner(disabled bool) *wow.Wow { - if !disabled { - w := wow.New(os.Stdout, spin.Get(spin.Dots), " Loading dictionaries...") - w.Start() - return w - } - return nil -} - -// HACK: Waiting for a fix to issue -// https://github.com/gernest/wow/issues/5 -func clearOutput(w *wow.Wow, disabled bool) { - if !disabled { - w.Text("\b") - time.Sleep(80 * time.Millisecond) - w.Stop() - } -} diff --git a/cmd/cameradar/cameradar.go b/cmd/cameradar/cameradar.go new file mode 100644 index 0000000..8d57299 --- /dev/null +++ b/cmd/cameradar/cameradar.go @@ -0,0 +1,92 @@ +package main + +import ( + "errors" + "fmt" + "os" + "strings" + "time" + + "github.com/spf13/pflag" + "github.com/spf13/viper" + "github.com/ullaakut/cameradar" + "github.com/ullaakut/disgo" + "github.com/ullaakut/disgo/style" +) + +func parseArguments() error { + viper.SetEnvPrefix("cameradar") + viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) + + pflag.StringSliceP("targets", "t", []string{}, "The targets on which to scan for open RTSP streams - required (ex: 172.16.100.0/24)") + pflag.StringSliceP("ports", "p", []string{"554", "5554", "8554"}, "The ports on which to search for RTSP streams") + pflag.StringP("custom-routes", "r", "/src/github.com/ullaakut/cameradar/dictionaries/routes", "The path on which to load a custom routes dictionary") + pflag.StringP("custom-credentials", "c", "/src/github.com/ullaakut/cameradar/dictionaries/credentials.json", "The path on which to load a custom credentials JSON dictionary") + pflag.IntP("speed", "s", 4, "The nmap speed preset to use for discovery") + pflag.DurationP("timeout", "T", 2*time.Second, "The timeout in miliseconds to use for attack attempts") + pflag.BoolP("debug", "d", true, "Enable the debug logs") + pflag.BoolP("verbose", "v", false, "Enable the verbose logs") + pflag.BoolP("help", "h", false, "displays this help message") + + viper.AutomaticEnv() + + pflag.Parse() + + err := viper.BindPFlags(pflag.CommandLine) + if err != nil { + return err + } + + if viper.GetBool("help") { + pflag.Usage() + fmt.Println("\nExamples of usage:") + fmt.Println("\tScanning your home network for RTSP streams:\tcameradar -t 192.168.0.0/24") + fmt.Println("\tScanning a remote camera on a specific port:\tcameradar -t 172.178.10.14 -p 18554 -s 2") + fmt.Println("\tScanning an unstable remote network: \t\tcameradar -t 172.178.10.14/24 -s 1 --timeout 10000 -l") + os.Exit(0) + } + + if viper.GetStringSlice("targets") == nil { + return errors.New("targets (-t, --targets) argument required\n examples:\n - 172.16.100.0/24\n - localhost\n - 8.8.8.8") + } + + return nil +} + +func main() { + err := parseArguments() + if err != nil { + printErr(err) + } + + c, err := cameradar.New( + cameradar.WithTargets(viper.GetStringSlice("targets")), + cameradar.WithPorts(viper.GetStringSlice("ports")), + cameradar.WithDebug(viper.GetBool("debug")), + cameradar.WithVerbose(viper.GetBool("verbose")), + cameradar.WithCustomCredentials(viper.GetString("custom-credentials")), + cameradar.WithCustomRoutes(viper.GetString("custom-routes")), + cameradar.WithSpeed(viper.GetInt("speed")), + cameradar.WithTimeout(viper.GetDuration("timeout")), + ) + if err != nil { + printErr(err) + } + + scanResult, err := c.Scan() + if err != nil { + printErr(err) + } + + streams, err := c.Attack(scanResult) + if err != nil { + printErr(err) + } + + c.PrintStreams(streams) +} + +func printErr(err error) { + disgo.Errorln(style.Failure(style.SymbolCross), err) + os.Exit(1) +} diff --git a/curl.go b/curl.go index e9c7d7b..7341a5a 100644 --- a/curl.go +++ b/curl.go @@ -1,4 +1,4 @@ -package cmrdr +package cameradar import ( curl "github.com/ullaakut/go-curl" diff --git a/curl_test.go b/curl_test.go index 7728d4c..fc4074a 100644 --- a/curl_test.go +++ b/curl_test.go @@ -1,4 +1,4 @@ -package cmrdr +package cameradar import ( "reflect" diff --git a/dictionaries/routes b/dictionaries/routes index ab982a6..7aa27e0 100644 --- a/dictionaries/routes +++ b/dictionaries/routes @@ -1,5 +1,4 @@ -/live/ch01_0 0/1:1/main 0/usrnm:pwd/main 0/video1 @@ -11,22 +10,6 @@ 12 125 666 -AVStream1_1 -CAM_ID.password.mp2 -CH001.sdp -GetData.cgi -HighResolutionVideo -LowResolutionVideo -MediaInput/h264 -MediaInput/mpeg4 -ONVIF/MediaInput -ONVIF/MediaInput?profile=4_def_profile6 -StdCh1 -Streaming/Channels/1 -Streaming/Unicast/channels/101 -StreamingSetting?version=1.0&action=getRTSPStream&ChannelID=1&ChannelName=Channel1 -VideoInput/1/h264/1 -VideoInput/1/mpeg4/1 access_code access_name_for_stream_1_to_5 api/mjpegvideo.cgi @@ -34,10 +17,12 @@ av0_0 av2 avc avn=2 +AVStream1_1 axis-media/media.amp axis-media/media.amp?camera=1 axis-media/media.amp?videocodec=h264 cam +CAM_ID.password.mp2 cam/realmonitor cam/realmonitor?channel=0&subtype=0 cam/realmonitor?channel=1&subtype=0 @@ -52,52 +37,58 @@ cam1/mpeg4?user='username'&pwd='password' cam1/onvif-h264 camera.stm ch0 -ch00/0 -ch001.sdp -ch01.264 -ch01.264? -ch01.264?ptype=tcp ch0_0.h264 ch0_unicast_firststream ch0_unicast_secondstream +ch00/0 +ch001.sdp +CH001.sdp +ch01.264 +ch01.264? +ch01.264?ptype=tcp ch1-s1 channel1 +GetData.cgi gnz_media/main h264 +h264_vga.sdp h264.sdp h264/ch1/sub/av_stream h264/media.amp -h264_vga.sdp +HighResolutionVideo image.mpg img/media.sav img/media.sav?channel=1 img/video.asf img/video.sav ioImage/1 -ipcam.sdp ipcam_h264.sdp ipcam_mjpeg.sdp +ipcam.sdp live +live_mpeg4.sdp +live_st1 live.sdp live/av0 live/ch0 live/ch00_0 +live/ch01_0 live/h264 live/main live/main0 live/mpeg4 live1.sdp live3.sdp -live_mpeg4.sdp -live_st1 livestream -livestream/ +LowResolutionVideo main media media.amp media.amp?streamprofile=Profile1 media/media.amp media/video1 +MediaInput/h264 +MediaInput/mpeg4 medias2 mjpeg/media.smp mp4 @@ -116,6 +107,8 @@ nphMpeg4/g726-640x48 nphMpeg4/g726-640x480 nphMpeg4/nil-320x240 onvif-media/media.amp +ONVIF/MediaInput +ONVIF/MediaInput?profile=4_def_profile6 onvif1 pass@10.0.0.5:6667/blinkhd play1.sdp @@ -129,12 +122,16 @@ rtsp_live2 rtsp_tunnel rtsph264 rtsph2641080p +StdCh1 stream stream.sdp stream1 streaming/channels/0 +Streaming/Channels/1 streaming/channels/1 streaming/channels/101 +Streaming/Unicast/channels/101 +StreamingSetting?version=1.0&action=getRTSPStream&ChannelID=1&ChannelName=Channel1 tcp/av0_0 test trackID=1 @@ -142,12 +139,12 @@ ucast/11 udp/av0_0 udp/unicast/aiphone_H264 udpstream +user_defined user.pin.mp2 -user=admin&password=&channel=1&stream=0.sdp? -user=admin&password=&channel=1&stream=0.sdp?real_stream user=admin_password=?????_channel=1_stream=0.sdp?real_stream user=admin_password=R5XFY888_channel=1_stream=0.sdp?real_stream -user_defined +user=admin&password=&channel=1&stream=0.sdp? +user=admin&password=&channel=1&stream=0.sdp?real_stream v2 video video.3gp @@ -160,7 +157,9 @@ video.pro3 video0.sdp video1 video1+audio1 -videoMain videoinput_1/h264_1/media.stm +VideoInput/1/h264/1 +VideoInput/1/mpeg4/1 +videoMain vis wfov \ No newline at end of file diff --git a/go.mod b/go.mod index fc070db..04ac629 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( github.com/spf13/pflag v1.0.3 github.com/spf13/viper v1.3.1 github.com/ullaakut/disgo v0.3.0 - github.com/ullaakut/go-curl v0.0.0-20190310175419-50acab4cef70 + github.com/ullaakut/go-curl v0.0.0-20190525093431-597e157bbffd github.com/ullaakut/nmap v0.0.0-20190306183004-e38898a9bead gopkg.in/go-playground/validator.v9 v9.27.0 ) diff --git a/go.sum b/go.sum index a57676d..72205a9 100644 --- a/go.sum +++ b/go.sum @@ -51,6 +51,8 @@ github.com/ullaakut/disgo v0.3.0 h1:2zrEyNBfPRgDVDgzM/qLXZ4Yqt3Lxz7ERvZUSmqSY2M= github.com/ullaakut/disgo v0.3.0/go.mod h1:UOgLVyqihzJ7yihrHjYZikivT+AHb9NhT3r1OyPCJqg= github.com/ullaakut/go-curl v0.0.0-20190310175419-50acab4cef70 h1:3q4hgRu9NT894aYmnoMFl5wPvdNhpHYmdi2+Njyxq5U= github.com/ullaakut/go-curl v0.0.0-20190310175419-50acab4cef70/go.mod h1:FTfXm4jC9Ff1yqc3/HMXCyr+SGO03vJyijJCQlNyF10= +github.com/ullaakut/go-curl v0.0.0-20190525093431-597e157bbffd h1:IzJ7V8S7/NXc4aLOj0QavbQZ5Z/Q2RpCifshHoJ5ytA= +github.com/ullaakut/go-curl v0.0.0-20190525093431-597e157bbffd/go.mod h1:FTfXm4jC9Ff1yqc3/HMXCyr+SGO03vJyijJCQlNyF10= github.com/ullaakut/nmap v0.0.0-20190306183004-e38898a9bead h1:Pw5wKSAfxi8GcYJSc3GdcwtPG5tyg7zg9E3hAHbLPO0= github.com/ullaakut/nmap v0.0.0-20190306183004-e38898a9bead/go.mod h1:4CQy4PqZA4Snk3+MS26+1oAkJ8dCY8kGH6+kF42yajw= github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= diff --git a/helpers.go b/helpers.go index d975089..c29fa85 100644 --- a/helpers.go +++ b/helpers.go @@ -1,9 +1,9 @@ -package cmrdr +package cameradar import "fmt" func replace(streams []Stream, new Stream) []Stream { - updatedSlice := streams[:0] + var updatedSlice []Stream for _, old := range streams { if old.Address == new.Address && old.Port == new.Port { @@ -16,12 +16,12 @@ func replace(streams []Stream, new Stream) []Stream { return updatedSlice } -// GetCameraRTSPURL generates a stream's RTSP URL +// GetCameraRTSPURL generates a stream's RTSP URL. func GetCameraRTSPURL(stream Stream) string { return "rtsp://" + stream.Username + ":" + stream.Password + "@" + stream.Address + ":" + fmt.Sprint(stream.Port) + "/" + stream.Route } -// GetCameraAdminPanelURL returns the URL to the camera's admin panel +// GetCameraAdminPanelURL returns the URL to the camera's admin panel. func GetCameraAdminPanelURL(stream Stream) string { return "http://" + stream.Address + "/" } diff --git a/helpers_test.go b/helpers_test.go index b3c6d91..d4c278a 100644 --- a/helpers_test.go +++ b/helpers_test.go @@ -1,4 +1,4 @@ -package cmrdr +package cameradar import ( "testing" @@ -10,25 +10,25 @@ func TestReplace(t *testing.T) { validStream1 := Stream{ Device: "fakeDevice", Address: "fakeAddress", - Port: 1337, + Port: 1, } validStream2 := Stream{ Device: "fakeDevice", Address: "differentFakeAddress", - Port: 1337, + Port: 2, } - invalidStreamNoPort := Stream{ + invalidStream := Stream{ Device: "invalidDevice", - Address: "fakeAddress", - Port: 0, + Address: "anotherFakeAddress", + Port: 3, } - invalidStreamNoPortModified := Stream{ + invalidStreamModified := Stream{ Device: "updatedDevice", - Address: "fakeAddress", - Port: 1337, + Address: "anotherFakeAddress", + Port: 3, } testCases := []struct { @@ -37,25 +37,21 @@ func TestReplace(t *testing.T) { expectedStreams []Stream }{ - // Valid baseline { - streams: []Stream{validStream1, validStream2, invalidStreamNoPort}, - newStream: invalidStreamNoPortModified, + streams: []Stream{validStream1, validStream2, invalidStream}, + newStream: invalidStreamModified, - expectedStreams: []Stream{validStream1, validStream2, invalidStreamNoPortModified}, + expectedStreams: []Stream{validStream1, validStream2, invalidStreamModified}, }, } + for _, test := range testCases { streams := replace(test.streams, test.newStream) - for _, stream := range test.streams { - foundStream := false - for _, result := range streams { - if result.Address == stream.Address && result.Device == stream.Device && result.Port == stream.Port { - foundStream = true - } - } - assert.Equal(t, true, foundStream, "wrong streams parsed") + assert.Equal(t, len(test.expectedStreams), len(streams)) + + for _, expectedStream := range test.expectedStreams { + assert.Contains(t, streams, expectedStream) } } } @@ -74,16 +70,15 @@ func TestGetCameraRTSPURL(t *testing.T) { expectedRTSPURL string }{ - // Valid baseline { stream: validStream, expectedRTSPURL: "rtsp://ullaakut:ba69897483886f0d2b0afb6345b76c0c@1.2.3.4:1337/cameradar.sdp", }, } + for _, test := range testCases { - output := GetCameraRTSPURL(test.stream) - assert.Equal(t, test.expectedRTSPURL, output, "wrong RTSP URL generated") + assert.Equal(t, test.expectedRTSPURL, GetCameraRTSPURL(test.stream)) } } @@ -97,15 +92,14 @@ func TestGetCameraAdminPanelURL(t *testing.T) { expectedRTSPURL string }{ - // Valid baseline { stream: validStream, expectedRTSPURL: "http://1.2.3.4/", }, } + for _, test := range testCases { - output := GetCameraAdminPanelURL(test.stream) - assert.Equal(t, test.expectedRTSPURL, output, "wrong Admin Panel URL generated") + assert.Equal(t, test.expectedRTSPURL, GetCameraAdminPanelURL(test.stream)) } } diff --git a/images/CameradarV4.png b/images/CameradarV4.png new file mode 100644 index 0000000..ab69918 Binary files /dev/null and b/images/CameradarV4.png differ diff --git a/images/Models.png b/images/Models.png deleted file mode 100644 index 37b1ec7..0000000 Binary files a/images/Models.png and /dev/null differ diff --git a/loaders.go b/loaders.go index 2f5ee73..c8c422b 100644 --- a/loaders.go +++ b/loaders.go @@ -1,14 +1,13 @@ -package cmrdr +package cameradar import ( "bufio" "encoding/json" + "fmt" "io" "io/ioutil" "os" "strings" - - "github.com/pkg/errors" ) var fs fileSystem = osFS{} @@ -32,48 +31,51 @@ type osFS struct{} func (osFS) Open(name string) (file, error) { return os.Open(name) } func (osFS) Stat(name string) (os.FileInfo, error) { return os.Stat(name) } -// LoadCredentials opens a dictionary file and returns its contents as a Credentials structure -func LoadCredentials(path string) (Credentials, error) { - var creds Credentials +// LoadCredentials opens a dictionary file and returns its contents as a Credentials structure. +func (s *Scanner) LoadCredentials() error { + s.term.Debugf("Loading credentials dictionary from path %q\n", s.credentialDictionaryPath) - // Open & Read XML file - content, err := ioutil.ReadFile(path) + // Open & Read XML file. + content, err := ioutil.ReadFile(s.credentialDictionaryPath) if err != nil { - return creds, errors.Wrap(err, "could not read credentials dictionary file at "+path+":") + return fmt.Errorf("could not read credentials dictionary file at %q: %v", s.credentialDictionaryPath, err) } - // Unmarshal content of JSON file into data structure - err = json.Unmarshal(content, &creds) + // Unmarshal content of JSON file into data structure. + err = json.Unmarshal(content, &s.credentials) if err != nil { - return creds, err + return fmt.Errorf("unable to unmarshal dictionary contents: %v", err) } - return creds, nil + s.term.Debugf("Loaded %d usernames and %d passwords\n", len(s.credentials.Usernames), len(s.credentials.Passwords)) + return nil } -// LoadRoutes opens a dictionary file and returns its contents as a Routes structure -func LoadRoutes(path string) (Routes, error) { - file, err := os.Open(path) +// LoadRoutes opens a dictionary file and returns its contents as a Routes structure. +func (s *Scanner) LoadRoutes() error { + s.term.Debugf("Loading routes dictionary from path %q\n", s.routeDictionaryPath) + + file, err := os.Open(s.routeDictionaryPath) if err != nil { - return nil, err + return fmt.Errorf("unable to open dictionary: %v", err) } defer file.Close() - var routes Routes scanner := bufio.NewScanner(file) - for scanner.Scan() { - routes = append(routes, scanner.Text()) + s.routes = append(s.routes, scanner.Text()) } - return routes, scanner.Err() + s.term.Debugf("Loaded %d routes\n", len(s.routes)) + + return scanner.Err() } -// ParseCredentialsFromString parses a dictionary string and returns its contents as a Credentials structure +// ParseCredentialsFromString parses a dictionary string and returns its contents as a Credentials structure. func ParseCredentialsFromString(content string) (Credentials, error) { var creds Credentials - // Unmarshal content of JSON file into data structure + // Unmarshal content of JSON file into data structure. err := json.Unmarshal([]byte(content), &creds) if err != nil { return creds, err @@ -82,28 +84,39 @@ func ParseCredentialsFromString(content string) (Credentials, error) { return creds, nil } -// ParseRoutesFromString parses a dictionary string and returns its contents as a Routes structure +// ParseRoutesFromString parses a dictionary string and returns its contents as a Routes structure. func ParseRoutesFromString(content string) Routes { return strings.Split(content, "\n") } -// ParseTargetsFile parses an input file containing hosts to targets -func ParseTargetsFile(path string) ([]string, error) { +// LoadTargets parses the file containing hosts to targets, if the targets are +// just set to a file name. +func (s *Scanner) LoadTargets() error { + if len(s.targets) != 1 { + return nil + } + + path := s.targets[0] + _, err := fs.Stat(path) if err != nil { - return []string{path}, nil + return nil } file, err := fs.Open(path) if err != nil { - return []string{path}, err + return fmt.Errorf("unable to open targets file %q: %v", path, err) } defer file.Close() bytes, err := ioutil.ReadAll(file) if err != nil { - return []string{path}, err + return fmt.Errorf("unable to read targets file %q: %v", path, err) } - return strings.Split(string(bytes), "\n"), nil + s.targets = strings.Split(string(bytes), "\n") + + s.term.Debugf("Successfylly parsed targets file with %d entries", len(s.targets)) + + return nil } diff --git a/loaders_test.go b/loaders_test.go index dee302b..ff8f354 100644 --- a/loaders_test.go +++ b/loaders_test.go @@ -1,12 +1,15 @@ -package cmrdr +package cameradar import ( "bytes" + "errors" "fmt" "io/ioutil" "os" "testing" + "github.com/ullaakut/disgo" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) @@ -91,91 +94,79 @@ func TestLoadCredentials(t *testing.T) { Passwords: []string{"12345", "root"}, } - testCases := []struct { + tests := []struct { + description string + input []byte fileExists bool - expectedOutput Credentials - expectedErrMsg string + expectedCredentials Credentials + expectedErr error }{ - // Valid baseline { - fileExists: true, - input: credentialsJSONString, - expectedOutput: validCredentials, + description: "Valid baseline", + + fileExists: true, + input: credentialsJSONString, + expectedCredentials: validCredentials, }, - // File does not exist { - fileExists: false, - input: credentialsJSONString, - expectedErrMsg: "could not read credentials dictionary file at", + description: "File does not exist", + + fileExists: false, + input: credentialsJSONString, + expectedErr: errors.New("could not read credentials dictionary file at \"/tmp/cameradar_test_load_credentials_1.xml\": open /tmp/cameradar_test_load_credentials_1.xml: no such file or directory"), }, - // Invalid format { - fileExists: true, - input: []byte("not json"), - expectedErrMsg: "invalid character", + description: "Invalid format", + + fileExists: true, + input: []byte("not json"), + expectedErr: errors.New("unable to unmarshal dictionary contents: invalid character 'o' in literal null (expecting 'u')"), }, - // No streams in dictionary { + description: "No streams in dictionary", + fileExists: true, input: []byte("{\"invalid\":\"json\"}"), }, } - for i, test := range testCases { - filePath := "/tmp/cameradar_test_load_credentials_" + fmt.Sprint(i) + ".xml" - // create file - if test.fileExists { - _, err := os.Create(filePath) - if err != nil { - fmt.Printf("could not create xml file for LoadCredentials: %v. iteration: %d. file path: %s\n", err, i, filePath) - os.Exit(1) - } - - err = ioutil.WriteFile(filePath, test.input, 0644) - if err != nil { - fmt.Printf("could not write xml file for LoadCredentials: %v. iteration: %d. file path: %s\n", err, i, filePath) - os.Exit(1) - } - } - - result, err := LoadCredentials(filePath) - if len(test.expectedErrMsg) > 0 { - if err == nil { - fmt.Printf("unexpected success in LoadCredentials test, iteration %d. expected error: %s\n", i, test.expectedErrMsg) - os.Exit(1) - } - - assert.Contains(t, err.Error(), test.expectedErrMsg, "wrong error message") - } else { - if err != nil { - fmt.Printf("unexpected error in LoadCredentials test, iteration %d: %v\n", i, err) - os.Exit(1) - } - - for _, expectedUsername := range test.expectedOutput.Usernames { - foundUsername := false - for _, username := range result.Usernames { - if username == expectedUsername { - foundUsername = true - } + for i, test := range tests { + t.Run(test.description, func(t *testing.T) { + filePath := "/tmp/cameradar_test_load_credentials_" + fmt.Sprint(i) + ".xml" + // create file. + if test.fileExists { + _, err := os.Create(filePath) + if err != nil { + t.Fatalf("could not create xml file for LoadCredentials: %v. iteration: %d. file path: %s\n", err, i, filePath) } - assert.Equal(t, true, foundUsername, "wrong usernames parsed") - } - - for _, expectedPassword := range test.expectedOutput.Passwords { - foundPassword := false - for _, password := range result.Passwords { - if password == expectedPassword { - foundPassword = true - } + err = ioutil.WriteFile(filePath, test.input, 0644) + if err != nil { + t.Fatalf("could not write xml file for LoadCredentials: %v. iteration: %d. file path: %s\n", err, i, filePath) } - - assert.Equal(t, true, foundPassword, "wrong passwords parsed") } - } + + scanner := &Scanner{ + term: disgo.NewTerminal(disgo.WithDefaultOutput(ioutil.Discard)), + credentialDictionaryPath: filePath, + } + + err := scanner.LoadCredentials() + + assert.Equal(t, test.expectedErr, err) + + assert.Len(t, scanner.credentials.Usernames, len(test.expectedCredentials.Usernames)) + for _, expectedUsername := range test.expectedCredentials.Usernames { + assert.Contains(t, scanner.credentials.Usernames, expectedUsername) + } + + assert.Len(t, scanner.credentials.Passwords, len(test.expectedCredentials.Passwords)) + for _, expectedPassword := range test.expectedCredentials.Passwords { + assert.Contains(t, scanner.credentials.Passwords, expectedPassword) + } + }) } } @@ -183,74 +174,69 @@ func TestLoadRoutes(t *testing.T) { routesJSONString := []byte("admin\nroot") validRoutes := Routes{"admin", "root"} - testCases := []struct { - input []byte - fileExists bool + tests := []struct { + description string + input []byte + fileExists bool - expectedOutput Routes - expectedErrMsg string + expectedRoutes Routes + expectedErr error }{ - // Valid baseline { + description: "Valid baseline", + fileExists: true, input: routesJSONString, - expectedOutput: validRoutes, + expectedRoutes: validRoutes, }, - // File does not exist { - fileExists: false, - input: routesJSONString, - expectedErrMsg: "no such file or directory", + description: "File does not exist", + + fileExists: false, + input: routesJSONString, + expectedErr: errors.New("unable to open dictionary: open /tmp/cameradar_test_load_routes_1.xml: no such file or directory"), }, - // No streams in dictionary { + description: "No streams in dictionary", + fileExists: true, input: []byte(""), }, } - for i, test := range testCases { - filePath := "/tmp/cameradar_test_load_routes_" + fmt.Sprint(i) + ".xml" + for i, test := range tests { + t.Run(test.description, func(t *testing.T) { + filePath := "/tmp/cameradar_test_load_routes_" + fmt.Sprint(i) + ".xml" - // create file - if test.fileExists { - _, err := os.Create(filePath) - if err != nil { - fmt.Printf("could not create xml file for LoadRoutes: %v. iteration: %d. file path: %s\n", err, i, filePath) - os.Exit(1) - } - - err = ioutil.WriteFile(filePath, test.input, 0644) - if err != nil { - fmt.Printf("could not write xml file for LoadRoutes: %v. iteration: %d. file path: %s\n", err, i, filePath) - os.Exit(1) - } - } - - result, err := LoadRoutes(filePath) - if len(test.expectedErrMsg) > 0 { - if err == nil { - fmt.Printf("unexpected success in LoadRoutes test, iteration %d. expected error: %s\n", i, test.expectedErrMsg) - os.Exit(1) - } - assert.Contains(t, err.Error(), test.expectedErrMsg, "wrong error message") - } else { - if err != nil { - fmt.Printf("unexpected error in LoadRoutes test, iteration %d: %v\n", i, err) - os.Exit(1) - } - - for _, expectedRoute := range test.expectedOutput { - foundRoute := false - for _, route := range result { - if route == expectedRoute { - foundRoute = true - } + // Create file. + if test.fileExists { + _, err := os.Create(filePath) + if err != nil { + fmt.Printf("could not create xml file for LoadRoutes: %v. iteration: %d. file path: %s\n", err, i, filePath) + os.Exit(1) } - assert.Equal(t, true, foundRoute, "wrong routes parsed") + err = ioutil.WriteFile(filePath, test.input, 0644) + if err != nil { + fmt.Printf("could not write xml file for LoadRoutes: %v. iteration: %d. file path: %s\n", err, i, filePath) + os.Exit(1) + } } - } + + scanner := &Scanner{ + term: disgo.NewTerminal(disgo.WithDefaultOutput(ioutil.Discard)), + routeDictionaryPath: filePath, + } + + err := scanner.LoadRoutes() + + assert.Equal(t, test.expectedErr, err) + + assert.Len(t, scanner.routes, len(test.expectedRoutes)) + for _, expectedRoute := range test.expectedRoutes { + assert.Contains(t, scanner.routes, expectedRoute) + } + }) } } @@ -295,58 +281,59 @@ func TestParseCredentialsFromString(t *testing.T) { }, } - testCases := []struct { - str string - expectedResult Credentials + tests := []struct { + str string + expectedCredentials Credentials }{ { - str: "{\"usernames\":[\"\",\"admin\",\"Admin\",\"Administrator\",\"root\",\"supervisor\",\"ubnt\",\"service\",\"Dinion\",\"administrator\",\"admin1\"],\"passwords\":[\"\",\"admin\",\"9999\",\"123456\",\"pass\",\"camera\",\"1234\",\"12345\",\"fliradmin\",\"system\",\"jvc\",\"meinsm\",\"root\",\"4321\",\"111111\",\"1111111\",\"password\",\"ikwd\",\"supervisor\",\"ubnt\",\"wbox123\",\"service\"]}", - expectedResult: defaultCredentials, + str: "{\"usernames\":[\"\",\"admin\",\"Admin\",\"Administrator\",\"root\",\"supervisor\",\"ubnt\",\"service\",\"Dinion\",\"administrator\",\"admin1\"],\"passwords\":[\"\",\"admin\",\"9999\",\"123456\",\"pass\",\"camera\",\"1234\",\"12345\",\"fliradmin\",\"system\",\"jvc\",\"meinsm\",\"root\",\"4321\",\"111111\",\"1111111\",\"password\",\"ikwd\",\"supervisor\",\"ubnt\",\"wbox123\",\"service\"]}", + expectedCredentials: defaultCredentials, }, { - str: "{}", - expectedResult: Credentials{}, + str: "{}", + expectedCredentials: Credentials{}, }, { - str: "{\"invalid_field\":42}", - expectedResult: Credentials{}, + str: "{\"invalid_field\":42}", + expectedCredentials: Credentials{}, }, { - str: "not json", - expectedResult: Credentials{}, + str: "not json", + expectedCredentials: Credentials{}, }, } - for _, test := range testCases { + + for _, test := range tests { parsedCredentials, _ := ParseCredentialsFromString(test.str) - assert.Equal(t, test.expectedResult, parsedCredentials, "unexpected result, parse error") + assert.Equal(t, test.expectedCredentials, parsedCredentials) } } func TestParseRoutesFromString(t *testing.T) { - testCases := []struct { + tests := []struct { str string - expectedResult Routes + expectedRoutes Routes }{ { str: "a\nb\nc", - expectedResult: []string{"a", "b", "c"}, + expectedRoutes: []string{"a", "b", "c"}, }, { str: "a", - expectedResult: []string{"a"}, + expectedRoutes: []string{"a"}, }, { str: "", - expectedResult: []string{""}, + expectedRoutes: []string{""}, }, } - for _, test := range testCases { - parsedRoutes := ParseRoutesFromString(test.str) - assert.Equal(t, test.expectedResult, parsedRoutes, "unexpected result, parse error") + + for _, test := range tests { + assert.Equal(t, test.expectedRoutes, ParseRoutesFromString(test.str)) } } -func TestParseTargetsFile(t *testing.T) { +func TestLoadTargets(t *testing.T) { oldFS := fs mfs := &mockedFS{} @@ -355,65 +342,90 @@ func TestParseTargetsFile(t *testing.T) { fs = oldFS }() - testCases := []struct { - input string + tests := []struct { + description string + + targets []string fileExists bool openError bool readError bool - expectedResult []string - expectedError error + expectedTargets []string + expectedError error }{ { - input: "0.0.0.0", + description: "not a file", + + targets: []string{"0.0.0.0"}, fileExists: false, - expectedResult: []string{"0.0.0.0"}, - expectedError: nil, + expectedTargets: []string{"0.0.0.0"}, + expectedError: nil, }, { - input: "test_does_not_really_exist", + description: "not file targets", + + targets: []string{"0.0.0.0", "1.2.3.4/24"}, + + expectedTargets: []string{"0.0.0.0", "1.2.3.4/24"}, + expectedError: nil, + }, + { + description: "file contains targets", + + targets: []string{"test_does_not_really_exist"}, fileExists: true, - expectedResult: []string{"0.0.0.0", "localhost", "192.17.0.0/16", "192.168.1.140-255", "192.168.2-3.0-255"}, - expectedError: nil, + expectedTargets: []string{"0.0.0.0", "localhost", "192.17.0.0/16", "192.168.1.140-255", "192.168.2-3.0-255"}, + expectedError: nil, }, { - input: "test_does_not_really_exist", + description: "open error", + + targets: []string{"test_does_not_really_exist"}, fileExists: true, openError: true, - expectedResult: []string{"test_does_not_really_exist"}, - expectedError: os.ErrNotExist, + expectedTargets: []string{"test_does_not_really_exist"}, + expectedError: errors.New("unable to open targets file \"test_does_not_really_exist\": file does not exist"), }, { - input: "test_does_not_really_exist", + description: "read error", + + targets: []string{"test_does_not_really_exist"}, fileExists: true, readError: true, - expectedResult: []string{"test_does_not_really_exist"}, - expectedError: os.ErrNotExist, + expectedTargets: []string{"test_does_not_really_exist"}, + expectedError: errors.New("unable to read targets file \"test_does_not_really_exist\": file does not exist"), }, } - for _, test := range testCases { - mfs.fileExists = test.fileExists - mfs.openError = test.openError + for _, test := range tests { + t.Run(test.description, func(t *testing.T) { + mfs.fileExists = test.fileExists + mfs.openError = test.openError - mfs.fileMock = &fileMock{ - readError: test.readError, - } - mfs.fileMock.On("Close").Return(nil) - mfs.fileMock.WriteString("0.0.0.0\nlocalhost\n192.17.0.0/16\n192.168.1.140-255\n192.168.2-3.0-255") + mfs.fileMock = &fileMock{ + readError: test.readError, + } + mfs.fileMock.On("Close").Return(nil) + mfs.fileMock.WriteString("0.0.0.0\nlocalhost\n192.17.0.0/16\n192.168.1.140-255\n192.168.2-3.0-255") - result, err := ParseTargetsFile(test.input) - assert.Equal(t, test.expectedResult, result, "unexpected result, parse error") - assert.Equal(t, test.expectedError, err, "unexpected error") + scanner := &Scanner{ + term: disgo.NewTerminal(disgo.WithDefaultOutput(ioutil.Discard)), + targets: test.targets, + } + + err := scanner.LoadTargets() + assert.Equal(t, test.expectedTargets, scanner.targets) + assert.Equal(t, test.expectedError, err) + }) } } diff --git a/models.go b/models.go index 79e67c2..d620a2b 100644 --- a/models.go +++ b/models.go @@ -1,4 +1,4 @@ -package cmrdr +package cameradar import "time" diff --git a/discover.go b/scan.go similarity index 54% rename from discover.go rename to scan.go index ca1d251..a41bae7 100644 --- a/discover.go +++ b/scan.go @@ -1,4 +1,4 @@ -package cmrdr +package cameradar import ( "strings" @@ -6,7 +6,7 @@ import ( "github.com/ullaakut/nmap" ) -// Discover scans the target networks and tries to find RTSP streams within them. +// Scan scans the target networks and tries to find RTSP streams within them. // // targets can be: // @@ -18,28 +18,30 @@ import ( // ports can be: // // - one or multiple ports and port ranges separated by commas (e.g.: 554,8554-8560,18554-28554) -func Discover(targets, ports []string, speed int) ([]Stream, error) { - // Run nmap command to discover open ports on the specified targets & ports - scanner, err := nmap.NewScanner( - nmap.WithTargets(targets...), - nmap.WithPorts(ports...), - nmap.WithTimingTemplate(nmap.Timing(speed)), +func (s *Scanner) Scan() ([]Stream, error) { + s.term.StartStep("Scanning the network") + + // Run nmap command to discover open ports on the specified targets & ports. + nmapScanner, err := nmap.NewScanner( + nmap.WithTargets(s.targets...), + nmap.WithPorts(s.ports...), + nmap.WithTimingTemplate(nmap.Timing(s.speed)), ) if err != nil { - return nil, err + return nil, s.term.FailStepf("unable to create network scanner: %v", err) } - return scan(scanner) + return s.scan(nmapScanner) } -func scan(scanner nmap.ScanRunner) ([]Stream, error) { - results, err := scanner.Run() +func (s *Scanner) scan(nmapScanner nmap.ScanRunner) ([]Stream, error) { + results, err := nmapScanner.Run() if err != nil { - return nil, err + return nil, s.term.FailStepf("error while scanning network: %v", err) } + // Get streams from nmap results. var streams []Stream - // Get streams from nmap results for _, host := range results.Hosts { for _, port := range host.Ports { if port.Status() != "open" { @@ -60,5 +62,9 @@ func scan(scanner nmap.ScanRunner) ([]Stream, error) { } } + s.term.Debugf("Found %d RTSP streams\n", len(streams)) + + s.term.EndStep() + return streams, nil } diff --git a/discover_test.go b/scan_test.go similarity index 84% rename from discover_test.go rename to scan_test.go index 3d17f1e..e7eacec 100644 --- a/discover_test.go +++ b/scan_test.go @@ -1,13 +1,16 @@ -package cmrdr +package cameradar import ( "errors" + "io/ioutil" "os" "testing" - "github.com/ullaakut/nmap" + "github.com/ullaakut/disgo" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" + "github.com/ullaakut/nmap" ) type nmapMock struct { @@ -23,7 +26,33 @@ func (m *nmapMock) Run() (*nmap.Run, error) { return nil, args.Error(1) } -func TestDiscover(t *testing.T) { +var ( + validStream1 = Stream{ + Device: "fakeDevice", + Address: "fakeAddress", + Port: 1337, + } + + validStream2 = Stream{ + Device: "fakeDevice", + Address: "differentFakeAddress", + Port: 1337, + } + + invalidStreamNoPort = Stream{ + Device: "invalidDevice", + Address: "fakeAddress", + Port: 0, + } + + invalidStreamNoAddress = Stream{ + Device: "invalidDevice", + Address: "", + Port: 1337, + } +) + +func TestScan(t *testing.T) { tests := []struct { description string @@ -32,8 +61,8 @@ func TestDiscover(t *testing.T) { speed int removePath bool - expectedErr error - expectedResult []Stream + expectedErr error + expectedStreams []Stream }{ { description: "create new scanner and call scan, no error", @@ -48,7 +77,7 @@ func TestDiscover(t *testing.T) { removePath: true, ports: []string{"80"}, - expectedErr: errors.New("'nmap' binary was not found"), + expectedErr: errors.New("unable to create network scanner: 'nmap' binary was not found"), }, } @@ -58,43 +87,28 @@ func TestDiscover(t *testing.T) { os.Setenv("PATH", "") } - result, err := Discover(test.targets, test.ports, test.speed) + scanner := &Scanner{ + term: disgo.NewTerminal(disgo.WithDefaultOutput(ioutil.Discard)), + targets: test.targets, + ports: test.ports, + speed: test.speed, + } + + result, err := scanner.Scan() assert.Equal(t, test.expectedErr, err) - assert.Equal(t, test.expectedResult, result) + assert.Equal(t, test.expectedStreams, result) }) } } -func TestScan(t *testing.T) { - validStream1 := Stream{ - Device: "fakeDevice", - Address: "fakeAddress", - Port: 1337, - } +func TestInternalScan(t *testing.T) { - validStream2 := Stream{ - Device: "fakeDevice", - Address: "differentFakeAddress", - Port: 1337, - } - - invalidStreamNoPort := Stream{ - Device: "invalidDevice", - Address: "fakeAddress", - Port: 0, - } - - invalidStreamNoAddress := Stream{ - Device: "invalidDevice", - Address: "", - Port: 1337, - } - - testCases := []struct { + tests := []struct { description string - nmapResult *nmap.Run - nmapError error + + nmapResult *nmap.Run + nmapError error expectedStreams []Stream expectedErr error @@ -281,17 +295,21 @@ func TestScan(t *testing.T) { description: "scan failed", nmapError: errors.New("scan failed"), - expectedErr: errors.New("scan failed"), + expectedErr: errors.New("error while scanning network: scan failed"), }, } - for _, test := range testCases { + for _, test := range tests { t.Run(test.description, func(t *testing.T) { nmapMock := &nmapMock{} nmapMock.On("Run").Return(test.nmapResult, test.nmapError) - results, err := scan(nmapMock) + scanner := &Scanner{ + term: disgo.NewTerminal(disgo.WithDefaultOutput(ioutil.Discard)), + } + + results, err := scanner.scan(nmapMock) assert.Equal(t, test.expectedErr, err) assert.Equal(t, test.expectedStreams, results, "wrong streams parsed") diff --git a/scanner.go b/scanner.go new file mode 100644 index 0000000..b34dd3d --- /dev/null +++ b/scanner.go @@ -0,0 +1,143 @@ +package cameradar + +import ( + "fmt" + "os" + "strings" + "time" + + "github.com/ullaakut/disgo" + curl "github.com/ullaakut/go-curl" +) + +// Scanner represents a cameradar scanner. It scans a network and +// attacks all streams found to get their RTSP credentials. +type Scanner struct { + curl Curler + term *disgo.Terminal + + targets []string + ports []string + debug bool + verbose bool + speed int + timeout time.Duration + credentialDictionaryPath string + routeDictionaryPath string + + credentials Credentials + routes Routes +} + +// New creates a new Cameradar Scanner and applies the given options. +func New(options ...func(*Scanner)) (*Scanner, error) { + err := curl.GlobalInit(curl.GLOBAL_ALL) + if err != nil { + return nil, fmt.Errorf("unable to initialize curl library: %v", err) + } + + handle := curl.EasyInit() + if handle == nil { + return nil, fmt.Errorf("unable to initialize curl handle: %v", err) + } + + scanner := &Scanner{ + curl: &Curl{CURL: handle}, + credentialDictionaryPath: "/src/github.com/ullaakut/cameradar/dictionaries/credentials.json", + routeDictionaryPath: "/src/github.com/ullaakut/cameradar/dictionaries/routes", + } + + for _, option := range options { + option(scanner) + } + + gopath := os.Getenv("GOPATH") + scanner.credentialDictionaryPath = strings.Replace(scanner.credentialDictionaryPath, "", gopath, 1) + scanner.routeDictionaryPath = strings.Replace(scanner.routeDictionaryPath, "", gopath, 1) + + scanner.term = disgo.NewTerminal( + disgo.WithDebug(scanner.debug), + ) + + err = scanner.LoadTargets() + if err != nil { + return nil, fmt.Errorf("unable to parse target file: %v", err) + } + + scanner.term.StartStepf("Loading credentials") + err = scanner.LoadCredentials() + if err != nil { + return nil, scanner.term.FailStepf("unable to load credentials dictionary: %v", err) + } + + scanner.term.StartStepf("Loading routes") + err = scanner.LoadRoutes() + if err != nil { + return nil, scanner.term.FailStepf("unable to load credentials dictionary: %v", err) + } + + disgo.EndStep() + + return scanner, nil +} + +// WithTargets specifies the targets to scan and attack. +func WithTargets(targets []string) func(s *Scanner) { + return func(s *Scanner) { + s.targets = targets + } +} + +// WithPorts specifies the ports to scan and attack. +func WithPorts(ports []string) func(s *Scanner) { + return func(s *Scanner) { + s.ports = ports + } +} + +// WithDebug specifies whether or not to enable debug logs. +func WithDebug(debug bool) func(s *Scanner) { + return func(s *Scanner) { + s.debug = debug + } +} + +// WithVerbose specifies whether or not to enable verbose logs. +func WithVerbose(verbose bool) func(s *Scanner) { + return func(s *Scanner) { + s.verbose = verbose + } +} + +// WithCustomCredentials specifies a custom credential dictionary +// to use for the attacks. +func WithCustomCredentials(dictionaryPath string) func(s *Scanner) { + return func(s *Scanner) { + s.credentialDictionaryPath = dictionaryPath + } +} + +// WithCustomRoutes specifies a custom route dictionary +// to use for the attacks. +func WithCustomRoutes(dictionaryPath string) func(s *Scanner) { + return func(s *Scanner) { + s.routeDictionaryPath = dictionaryPath + } +} + +// WithSpeed specifies the speed at which the scan should be executed. Faster +// means easier to detect, slower has bigger timeout values and is more silent. +func WithSpeed(speed int) func(s *Scanner) { + return func(s *Scanner) { + s.speed = speed + } +} + +// WithTimeout specifies the amount of time after which attack requests should +// timeout. This should be high if the network you are attacking has a poor +// connectivity or that you are located far away from it. +func WithTimeout(timeout time.Duration) func(s *Scanner) { + return func(s *Scanner) { + s.timeout = timeout + } +} diff --git a/scanner_test.go b/scanner_test.go new file mode 100644 index 0000000..40d18a9 --- /dev/null +++ b/scanner_test.go @@ -0,0 +1,133 @@ +package cameradar + +import ( + "fmt" + "io/ioutil" + "testing" + "time" + + "github.com/stretchr/testify/assert" + curl "github.com/ullaakut/go-curl" +) + +func TestNew(t *testing.T) { + tests := []struct { + description string + + targets []string + ports []string + debug bool + verbose bool + customCredentials string + customRoutes string + speed int + timeout time.Duration + + loadTargetsFail bool + loadCredsFail bool + loadRoutesFail bool + + curlGlobalFail bool + curlEasyFail bool + + expectedErr bool + }{ + { + description: "no error while loading dictionaries", + + targets: []string{"titi", "toto"}, + ports: []string{"554"}, + debug: true, + verbose: false, + speed: 3, + timeout: time.Millisecond, + }, + { + description: "unable to load targets", + + loadTargetsFail: true, + + expectedErr: true, + }, + { + description: "unable to load credentials", + + loadCredsFail: true, + + expectedErr: true, + }, + { + description: "unable to load routes", + + loadRoutesFail: true, + + expectedErr: true, + }, + { + description: "curl fails to init", + + curlGlobalFail: true, + + expectedErr: true, + }, + { + description: "curl fails to create handle", + + curlEasyFail: true, + + expectedErr: true, + }, + } + + for i, test := range tests { + t.Run(test.description, func(t *testing.T) { + if test.loadTargetsFail { + test.targets = []string{generateTmpFileName(i, "targets")} + ioutil.WriteFile(test.targets[0], []byte(`0.0.0.0`), 0000) + } + + if !test.loadCredsFail { + test.customCredentials = generateTmpFileName(i, "creds") + ioutil.WriteFile(test.customCredentials, []byte(`{"usernames":["admin"],"passwords":["admin"]}`), 0644) + } + + if !test.loadRoutesFail { + test.customRoutes = generateTmpFileName(i, "routes") + ioutil.WriteFile(test.customRoutes, []byte(`live.sdp`), 0644) + } + + curl.TestGlobalFail = test.curlGlobalFail + curl.TestEasyFail = test.curlEasyFail + + scanner, err := New( + WithTargets(test.targets), + WithPorts(test.ports), + WithDebug(test.debug), + WithVerbose(test.verbose), + WithSpeed(test.speed), + WithTimeout(test.timeout), + WithCustomCredentials(test.customCredentials), + WithCustomRoutes(test.customRoutes), + ) + + if test.expectedErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + + if scanner != nil { + assert.Equal(t, test.targets, scanner.targets) + assert.Equal(t, test.ports, scanner.ports) + assert.Equal(t, test.debug, scanner.debug) + assert.Equal(t, test.verbose, scanner.verbose) + assert.Equal(t, test.speed, scanner.speed) + assert.Equal(t, test.timeout, scanner.timeout) + } + }) + } +} + +func generateTmpFileName(iteration int, purpose string) string { + return fmt.Sprintf("/tmp/cameradar_test_scanner_%s_%d_%d", purpose, time.Now().Unix(), iteration) +} diff --git a/summary.go b/summary.go new file mode 100644 index 0000000..7785e1c --- /dev/null +++ b/summary.go @@ -0,0 +1,63 @@ +package cameradar + +import ( + "github.com/ullaakut/disgo/style" + curl "github.com/ullaakut/go-curl" +) + +// PrintStreams prints information on each stream. +func (s *Scanner) PrintStreams(streams []Stream) { + if len(streams) == 0 { + s.term.Infof("%s No streams were found. Please make sure that your target is on an accessible network.\n", style.Failure(style.SymbolCross)) + } + + success := 0 + for _, stream := range streams { + if stream.Available { + s.term.Infof("%s\tDevice RTSP URL:\t%s\n", style.Success(style.SymbolRightTriangle), style.Link(GetCameraRTSPURL(stream))) + s.term.Infof("\tAvailable:\t\t%s\n", style.Success(style.SymbolCheck)) + success++ + } else { + s.term.Infof("%s\tAdmin panel URL:\t%s You can use this URL to try attacking the camera's admin panel instead.\n", style.Failure(style.SymbolCross), style.Link(GetCameraAdminPanelURL(stream))) + s.term.Infof("\tAvailable:\t\t%s\n", style.Failure(style.SymbolCross)) + } + + if len(stream.Device) > 0 { + s.term.Infof("\tDevice model:\t\t%s\n\n", stream.Device) + } + + s.term.Infof("\tIP address:\t\t%s\n", stream.Address) + s.term.Infof("\tRTSP port:\t\t%d\n", stream.Port) + + switch stream.AuthenticationType { + case curl.AUTH_NONE: + s.term.Infoln("\tThis camera does not require authentication") + case curl.AUTH_BASIC: + s.term.Infoln("\tAuth type:\t\tbasic") + case curl.AUTH_DIGEST: + s.term.Infoln("\tAuth type:\t\tdigest") + } + + if stream.CredentialsFound { + s.term.Infof("\tUsername:\t\t%s\n", style.Success(stream.Username)) + s.term.Infof("\tPassword:\t\t%s\n", style.Success(stream.Password)) + } else { + s.term.Infof("\tUsername:\t\t%s\n", style.Failure("not found")) + s.term.Infof("\tPassword:\t\t%s\n", style.Failure("not found")) + } + + if stream.RouteFound { + s.term.Infof("\tRTSP route:\t\t%s\n\n\n", style.Success("/"+stream.Route)) + } else { + s.term.Infof("\tRTSP route:\t\t%s\n\n\n", style.Failure("not found")) + } + } + + if success > 1 { + s.term.Infof("%s Successful attack: %s devices were accessed", style.Success(style.SymbolCheck), style.Success(len(streams))) + } else if success == 1 { + s.term.Infof("%s Successful attack: %s device was accessed", style.Success(style.SymbolCheck), style.Success("one")) + } else { + s.term.Infof("%s Streams were found but none were accessed. They are most likely configured with secure credentials and routes. You can try adding entries to the dictionary or generating your own in order to attempt a bruteforce attack on the cameras.\n", style.Failure("\xE2\x9C\x96")) + } +} diff --git a/summary_test.go b/summary_test.go new file mode 100644 index 0000000..9cb0fbe --- /dev/null +++ b/summary_test.go @@ -0,0 +1,186 @@ +package cameradar + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/ullaakut/disgo" +) + +var ( + unavailable = Stream{} + + available = Stream{ + Available: true, + } + + deviceFound = Stream{ + Device: "devicename", + } + + noAuth = Stream{ + AuthenticationType: 0, + } + + basic = Stream{ + AuthenticationType: 1, + } + + digest = Stream{ + AuthenticationType: 2, + } + + credsFound = Stream{ + CredentialsFound: true, + Username: "us3r", + Password: "p4ss", + } + + routeFound = Stream{ + RouteFound: true, + Route: "r0ute", + } +) + +func TestPrintStreams(t *testing.T) { + tests := []struct { + description string + + streams []Stream + + expectedLogs []string + }{ + { + description: "displays the proper message when no streams found", + + streams: nil, + + expectedLogs: []string{"No streams were found"}, + }, + { + description: "displays the admin panel URL when a stream is not accessible", + + streams: []Stream{ + unavailable, + }, + + expectedLogs: []string{"Admin panel URL"}, + }, + { + description: "displays the device name when it is found", + + streams: []Stream{ + deviceFound, + }, + + expectedLogs: []string{"Device model:"}, + }, + { + description: "displays authentication type (no auth)", + + streams: []Stream{ + noAuth, + }, + + expectedLogs: []string{"This camera does not require authentication"}, + }, + { + description: "displays authentication type (basic)", + + streams: []Stream{ + basic, + }, + + expectedLogs: []string{"basic"}, + }, + { + description: "displays authentication type (digest)", + + streams: []Stream{ + digest, + }, + + expectedLogs: []string{"digest"}, + }, + { + description: "displays credentials properly", + + streams: []Stream{ + credsFound, + }, + + expectedLogs: []string{ + "Username", + "us3r", + "Password", + "p4ss", + }, + }, + { + description: "displays route properly", + + streams: []Stream{ + routeFound, + }, + + expectedLogs: []string{ + "RTSP route", + "/r0ute", + }, + }, + { + description: "displays successes properly (no success)", + + streams: []Stream{ + unavailable, + }, + + expectedLogs: []string{ + "Streams were found but none were accessed", + }, + }, + { + description: "displays successes properly (1 success)", + + streams: []Stream{ + available, + }, + + expectedLogs: []string{ + "Successful attack", + "device was accessed", + }, + }, + { + description: "displays successes properly (multiple successes)", + + streams: []Stream{ + available, + available, + available, + available, + }, + + expectedLogs: []string{ + "Successful attack", + "devices were accessed", + }, + }, + } + + for _, test := range tests { + t.Run(test.description, func(t *testing.T) { + writer := &bytes.Buffer{} + scanner := &Scanner{ + term: disgo.NewTerminal(disgo.WithDefaultOutput(writer)), + } + + scanner.PrintStreams(test.streams) + + for _, expectedLog := range test.expectedLogs { + assert.Contains(t, writer.String(), expectedLog) + } + }) + } +}