TL;DR: NodeJS in debug mode did not check the Origin-Header of websocket connections. This could lead to arbitrary code execution on victims systems if they visited a malicious website while debugging NodeJS. Visual Studio Code 1.19 - 1.19.2 was running in debug mode by default and exposed all users to this vulnerability.
Due to my suspiciousness against 3rd party software (probably a side effect of being an information security professional) I regularly check my systems for open ports (either by directly using ss/netstat or with my cinnamon plugin (disclaimer: mostly written for my needs and I'm to lazy for the documentation)). In January 2018 I noticed that Visual Studio Code has opened TCP port 9333 listening on localhost (version 1.19.1 at that time).
Due to my suspiciousness against 3rd party software (probably a side effect of being an information security professional) I regularly check my systems for open ports (either by directly using ss/netstat or with my cinnamon plugin (disclaimer: mostly written for my needs and I'm to lazy for the documentation)). In January 2018 I noticed that Visual Studio Code has opened TCP port 9333 listening on localhost (version 1.19.1 at that time).
It's not uncommon these days for code editors to open listeners (mostly for remote debugging, but also for live updating the website your currently developing or shared intellisense support). Nevertheless, I wanted to see why it's listening on this port as I wasn't debugging anything and also worked only on some python code.
As it turned out, it was actually a debug port! Not for some code I was developing, but to debug vscode itself...
The debug protocol is based on the Google Chrome debugging protocol, which itself uses websockets to interact with the debuggee. To use this debug api, one has to connect to a websocket server running at
ws://127.0.0.1:9333/<some uuid>
(e.g. ws://127.0.0.1:9333/e6958bb9-c030-4ead-a843-6d4cde63d129
). The uuid in this case is randomly generated during the start of the debug server and acts as some sort of "authentication".
A client which wants to debug vscode or one of its extensions has to visit http://127.0.0.1:9333/json to obtain this uuid before opening the connection:
$ curl -i http://127.0.0.1:9333/json
HTTP/1.0 200 OK
Content-Type: application/json; charset=UTF-8
Cache-Control: no-cache
Content-Length: 479
[ {
"description": "node.js instance",
"devtoolsFrontendUrl": "chrome-devtools://devtools/bundled/inspector.html?experiments=true&v8only=true&ws=127.0.0.1:9333/37507bb8-193d-4ba1-8b99-ed4650250232",
"faviconUrl": "https://nodejs.org/static/favicon.ico",
"id": "37507bb8-193d-4ba1-8b99-ed4650250232",
"title": "/opt/visual-studio-code/code",
"type": "node",
"url": "file://",
"webSocketDebuggerUrl": "ws://127.0.0.1:9333/37507bb8-193d-4ba1-8b99-ed4650250232"
} ]
A maybe lesser known fact is that the same-origin policy is not enforced on websockets. This means that any website is allowed to talk to a websocket endpoint. The advise in this case is to check the Origin-Header against a whitelist of hosts as this header is enforced by browsers.
As the used implementation doesn't check this header, the only protection against malicious websites is the unpredictability of the uuid value. My first attempt to bypass this "protection" was to try a time-based attack against each character in the uuid as the comparison is done in a non-constant way. While waiting for some tests to finish, I became aware of the DNS rebinding approaches used by Tavis Ormandy in his Project Zero bugs.
DNS rebinding exploits the fact that most of the DNS resolvers allow domain names to point to 127.0.0.1 or ::1. A rebinding attack is done by changing the target ip address of a domain name in a short amount of time to 127.0.0.1 after the malicious website was delivered to the victim browser:
- evil.com's A record points to an attacker controlled server
- victim visits evil.com, loads embedded javascript code
- evil.com's A record points to 127.0.0.1
- javascript code tries to request evil.com (now pointing to 127.0.0.1)
- Same-Origin policy not violated => access to local server granted
This enabled me to request http://127.0.0.1:9333/json from a website and therefore obtain the uuid value.
After being able to establish a connection, I tried to gain arbitrary code execution and also execute system commands. Injecting javascript code into the vscode process can be done by calling the Runtime.evaluate method with an expression parameter containing the code to run and a generatePreview parameter set to true (immediately returns the result of the call). Sadly, no useful functions or functions were available in the main context (e.g. require to load modules or the child_process module to execute shell commands). Luckily, a process object is available in the context. This object has a mainModule attribute, which itself contains a reference to the require function 😊. Full exploit expression:
require = process.mainModule.require;
execSync = require("child_process").execSync;
execSync("ifconfig");
I always had the feeling that this might affect other applications as well, but hadn't the time to investigate it further until March 2018. After digging a bit into the components of vscode, I noticed that it was only using the nodejs implementation of the debugging protocol. As it turns out, the only difference between those are the port number (9333 vs 9229) and that the plain NodeJS already provides the require function in the global scope. I adjusted the poc, confirmed that it works against a plain NodeJS instance and contacted the NodeJS security team.
Timeline
- 2018-03-02: Adjusted poc for nodejs and contacted security@nodejs.org
- 2018-03-05: Vulnerability confirmed by the NodeJS security team
- 2018-03-28: Fixed versions released
IMO checking the origin header and host is not sufficient to completely fix the vulnerability, it only limits exploitability by preventing javascript running in a browser from accessing it.
ReplyDeleteConsider an application running on the same host on a low privileged account. This application can open TCP connections to localhost (where it can specify an arbitrary host and origin, since it's not a browser) and use that to get code execution on the user the debug server is running on.
To properly fix this, the whole http://127.0.0.1:9333/json endpoint needs to be removed and replaced by a transport mechanism other than TCP which enforces ACLs or other access control mechanisms. (Personally I'm still not too happy with such an approach, because it relies on implicit authentication, but at least it matches the security model of Windows/Linux)