什么是 Electron

Chromium+NodeJS≈Electron!

简单来说,就是用 web 前端开发的技术栈来写跨平台的桌面端应用,运行环境是 Chromium,NodeJS 负责调用系统 API、与操作系统底层进行交互,实现了仅靠前端无法实现的功能

虽然降低了开发门槛 很好上手,但缺点也很明显 —— 体积很大、占用很大,每一个 Electron 应用就带了一个 Chromium 内核,代码几十 k 引擎几百 M,还会释放用户的数据到 AppData,一旦进程启动…. 想想浏览器恐怖的内存占用吧

有同样理念的还有 tauri,使用的是 Rust + 原生 webview,但 Rust 做后端就能明显看出二者的差距了,tauri 是向传统后端项目中添加 webview 做 GUI,而 Electron 则是 Javascript 走天下

* 讲正事之前,来点前端开发的笑话

image-20230805165307463

Electrun Runtime 的设想

既然每一个 Electron 应用都需要 Chromium 和 NodeJS 那为什么不把这两个打包成类似 JDK 的 Electron Runtime 呢?

我们先讨论为什么不行;Electron 和 Node 的版本滚动更新都很勤快,更别提依赖的前端库更是一个版本更新过后就要担心是否会造成冲突 / 报错,因此如果一个版本对应一个运行时 可能最终效果和现行方案是一样的

但这不耽误我们设想 如果真要做成 Runtime,开发流程和使用环境会做出怎样的改变;以 Runtime 开发者的角度来说,我们需要为 Runtime 使用者(也就是 Electron 应用开发者)提供 打包工具,Runtime 使用者可以使用打包工具将 js 等静态文件和 执行程序卸载程序一起打包为资源文件,这个资源文件会被封装到安装程序中,在修改图标、签名、文件名等资源信息后就得到了 安装程序

这个安装程序在用户电脑上运行时会自动检测是否安装了 Electron Runtime,如果未安装会自动下载发行版 并记录到注册表上,之后在用户指定的安装目录下释放资源文件,向注册表中写下 卸载程序的位置(使用户可以通过控制面板卸载程序),按用户需求创建开始菜单等等,这样程序就安装完成了

执行程序在运行时会检测用户的注册表,找到 Electron Runtime 的位置,并把当前入口程序作为参数传给 Runtime

卸载程序会删除安装目录下的文件、删除注册表的信息

调试

开启调试

我们可以在以 Chromium 为内核的任意浏览器中直接调试 Electron 应用,比如纯净的 Chromium 内核

image-20230804173904739

或混沌中立的 edge

image-20230804173951759

一般情况下可以在运行程序时加上这样的参数来开启 debug

--remote-debugging-port=9222

image-20230805145641055

但很显然这个 flag 非常醒目,很多开发者会在初始化阶段对其进行检测 并终止程序运行

image-20230804174325497

虽然但是 家贼难防啊 ——NodeJS 可以仍可开启动态调试,直接锁定 PID 开启调试

kill -SIGUSR [pid]

windows 可以在 NodeJS 中调用_debugProcess

process._debugProcess(pid)

image-20230804175313142

image-20230810124339767

一旦允许调试 那就意味着打开了潘多拉的魔盒,debugger 对于 NodeJS 的执行环境有完全的权限(没有什么上下文隔离之类的阻拦),如果这一调试功能被滥用 就可直接导致 RCE(详情见后)

源码

Electron 应用的 js 源码一般就位于程序目录的 resources 文件夹内,会有一份.asar 格式的打包文件,我们可以用 asar 这个官方工具进行解压

asar extract app.asar asar/

image-20230804181019523

可以发现.asar 的内容实际是 JSON + 自定义编码,非常容易从中得到源码

* 因此审计 Electron 项目约等于白盒审计 x

源码加密

由于 Electron 官方没有在源码保护方面给出很好的解决办法,出现了开源的加密方案 -> electron-asar-encrypt-demo

Electron 的架构

可能由于 Electron 和 Chromium 深度绑定的原因,Electron 也仿照了 Chromium 的多进程模式,即:主进程 Main Process 作为核心进程,一个窗口一个独立的渲染进程 Renderer Process;主进程和渲染进程之间采取 IPC 通信

image-20230809164606231

以 Typora 举例,打开一个 Typora 窗口会启动 4 个进程(所有 Electron 应用的表现都一样),用 flag 区分不同进程的职责

image-20230808170518545

  • 无 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

使用例

  1. 创建项目
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 ."
}
  1. 编写 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()
})
  1. 编写 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对象
  }
})
  1. 编写网页(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>
  1. 运行

因为前面已经在 package.json 中添加了运行命令,直接用 npm start 即可执行

image-20230807123403980

  1. 打包分发
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 连接我们自己就可以做到

image-20230810123554023

2019 年就有大佬完成了实现 debug 通讯协议的工具 -> cefdebug,可以自动查找当前开启 debug 的 Electron 应用并像 DevTools 窗口一样执行命令

image-20230810151209672

设想这样的攻击场景:受害者本地 Electron 应用开启调试,通过钓鱼手段让受害者运行 cefdebug 应用并自动连接 ws,达成 RCE,同时让 js 开启新的 socket 连接做到回显

但…. 我们怎么能保证受害者本地的 Electron 应用能开启调试呢?正常的开发者都不会在启动时留下这样的 flag

我们考虑直接 pkg 打包一个带 Node 环境的恶意文件,在其中用 process._debugProcess 开启 Electron 应用的调试,再接上前面的设想,达成 RCE

Electron_shell

新鲜热乎的利用工具 -> Electron_shell

image-20230810112949105

源码分析

主要文件是_inspect.js, _inspect_repl.js 和_inspect_clients.js,其实这三个代码就是从 Node 源码中摘出来的

image-20230810165543995

作者在_inspect.js 的 startInspect 函数中加入了寻找 pid 的逻辑,并把原来从命令行读入的 debug 参数改为了手动指定 pid

image-20230810170126380

其中的 writeTofile 函数是向系统临时目录中写入 RCE 和回显的 js payload

image-20230810170659210

而执行 payload 被放在了 inspect_repl.js 中

image-20230810170758498

最终以 inspect.js 的 startInspect 作为入口,用 pkg 打包 NodeJS 运行环境

pkg.cmd -t node16-win-x64 cli.js

经过分析我们发现,cli.exe 实际就是把 node-cli 内置的和 inspect 有关的部分拆了出来,并加入恶意的部分组合打包而成的


以下是本文中涉及到的 和我学习时看过的所有文章的链接 每日感谢互联网的丰富资源(

如何评价 Electron?

为什么 electron 不做成独立的 runtime? - liulun 的回答 - 知乎

注入任意代码到运行中的 Electron 应用

向 Typora 学习 electron 安全攻防

深入理解 Electron(一)Electron 架构介绍

quick-start

cefdebug | Electron_shell