By Vasco Franco
In part one of this two-part series, we escaped Webviews in real-world misconfigured VSCode extensions. But can we still escape extensions if they are well-configured?
In this post, we’ll demonstrate how I bypassed a Webview’s localResourceRoots
by exploiting small URL parsing differences between the browser—i.e., the Electron-created Chromium instance where VSCode and its Webviews run—and other VSCode logic and an over-reliance on the browser to do path normalization. This bypass allows an attacker with JavaScript execution inside a Webview to read files anywhere in the system, including those outside the localResourceRoots
. Microsoft assigned this bug CVE-2022-41042
and awarded us a bounty of $7,500 (about $2,500 per minute of bug finding).
Finding the issue
While exploiting the vulnerabilities detailed in the last post, I wondered if there could be bugs in VSCode itself that would allow us to bypass any security feature that limits what a Webview can do. In particular, I was curious if we could still exploit the bug we found in the SARIF Viewer extension (vulnerability 1 in part 1) if there were stricter rules in the Webview’s localResourceRoots
option.
From last post’s SARIF viewer exploit, we learned that you can always exfiltrate files using DNS prefetches if you have the following preconditions:
- You can execute JavaScript in a Webview. This enables you to add
link
tags to the DOM. - The CSP’s
connect-src
directive has the.vscode-resource.vscode-cdn.net
source. This enables you tofetch
local files.
…Files within the localResourceRoots
folders, that is! This option limits the folders from which a Webview can read files, and, in the SARIF viewer, it was configured to limit, well… nothing. But such a permissive localResourceRoots
is rare. Most extensions only allow access to files in the current workspace and in the extensions folder (the default values for the localResourceRoots
option).
Recall that Webviews read files by fetching the https://file+.vscode-resource.vscode-cdn.net
“fake” domain, as shown in the example below.
Without even looking at how the code enforced the localResourceRoots
option, I started playing around with different path traversal payloads with the goal of escaping from the root directories where we are imprisoned. I tried a few payloads, such as:
/etc/passwd
/../../../../../etc/passwd
/[valid_root]/../../../../../etc/passwd
As I expected, this didn’t work. The browser normalized the request’s path even before it reached VSCode, as shown in the image below.
I started trying different variants that the browser would not normalize, but that some VSCode logic might consider a valid path. In about three minutes, to my surprise, I found out that using %2f..
instead of /..
allowed us to escape the root folder(!!!).
We’ve escaped! We can now fetch files from anywhere in the filesystem. But why did this work? VSCode seems to decode the %2f
, but I couldn’t really understand what was happening under the hood. My initial assumption was that the function that reads the file (e.g., the fs.readFile
function) was decoding the %2f
, while the path normalization function did not. As we’ll see, this was not a bad guess, but not quite the real cause.
Root cause analysis
Let’s start from the beginning and see how VSCode handles vscode-resource.vscode-cdn.net
requests—remember, this is not a real domain.
It all starts in the service worker running on the Webview. This service worker intercepts every Webview’s request to the vscode-resource.vscode-cdn.net
domain and transforms it into a postMessage('load-resource')
to the main VSCode thread.
VSCode will handle the postMessage('load-resource')
call by building a URL object and calling loadResource
, as shown below.
Notice that the URL path is decoded with decodeURIComponent
. This is why our %2f
is decoded! But this alone still doesn’t explain why the path traversal works. Normalizing the path before checking if the path belongs to one of the roots would prevent our exploit. Let’s keep going.
The loadResource
function simply calls loadLocalResource
with roots: localResourceRoots
.
Then, the loadLocalResource
function calls getResourceToLoad
, which will iterate over each root in localResourceRoots
and check if the requested path is in one of these roots. If all checks pass, loadLocalResource
reads and returns the file contents, as shown below.
There is no path normalization, and the root check is done with resourceFsPath.startsWith(rootPath)
. This is why our path traversal works! If our path is [valid-root-path]/../../../../../etc/issue
, we’ll pass the .startsWith
check even though our path points to somewhere outside of the root.
In summary, two mistakes allow our exploit:
- The VSCode extension calls
decodeURIComponent(path)
on the path, decoding%2f
to/
. This allows us to bypass the browser’s normalization and introduce ../ sequences in the path. - The
containsResource
function checks that the requested file is within the expectedlocalResourceRoots
folder with thestartsWith
function without first normalizing the path (i.e., removing the../
sequences). This allows us to traverse outside the root with a payload such as[valid-root-path]/../../../
.
This bug is hard to spot by just manually auditing the code. The layers of abstraction and all the message passing mask where our data flows through, as well as some of the critical details that make the exploit work. This is why evaluating and testing software by executing the code and observing its behavior at runtime—dynamic analysis—is such an important part of auditing complex systems. Finding this bug through static analysis would require defining sources, sinks, sanitizers, and an interprocedural engine capable of understanding data that is passed in postMessage
calls. After all that work, you may still end up with a lot of false positives and false negatives; we use static analysis tools extensively at Trail of Bits, but they’re not the right tool for this job.
Recommendations for preventing path traversals
In the last blog’s third vulnerability, we examined a path traversal vulnerability caused by parsing a URL’s query string with flawed hand-coded logic that allowed us to circumvent the path normalization done by the browser. These bugs are very similar; in both cases, URL parsing differences and the reliance on the browser to do path normalization resulted in path traversal vulnerabilities with critical consequences.
So, when handling URLs, we recommend following these principles:
- Parse the URL from the path with an appropriate object (e.g., JavaScript’s URL class) instead of hand-coded logic.
- Do not transform any URL components after normalization unless there is a very good reason to do so. As we’ve seen, even decoding the path with a call to
decodeURIComponent(path)
was enough to fully bypass thelocalResourceRoots
feature since other parts of the code had assumptions that the browser would have normalized the path. If you want to read more about URL parsing discrepancies and how they can lead to critical bugs, I recommend reading A New Era of SSRF by Orange Tsai and Exploiting URL Parsing Confusion. - Always normalize the file path before checking if the file is within the expected root. Doing both operations together, ideally in the same encapsulated function, ensures that no future or existing code will transform the path in any way that invalidates the normalization operation.
Timeline
- September 7, 2022: Reported the bug to Microsoft
- September 16, 2022: Microsoft confirmed the behavior of the report and mentioned that the case is being reviewed for a possible bounty award
- September 20, 2022: Microsoft marks the report as out-of-scope for a bounty because “VS code extensions are not eligible for bounty award”
- September 21, 2022: I reply mentioning that the bug is in the way VSCode interacts with extensions, but not in a VSCode extension
- September 24, 2022: Microsoft acknowledges their mistake and awards the bug a $7,500 bounty.
- October 11, 2022: Microsoft fixes the bug in PR #163327 and assigns it CVE-2022-41042.