什么是Electron
Chromium+NodeJS≈Electron!
简单来说,就是用web前端开发的技术栈来写跨平台的桌面端应用,运行环境是Chromium,NodeJS负责调用系统API、与操作系统底层进行交互,实现了仅靠前端无法实现的功能
虽然降低了开发门槛 很好上手,但缺点也很明显——体积很大、占用很大,每一个Electron应用就带了一个Chromium内核,代码几十k 引擎几百M,还会释放用户的数据到AppData,一旦进程启动…. 想想浏览器恐怖的内存占用吧
有同样理念的还有tauri,使用的是Rust+原生webview,但Rust做后端就能明显看出二者的差距了,tauri是向传统后端项目中添加webview做GUI,而Electron则是Javascript走天下
*讲正事之前,来点前端开发的笑话
Electrun Runtime的设想
既然每一个Electron应用都需要Chromium和NodeJS 那为什么不把这两个打包成类似JDK的Electron Runtime呢?
我们先讨论为什么不行;Electron和Node的版本滚动更新都很勤快,更别提依赖的前端库更是一个版本更新过后就要担心是否会造成冲突/报错,因此如果一个版本对应一个运行时 可能最终效果和现行方案是一样的
但这不耽误我们设想 如果真要做成Runtime,开发流程和使用环境会做出怎样的改变;以Runtime开发者的角度来说,我们需要为Runtime使用者(也就是Electron应用开发者)提供 打包工具,Runtime使用者可以使用打包工具将js等静态文件和 执行程序、 卸载程序一起打包为资源文件,这个资源文件会被封装到安装程序中,在修改图标、签名、文件名等资源信息后就得到了 安装程序
这个安装程序在用户电脑上运行时会自动检测是否安装了Electron Runtime,如果未安装会自动下载发行版 并记录到注册表上,之后在用户指定的安装目录下释放资源文件,向注册表中写下 卸载程序的位置(使用户可以通过控制面板卸载程序),按用户需求创建开始菜单等等,这样程序就安装完成了
执行程序在运行时会检测用户的注册表,找到Electron Runtime的位置,并把当前入口程序作为参数传给Runtime
卸载程序会删除安装目录下的文件、删除注册表的信息
调试
开启调试
我们可以在以Chromium为内核的任意浏览器中直接调试Electron应用,比如纯净的Chromium内核
或混沌中立的edge
一般情况下可以在运行程序时加上这样的参数来开启debug
--remote-debugging-port=9222
但很显然这个flag非常醒目,很多开发者会在初始化阶段对其进行检测 并终止程序运行
虽然但是 家贼难防啊——NodeJS可以仍可开启动态调试,直接锁定PID开启调试
kill -SIGUSR [pid]
windows可以在NodeJS中调用_debugProcess
process._debugProcess(pid)
一旦允许调试 那就意味着打开了潘多拉的魔盒,debugger对于NodeJS的执行环境有完全的权限(没有什么上下文隔离之类的阻拦),如果这一调试功能被滥用 就可直接导致RCE(详情见后)
源码
Electron应用的js源码一般就位于程序目录的resources文件夹内,会有一份.asar格式的打包文件,我们可以用asar这个官方工具进行解压
asar extract app.asar asar/
可以发现.asar的内容实际是JSON+自定义编码,非常容易从中得到源码
*因此审计Electron项目约等于白盒审计x
源码加密
由于Electron官方没有在源码保护方面给出很好的解决办法,出现了开源的加密方案-> electron-asar-encrypt-demo
Electron的架构
可能由于Electron和Chromium深度绑定的原因,Electron也仿照了Chromium的多进程模式,即:主进程Main Process作为核心进程,一个窗口一个独立的渲染进程Renderer Process;主进程和渲染进程之间采取IPC通信
以Typora举例,打开一个Typora窗口会启动4个进程(所有Electron应用的表现都一样),用flag区分不同进程的职责
- 无flag:主进程
- type=utility:效率进程,可用于托管不受信的服务、CPU密集型服务或容易崩溃的组件,可以通过MessagePorts与渲染进程建立通信,当需要从主进程派生新的子进程时 Electron会优先选择效率进程API而不是NodeJS child_process.fork API
- type=renderer:渲染进程,再打开第二个Typora窗口会新增一个type=renderer的进程,其余进程不变;第三个进程
主进程&渲染进程
主进程的主要是使用BrowserWindow
模块创建和管理窗口,一个实例对应一个app窗口,使用单独的渲染进程加载其中的网页,在主进程中可以用window的webContents
对象与网页内容进行交互;由于BrowserWindow
模块是一个EventEmitter
,所以我们也可以处理程序的执行流、控制app的生命周期,当一个BrowserWindow
实例被销毁时 对应的渲染器进程也会终止
如果想在BrowserWindow
中集成第三方web内容(web-embeds),可以用<iframe>
, <webview>
(不建议), BrowserViews
;此外,渲染进程也会为web-embeds对象而单独服务
默认情况下nodeIntegration关闭,渲染器无权访问直接NodeJS的API,我们编写的用于交互的页面需要遵守一般网页开发的规范,使用npm包需要用和web开发时一样的打包工具(比如webpack或parcel)
preload.js
预加载脚本会在渲染进程中会被优先于网页内容加载,虽然处于渲染进程中 但可以访问部分Polyfill形式实现的NodeJS API,只能载入部分模块和对象
从Electron12之后 默认情况下contextIsolation开启,preload.js和renderer process、Electron内部代码和renderer process之间都存在隔离,即使preload.js和浏览器/渲染进程共享全局的window
对象,但在preload.js中对window
对象做出的改动无法附加到真正的页面上
为安全考虑我们必须使用contextBridge
以及IPC模块来进行交互,在preload中暴露API、在页面中调用API
// preload.js
const {contextBridge} = require('electron')
contextBridge.exposeInMainWorld('myAPI', {
doAThing:()=> {}
})
// render.js in index.html (in renderer process)
window.myAPI.doAThing()
sandbox
从Electron20开始 渲染进程默认启用sandbox,沙盒化的渲染器不会有NodeJS环境
- 当nodeIntegration启用时沙盒会被禁用
- 在
app.whenReady
前可以调用app.enableSandbox()
强制沙盒化所有渲染器 - 可以用
--no-sandbox
来完全禁用Chromium沙盒
IPC通信
主进程使用ipcMain
,渲染进程使用ipcRender
,preload.js通过contextBridge.exposeInMainWorld()
向渲染进程暴露相关API
出于安全考虑,我们不会在preload.js中暴露整个ipcRenderer.send
,而是有约束、限定功能
- 以渲染进程-> 主进程的通信举例代码:
ipcMain.on, ipcRenderer.send
// main.js
const { app, BrowserWindow, ipcMain } = require('electron')
const path = require('path')
function createWindow () {
const mainWindow = new BrowserWindow({
webPreferences: {
preload: path.join(__dirname, 'preload.js')
}
})
ipcMain.on('set-title', (event, title) => {
const webContents = event.sender
const win = BrowserWindow.fromWebContents(webContents)
win.setTitle(title)
})
mainWindow.loadFile('index.html')
}
app.whenReady().then(() => {
createWindow()
app.on('activate', function () {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
app.on('window-all-closed', function () {
if (process.platform !== 'darwin') app.quit()
})
// preload.js
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('electronAPI', {
setTitle: (title) => ipcRenderer.send('set-title', title)
})
<!--index.html-->
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'">
<title>Hello World!</title>
</head>
<body>
Title: <input id="title"/>
<button id="btn" type="button">Set</button>
<script src="./renderer.js"></script>
</body>
</html>
// renderer.js
const setButton = document.getElementById('btn')
const titleInput = document.getElementById('title')
setButton.addEventListener('click', () => {
const title = titleInput.value
window.electronAPI.setTitle(title)
})
- 渲染器和主进程可以双向通信,使用ipcMain.handle和ipcRenderer.invoke
- 主进程到渲染进程单向通信可以使用webContents.send发送消息,preload.js用ipcRenderer.on处理消息
- 不同渲染进程间的通信使用类似postMessgae的MessagePorts
使用例
- 创建项目
npm init # entry-point应为main.js(做为主进程), author和description为必填项
$env:ELECTRON_MIRROR="https://npm.taobao.org/mirrors/electron/"
npm install --save-dev electron
会自动生成项目的package.json文件,可在scripts
字段中添加
"scripts": {
"start": "electron ."
}
- 编写main.js
main.js即为程序的主进程,可以创建浏览窗口、加载html、设置preload.js,也将处理Electron初始化、窗口事件等,可以把所有主进程都写在这里 也可以拆分成几个文件 然后用require导入
// main.js
const {app, BrowserWindow} = require('electron')
const path = require('path')
const createWindow = () => {
const mainWindow = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
sandbox: false,
nodeIntegration: true,
contextIsolation: false
}
})
// 创建渲染进程
mainWindow.loadFile('index.html')
// 打开开发工具
// mainWindow.webContents.openDevTools()
}
// 管理窗口的生命周期
app.whenReady().then(() => {
createWindow()
app.on('activate', () => {
// 对macOS 如果没有可用窗口 打开新窗口
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
// 除macOS外,当所有窗口都被关闭的时候退出程序
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit()
})
- 编写preload.js
在主进程通过Node的全局process
对象访问进程相关信息很简单,但我们不能直接在主进程中编写DOM,因为它和渲染进程是不同的上下文,这时就需要preload.js,它会在渲染进程加载之前加载,并有权访问两个渲染器全局(window和document)和NodeJS的API
// preload.js
window.addEventListener('DOMContentLoaded', () => {
const replaceText = (selector, text) => {
const element = document.getElementById(selector)
if (element) element.innerText = text
}
for (const type of ['chrome', 'node', 'electron']) {
replaceText(`${type}-version`, process.versions[type]) // 访问NodeJS的process对象
}
})
- 编写网页(HTML)
可以正常使用任何前端常用的技巧——包括meta标签的CSP设置
<!--index.html-->
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'">
<title>你好!</title>
</head>
<body>
<h1>Hello Electron!</h1>
正在使用 Node.js <span id="node-version"></span>,
Chromium <span id="chrome-version"></span>,
和 Electron <span id="electron-version"></span>.
</body>
</html>
- 运行
因为前面已经在package.json中添加了运行命令,直接用npm start
即可执行
- 打包分发
npm install --save-dev @electron-forge/cli
npx electron-forge import
npm run make
在./out/下可以看到打包好的二进制文件
Electron inspect abusing
前面开了个小头,这里来详细说说调试这一功能存在的安全隐患
任意Electron应用都可以开启调试,期间会有类似这样的socket通信
DevTools listening on ws://127.0.0.1:9222/devtools/browser/80cd01d9-9426-4c79-a9ee-8dea8987c336
而这样的websocket连接我们自己就可以做到
2019年就有大佬完成了实现debug通讯协议的工具-> cefdebug,可以自动查找当前开启debug的Electron应用并像DevTools窗口一样执行命令
设想这样的攻击场景:受害者本地Electron应用开启调试,通过钓鱼手段让受害者运行cefdebug应用并自动连接ws,达成RCE,同时让js开启新的socket连接做到回显
但…. 我们怎么能保证受害者本地的Electron应用能开启调试呢?正常的开发者都不会在启动时留下这样的flag
我们考虑直接pkg打包一个带Node环境的恶意文件,在其中用process._debugProcess
开启Electron应用的调试,再接上前面的设想,达成RCE
Electron_shell
新鲜热乎的利用工具-> Electron_shell
源码分析
主要文件是_inspect.js, _inspect_repl.js和_inspect_clients.js,其实这三个代码就是从Node源码中摘出来的
作者在_inspect.js的startInspect
函数中加入了寻找pid的逻辑,并把原来从命令行读入的debug参数改为了手动指定pid
其中的writeTofile
函数是向系统临时目录中写入RCE和回显的js payload
而执行payload被放在了inspect_repl.js中
最终以inspect.js的startInspect
作为入口,用pkg打包NodeJS运行环境
pkg.cmd -t node16-win-x64 cli.js
经过分析我们发现,cli.exe实际就是把node-cli内置的和inspect有关的部分拆了出来,并加入恶意的部分组合打包而成的