If you have not read the previous blog posts I recommend you to have a look at part 1, where we discuss how to extract the firmware from the camera, part 2 where we enumerate the attack surface. In this part we will focus on the exposed web services running on TCP ports 80 and 443. Since a valid exploit chain must achieve code execution without prior authentication, we have focused on the available functionality that can be accessed without authentication.

Unauthenticated Paths

The binary listening on web ports was determined using the netstat command:

root@BC500_AD:~$ netstat -tnlp
Active Internet connections (only servers)
Proto Recv-Q Send-Q Local Address      Foreign Address    State    PID/Program name    
tcp        0      0 0.0.0.0:554        0.0.0.0:*          LISTEN   738/streamd
tcp        0      0 0.0.0.0:80         0.0.0.0:*          LISTEN   640/webd
tcp        0      0 0.0.0.0:443        0.0.0.0:*          LISTEN   640/webd
tcp        0      0 10.0.0.2:49152     0.0.0.0:*          LISTEN   527/systemd

Ok, so webd is listening on TCP ports 80 and 443, let’s get the binary out of the camera for further analysis. Since we are first interested in determining the APIs available without prior authentication, running strings over the binary would provide a list of configured paths:

$ strings webd | grep /syno-api/ | sort -u
/syno-api/activate
/syno-api/date_time
/syno-api/login
/syno-api/logout
/syno-api/maintenance/firmware/upgrade
/syno-api/maintenance/firmware/version
/syno-api/maintenance/log/retrieve
/syno-api/maintenance/reboot
/syno-api/maintenance/reset
/syno-api/maintenance/system/report
/syno-api/manual/trigger/ad
/syno-api/manual/trigger/ai
/syno-api/manual/trigger/disconn
/syno-api/manual/trigger/md
/syno-api/manual/trigger/td
/syno-api/recording/download
/syno-api/recording/retrieve
/syno-api/recording/sd_card/format
/syno-api/recording/sd_card/mount
/syno-api/recording/sd_card/speed_test
/syno-api/recording/sd_card/unmount
/syno-api/security/connection
/syno-api/security/encryption_key
/syno-api/security/https/upload_cert
/syno-api/security/https/upload_key
/syno-api/security/info
/syno-api/security/info/language
/syno-api/security/info/mac
/syno-api/security/info/model
/syno-api/security/info/name
/syno-api/security/info/serial_number
/syno-api/security/ip_filter/deny
/syno-api/security/network
/syno-api/security/network/dhcp
/syno-api/security/user
/syno-api/session
/syno-api/snapshot
/syno-api/stream_num

A simple GET request was then sent to each of these paths. As you can see in the image below, many requests were responded with HTTP 401 Unauthorized, but a few were responded with HTTP 200, indicating that these paths could be successfully called without authentication.

Read access using GET requests allows us to get more information about the camera configuration, but more interesting are the API endpoints that allow us to make changes to the camera. For this purpose, PUT requests were sent to all paths. This revealed five endpoints that respond with an HTTP status code other than 401, namely “HTTP 411 Length Required”. So unauthenticated PUT requests with these status codes are not blocked immediately, but require the Content-Length request header. The same results apply to POST requests.

HTTP/1.1 411 Length Required
Content-Type: text/plain
Cache-Control: no-cache, no-store, must-revalidate, private, max-age=0
Content-Length: 71
Date: Thu, 01 Jan 1970 00:13:37 GMT
Connection: close

Error 411: Length Required
Error: Client did not specify content length

Determining how webd distinguishes whether a path should be available without authentication required reverse engineering the binary. Searching for strings that represent paths led to the discovery of a function that handles incoming requests. Two sets of paths were found in this function. One set representing paths that are available without authentication and with an arbitrary HTTP method. And another set representing paths that are accessible anonymously using HTTP GET.

Also, two strings are defined that represent prefixes for allowed paths:

So it appears that a whitelist mechanism is used and all requests that do not match one of the whitelisted paths or start with a whitelisted string require authentication.

Login

But how is the authentication mechanism actually implemented? By intercepting the traffic sent from the browser to the camera, we could see that Digest Auth is used and that a session cookie is received after a successful login. We also noticed that the cookies are stored in the camera’s filesystem under /tmp/ConnInfo. Playing around with authentication also revealed that the cookie can be omitted if the Digest Auth header is provided. This means that the path in the Authorization header does not have to match the path in the HTTP request, and the nonce set by the camera can be reused.

Reversing the webd further we found the code responsible for parsing the incoming Authorization header and reading the Digest Auth parameters.

A few lines below is a piece of code that attempts to read a “Password” field from a JSON object, converts the read value to a string, and passes it as one of the arguments to the azdg_decrypt function.

We have already seen a large JSON file stored at /data/app/active.json containing the camera’s configuration, including the configured user and password, represented as a 32-character HEX string. Initially, we thought that this was an MD5 hash of the password along with some other values, since the output of MD5 is also 16-byte long, and furthermore, for verifying the Digest Auth, the server may store an MD5 hash of the password, username, and realm values. However, the value stored in the JSON did not match the value that would be calculated according to the RFC 2617.

Another interesting aspect about the JSON file containing the camera configuration is that the user we set for the camera is not the only user present in the JSON. There is also a user named viewer that we have never configured.

        "User": {
            "0": {
                "Group": "admin",
                "Password": "85352da0e32d49f33edabfdc7b1fd1c0",
                "Username": "compass"
            },
            "1": {
                "Group": "viewer",
                "Password": "fe2c06e5d27869a74c90cd963c57948e",
                "Username": "viewer"
            },
            "Count": 2,
            "MaxCount": 10,
            "MinCount": 2
        }

If we could decrypt the password of the viewer user, we would potentially be able to authenticate as that user, and if not directly access all the functionality available to the user we configured, at least increase the reachable attack surface.

AZDG is a known cryptographic function based on MD5 and xor, and there are several implementations of it available online. This, together with the observation that a small change in the plaintext password results in a small change in the encrypted password, allowed us, after generating a few plaintext/ciphertext pairs, to come up with a decryption routine that flawlessly decrypts passwords from the JSON file.

However, decrypting the viewer‘s password stored in the configuration resulted in an empty string. Attempts to authenticate with an empty password failed. While investigating why the login with an empty password is denied, we noticed that in the same code we analyzed before, there is a check if the length of the decrypted password is 0, and if it is, the authentication flow is interrupted.

So it seems that logging in without knowing the password is unlikely, and authentication using hardcoded credentials is denied. We need to dig deeper.

Peculiarities

While exploring the functionalities offered by the web interfaces, we observed a number of strange behaviors. At one point, an empty file with the name anyone_login_to_web appeared in the /tmp directory. We were quite sure that we had never seen this file before and that none of us had created it there. When we checked again, the file disappeared, and when we looked at the /tmp directory again, the file was there. Listing the /tmp directory repeatedly with a script revealed that the file would appear for a moment and then disappear, only to reappear a moment later.

Judging by the name of the file, its presence might allow the login mechanism to be bypassed. Creating the file manually turned out to be cumbersome, as something deletes the file immediately after it is created, so we run a script that creates the file in an infinite loop. However, authentication still seemed to be required for the same paths as before. More reversing is needed.

Searching all binaries for the filename showed that it occurs in two of them: /bin/webd and /bin/central_server. In webd, there is a system call to create the file in the same function that is responsible for checking the authentication.

In central_server, multiple references to the anyone_login_to_web are present close to each other.

Where FUN_00075008 is just a wrapper for a __xstat call. It looks like webd creates the strange file and central_server, which is also running on the camera, just removes the file unless the current_dbg_level is set. Since we cannot remotely enable debugging without knowing the login credentials, it seems that we will not be able to take advantage of this peculiar behavior. Meanwhile, we solved the mystery of the /tmp/anyone_login_to_web file that was spontaneously appearing on the camera. It turned out that one of us was running an automated scan of the camera’s web interface, and the file gets created by webd every time a request is sent to the path that does not require authentication, or when an authenticated request is sent.

Another weird thing we found was a backdoor. Yes, a backdoor in the camera. Or at least a file named “backdoor”. In the systemd binary running on the camera, references to /tmp/backdoor and /data/app/backdoor were found and used to check for the existence of these files. However, since we did not find any other functionality that could create either of these files, we did not investigate further what backdoor functionality was actually shipped with the camera.

Missing Validation

Sticking to the API endpoints reachable without authentication, we have 5 paths that accept PUT and POST requests.

/syno-api/activate only accepts the value true as it is defined in /www/camera-cgi/synocam_config.json:
"activate": {"Default": false, "ParamType": "bool", "LegalValue": true, "ConfigGroup": "Custom.Activated"}.
The endpoint is used during the camera initialization when a PUT request with true is actually sent by the browser. According to the same configuration, /syno-api/security/info/language is a string value and no further restrictions are defined. Although the APIs for changing the camera’s MAC address and serial number can be accessed without authentication, these values are marked as read-only and cannot actually be changed. The /syno-api/session is handled differently than the previous requests, as it does not set or get a value stored in the active.json file, as the previous requests would do. Instead, this path can be used to get the current session in the response body if a valid session was provided in the request cookie. However, when accessing this endpoint with a PUT request, the camera always responds with “Invalid Uri.” because it tries to write the sent value into the active.json but cannot find there a parameter named “session” . In summary, although 5 paths are allowed for unauthenticated access, the only API that accepts any input is /syno-api/security/info/language.

Testing what values would be accepted by the language endpoint showed that any value up to 32 characters would be accepted, not just the predefined languages.

Finding out how the language value is used led us to a HTTP GET request to /uistrings/uistrings.cgi, which provides translations of all UI elements. This request can also be sent unauthenticated, so we took a closer look at how the set language is processed by the uistrings.cgi file.

The logic in the CGI file turned out to be very simple. The CGI script takes the stored language value and concatenates it with “/strings”. Then, if the <language>/strings file can be opened, it is read and, after some processing, its contents are returned in the HTTP response. If the file cannot be opened, a default enu/strings file is returned.

We tried to abuse the missing validation of the language parameter to leak the contents of the configuration file or the file that stores cookies. However, since the end of the path is beyond our control, all attempts were in vain.

JSON Parsing

Interestingly, despite the fact that the PUT request to set the language has a simple string in the body, the request must be sent with Content-Type: application/json, otherwise the request will not be accepted and an HTTP 404 response will be returned with an “Invalid Uri.” error message.

If the server is indeed expecting a JSON request, we sent a PUT request with a JSON body to the camera to see how such a request is processed. The response we got showed that the JSON was parsed, and it appears that keys from the JSON are concatenated with the path.

However, to verify that the JSON data could be used instead of the path, authenticated requests must be sent, since only requests to the full /syno-api/security/info/language path can be unauthenticated.

And as expected, a part of the path could be replaced by a JSON with the appropriate key

Also, multiple parts of the path could be converted to JSON

Interestingly, as implied by the error message received earlier, the dot is an element used to internally separate parts of the path and JSON keys

If we were able to send a request to a path that does not require authentication, and provide such JSON in the body that the request would be interpreted as modifying some other configuration value, we would have an authentication bypass.

Nevertheless, poking around with the JSON did not result in the authentication bypass. But during attempts to achieve it another interesting behavior was observed. If the JSON key is at least 52 characters long, an internal server error occurs.

Even stranger, requests with JSON keys that are at least 48 characters long contain non-ASCII bytes in the response error message.

The observed behavior was consistent and did not depend on the path where the request was sent, but the authentication check was performed before parsing the JSON. This means that we could send requests to /syno-api/activate, but not to /syno-api/security/user, for example. The fact that non-ASCII bytes appear in the response for JSON key lengths from 48 to 51 characters, which is exactly 4 bytes. The fact that binaries on the camera are 32-bit. And the fact that requests with long JSON keys cause a server error indicate that there is a memory corruption vulnerability in the JSON parsing functionality.

So finally we found a vulnerability that can be triggered by an unauthenticated actor and potentially lead to remote code execution on the camera.

Stay tuned for the next two articles in this series: