Electron中preload中的安全问题
2025-09-07 22:35:16 # 客户端安全

前言

对于另一篇文章[1]的学习,可以知道webPreferences的配置问题对Electron应用造成的安全问题。

主要以下三个方面:

  • 未开启上下文隔离以及sandbox
  • 不安全的实现
  • 接口过度暴露

第一点,前面的一篇文章已经说的很清楚了。现在我们要关注的点在于,安全配置下的安全问题,我们的前提是开启了上下文隔离,也开启了sandbox

不安全的实现

preload预加载脚本的意义在于完成主进程和渲染进程之前的联络[2],因此重要逻辑不应该在预加载脚本中进行,也不应该赋予其过于繁重的责任,完成主进程与渲染进程之间的通信,将通信结果传递给另一方才是它实际的意义,通过暴露方法使这种固定的逻辑可以被渲染进程调用

因此预加载脚本在渲染器加载网页之前注入,也就是说预加载脚本中的内容会先一步定义好,以供网页中的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);
// console.log(data)
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,点击读取文件。

image-20250907211943972

我们回头看代码:

  • 主进程main.js中的ipcMain.handle("readFile",...)负责监听readFile事件的发生,并进行事件的处理。

  • 预加载脚本preload.js中的contextBridge.exposeInMainWorld("myApi",...),ipcRenderer.invoke("readFile",...)负责将readFile事件进行暴露,暴露给renderer.js

  • 渲染进程renderer.js中的window.myApi.readFile(fileName)调用readFile事件,进行文件的读取。

image-20250907220531382

image-20250907220029507

小结

由这个极端的任意文件读取例子,我们可以学习到整个的readFile事件调用的流程。

了解了整个流程以后,我们发现,可以通过函数ipcMain.handle(...)去进行定位,再查看主进程main.js中定义的事件有没有使用到敏感操作的函数(如文件读取,命令执行等),再去看这个敏感函数的输入参数的内容有没有进行完全的过滤。若敏感操作的函数没有进行参数的完全过滤,再从renderer.js的渲染页面去找XSS漏洞,利用XSS去调用renderer.js的敏感函数事件,控制输入,传输给main.js,进而调用了危险函数,进而实现了整个利用的过程。

接口过度暴露

在前面的例子中,我们开启了sandbox,使用预加载脚本将API暴露给渲染进程,我们将打开文件功能进行了封装,封装成了一个函数,这也就意味着每个新功能,如果需要主进程参与可能都会创建不止一个新的函数。

如果开发者直接将ipcRendereripcRenderer.invoke这种API或非必要函数直接暴露给渲染进程,就可能导致渲染进程发起IPC通信,获取敏感信息或RCE等。

测试环境

假设程序由很多和操作系统命令执行结果相关的功能,所有主进程有一个接收参数并执行的通信,这样的preload脚本中直接传递参数,复用这一个监听即可[3]

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 将整个 ipcRenderer 对象暴露给渲染进程
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

image-20250907222056807

ipcRenderer.invoke本来只是在preload.js中与main.js交互的接口,但是在这个例子,preload.js将这个权限很大的接口传递给了renderer.js,这就使得renderder.js可以直接和main.js进行IPC通信了,这就导致了直接的命令执行。

小结

在编写preload.js脚本的时候,要注意,哪些函数是自己使用的,哪些接口不能暴露给渲染进程。

总结

如果安全配置较为完善,预加载脚本preload.js是一个很好的代码审计的切入点,因为安全漏洞的利用基本都要通过preload.js传递数据数据,这也就是掌握了咽喉位置,详细分析每一个IPC通信,就能找到几乎所有渲染进程攻击主进程的攻击面

参考


  1. https://x2nn.github.io/2025/08/12/Electron中的安全问题/#我的结论 ↩︎

  2. https://www.electronjs.org/zh/docs/latest/tutorial/tutorial-preload ↩︎

  3. https://github.com/Just-Hack-For-Fun/Electron-Security/blob/main/Electron-Preload/Electron 预加载脚本.pdf ↩︎