前言
对于另一篇文章的学习,可以知道webPreferences
的配置问题对Electron
应用造成的安全问题。
主要以下三个方面:
- 未开启上下文隔离以及
sandbox
- 不安全的实现
- 接口过度暴露
第一点,前面的一篇文章已经说的很清楚了。现在我们要关注的点在于,安全配置下的安全问题,我们的前提是开启了上下文隔离,也开启了sandbox
。
不安全的实现
preload预加载脚本的意义在于完成主进程和渲染进程之前的联络,因此重要逻辑不应该在预加载脚本中进行,也不应该赋予其过于繁重的责任,完成主进程与渲染进程之间的通信,将通信结果传递给另一方才是它实际的意义,通过暴露方法使这种固定的逻辑可以被渲染进程调用。
因此预加载脚本在渲染器加载网页之前注入,也就是说预加载脚本中的内容会先一步定义好,以供网页中的Javascript正确调用。
测试环境
下面搭建环境,举个任意文件读取的极端例子。
测试环境为v37.2.6
main.js
对应的main.js
为:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| const { app, BrowserWindow, ipcMain } = require("electron"); const fs = require("fs"); const path = require("path"); function createWindow() { const win = new BrowserWindow({ width: 800, height: 600, webPreferences: { preload: path.join(__dirname, "preload.js"), }, }); win.loadFile("./index.html"); } app.whenReady().then(() => { ipcMain.handle("readFile", async (event, filePath) => { try { filePath = path.join(__dirname, filePath); const data = await fs.promises.readFile(filePath, "utf-8"); return data; } catch (err) { console.error("Error reading file:", err); return null; } }); createWindow(); }); app.on("window-all-closed", () => { if (process.platform !== "darwin") { app.quit(); } });
|
preload.js
预加载脚本preload.js
的代码为:
1 2 3 4 5 6 7 8 9 10 11 12
| const { contextBridge, ipcRenderer } = require("electron"); contextBridge.exposeInMainWorld("myApi", { readFile: async (fileName) => { try { const data = await ipcRenderer.invoke("readFile", `docs/${fileName}`); return data; } catch (error) { console.error('Error invoking "readFile":', error); return null; } }, });
|
renderer.js
渲染进程的renderer.js
脚本为:
1 2 3 4 5 6 7 8 9
| const fileNameInput = document.getElementById("fileNameInput"); const readFileButton = document.getElementById("readFileButton"); const fileContent = document.getElementById("fileContent"); readFileButton.addEventListener("click", async () => { const fileName = fileNameInput.value; const data = await window.myApi.readFile(fileName); fileContent.textContent = data || "No content available."; });
|
前端显示的界面为index.html
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| <!DOCTYPE html> <html lang="en">
<head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Electron Path Traversal Vulnerability Demo</title> </head>
<body> <input type="text" id="fileNameInput" placeholder="Enter file name"> <button id="readFileButton">Read File</button> <pre id="fileContent"></pre> <script src="./renderer.js"></script> </body>
</html>
|
实验结果
当我们输入../../../../../../../etc/passwd
,点击读取文件。

我们回头看代码:
-
主进程main.js
中的ipcMain.handle("readFile",...)
负责监听readFile
事件的发生,并进行事件的处理。
-
预加载脚本preload.js
中的contextBridge.exposeInMainWorld("myApi",...)
,ipcRenderer.invoke("readFile",...)
负责将readFile
事件进行暴露,暴露给renderer.js
。
-
渲染进程renderer.js
中的window.myApi.readFile(fileName)
调用readFile
事件,进行文件的读取。


小结
由这个极端的任意文件读取例子,我们可以学习到整个的readFile
事件调用的流程。
了解了整个流程以后,我们发现,可以通过函数ipcMain.handle(...)
去进行定位,再查看主进程main.js
中定义的事件有没有使用到敏感操作的函数(如文件读取,命令执行等),再去看这个敏感函数的输入参数的内容有没有进行完全的过滤。若敏感操作的函数没有进行参数的完全过滤,再从renderer.js
的渲染页面去找XSS
漏洞,利用XSS
去调用renderer.js
的敏感函数事件,控制输入,传输给main.js
,进而调用了危险函数,进而实现了整个利用的过程。
接口过度暴露
在前面的例子中,我们开启了sandbox
,使用预加载脚本将API
暴露给渲染进程,我们将打开文件功能进行了封装,封装成了一个函数,这也就意味着每个新功能,如果需要主进程参与可能都会创建不止一个新的函数。
如果开发者直接将ipcRenderer
或ipcRenderer.invoke
这种API
或非必要函数直接暴露给渲染进程,就可能导致渲染进程发起IPC
通信,获取敏感信息或RCE等。
测试环境
假设程序由很多和操作系统命令执行结果相关的功能,所有主进程有一个接收参数并执行的通信,这样的preload
脚本中直接传递参数,复用这一个监听即可。
main.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| const { app, BrowserWindow, ipcMain } = require("electron"); const path = require("path"); function createWindow() { const win = new BrowserWindow({ width: 800, height: 600, webPreferences: { preload: path.join(__dirname, "preload.js"), }, }); win.loadFile("./index.html"); } app.whenReady().then(() => { ipcMain.handle("exec-command", async (event, cmd) => { return new Promise((resolve, reject) => { require("child_process").exec(cmd, (error, stdout, stderr) => { if (error) { console.error(`Error executing command "${cmd}":`, error); reject(error); } else { resolve(stdout.trim()); } }); }); }); createWindow(); }); app.on("window-all-closed", () => { if (process.platform !== "darwin") { app.quit(); } });
|
preload.js
1 2 3 4 5
| const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("electronApi", { invoke: ipcRenderer.invoke, });
|
renderer.js
1 2 3 4
| const fileContent = document.getElementById("cmdResultContent"); window.electronApi.invoke("exec-command", "pwd").then((result) => { fileContent.textContent = result || "No cmd exec result available."; });
|
前端渲染显示的界面为index.html
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| <!DOCTYPE html> <html lang="en">
<head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Electron Path Traversal Vulnerability Demo</title> </head>
<body> <pre id="cmdResultContent"></pre> <script src="./renderer.js"></script> </body>
</html>
|
实验结果
实验结果,最后成功执行了pwd
。

ipcRenderer.invoke
本来只是在preload.js
中与main.js
交互的接口,但是在这个例子,preload.js
将这个权限很大的接口传递给了renderer.js
,这就使得renderder.js
可以直接和main.js
进行IPC通信了,这就导致了直接的命令执行。
小结
在编写preload.js脚本的时候,要注意,哪些函数是自己使用的,哪些接口不能暴露给渲染进程。
总结
如果安全配置较为完善,预加载脚本preload.js是一个很好的代码审计的切入点,因为安全漏洞的利用基本都要通过preload.js传递数据数据,这也就是掌握了咽喉位置,详细分析每一个IPC通信,就能找到几乎所有渲染进程攻击主进程的攻击面。
参考