失败的 Electron 应用漏洞挖掘尝试(已成功)
挖掘之前先简单写一点 electron 的风险,这个内容其实在面试过几次后发现还是比较常问的,尤其是作为 web 安全和客户端的交界部分,然后一些 app 也没有太大逆向难度,直接解包就能看到几乎所有逻辑,当然如果开了 ASAR Integrity 或者把 main、preload 编成 V8 bytecode,分析起来会麻烦一些。但其实重要的信息或者逻辑其实还是依托于后端,只要开发不犯病乱写在 app 里就还好,客户端只是一个展示的 webview
Electron 的核心风险
Electron 的优势来自混合架构:前端技术负责界面,主进程负责窗口、菜单、文件系统、系统交互等原生能力。这也意味着它天然有两套安全模型叠在一起。
渲染进程本质上接近一个 Chromium 页面,容易遇到传统 Web 场景里的 XSS、DOM 注入、远程资源加载等问题;主进程则拥有完整的 Node.js 和 Electron API 权限,可以读写文件、创建窗口、调用系统能力。正常情况下,这两者应该通过受控 IPC 通信。问题在于,如果渲染进程拿到了 Node.js 能力,普通的脚本注入就可能直接升级成 RCE。
- 渲染进程开启了
nodeIntegration,页面脚本可以直接require('fs')、require('child_process')。 contextIsolation被关闭,页面脚本和 preload 运行在同一个全局上下文里,攻击者有机会污染或篡改暴露出来的接口。- preload 暴露了过大的能力,比如直接把
ipcRenderer、fs、require挂到window上。 - 主进程 IPC 只看 channel,不校验调用来源,也不校验参数结构。
- 应用加载远程内容,却没有把远程页面和本地能力彻底隔离。
所以 Electron 的安全不是一句“不要有 XSS”能解决的。只要应用里存在能影响渲染进程脚本执行的点,就必须继续追问:这个渲染进程能不能碰 Node?能不能调用 preload 暴露的接口?IPC 到主进程后会不会触发更高权限操作?
关键配置
nodeIntegration 应该关闭。它一旦开启,渲染进程就可以直接使用 Node.js 模块。对本地页面来说这已经有风险,如果再配合远程页面加载,风险会更高。一个 XSS payload 原本可能只是读取页面数据,但在这种配置下就可能调用 child_process 执行系统命令。
contextIsolation 应该开启。这是现代 Electron 应用非常关键的安全配置。开启后,页面脚本运行在 Main World,preload 运行在 Isolated World,两边不会共享同一个全局对象和原型链。这样即使页面脚本被污染,也不能直接改写 preload 内部逻辑。
preload 应该只作为受控桥梁存在。它不是给渲染进程“补 Node 能力”的地方,而是把极少量、经过封装的业务接口暴露出去。比如可以暴露 getAppVersion()、openFileDialog() 这种具体能力,但不应该暴露 ipcRenderer.send() 的裸接口,更不应该暴露整个 fs 模块。
sandbox 在条件允许时也值得开启。它能进一步限制渲染进程能力,不过对 preload 和部分旧代码有兼容成本,不能机械打开,需要结合项目结构测试。
Preload 要做减法
preload 是 Electron 里很容易被误用的位置。它确实可以访问部分 Node.js 能力,也能通过 contextBridge.exposeInMainWorld 给页面暴露接口,安全的 preload 应该遵守两个原则:
- 只暴露白名单 API。
- 暴露业务动作,而不是底层能力。
比如文件读取,
contextBridge.exposeInMainWorld('api', {
fs,
require,
ipcRenderer
})这种写法等于把隔离层重新拆掉了。页面只要被注入脚本,攻击者就能直接拿这些能力继续利用。
更好的方式是把能力收窄:
contextBridge.exposeInMainWorld('electronAPI', {
getAppVersion: () => ipcRenderer.invoke('app:get-version'),
openFile: () => ipcRenderer.invoke('dialog:open-file')
})这里暴露的是明确的业务动作,不是任意 IPC 发送器。渲染进程不需要知道主进程里怎么打开文件,也不应该获得随便构造 channel 的能力。
如果确实需要传参数,也应该在 preload 或主进程里做结构校验。参数类型、长度、枚举值、路径范围都要做限制,不能把渲染进程传来的对象直接展开给系统 API。
IPC 才是真正的权限边界
Electron 里很多敏感操作最终都会落到 IPC。渲染进程发起请求,主进程负责执行。如果主进程把 IPC 当成内部可信调用,就很容易出问题。
主进程处理 IPC 时至少要检查三件事:
- 请求来源是不是预期窗口。
- channel 是否在允许列表内。
- 参数是否符合预期结构和权限范围。
来源校验经常被忽略。实际场景里,一个应用可能有主窗口、设置窗口、登录窗口、WebView,甚至会加载远程内容。不是所有窗口都应该拥有同样的 IPC 权限。主进程可以通过 BrowserWindow.fromWebContents(event.sender) 找到来源窗口,再结合自己维护的窗口列表判断是否允许调用。
输入校验也不能省。比如打开文件、保存文件、读取配置、执行更新、跳转外链,这些操作都不应该直接信任渲染进程传来的参数。渲染进程在安全模型里应该被当成“不可信客户端”,哪怕它是本地页面。
这里有一个实用判断:如果某个 IPC handler 里出现了文件路径、命令执行、URL 跳转、系统设置、自动更新、凭据读取等能力,就应该默认它是高风险入口,需要单独审计。
对漏洞挖掘的启发
从挖掘角度看,Electron 应用可以优先看几类点。
第一类是窗口配置。重点搜索 BrowserWindow、webPreferences、nodeIntegration、contextIsolation、preload、webviewTag。如果看到 nodeIntegration: true 或 contextIsolation: false,基本就值得继续跟。
第二类是 preload 暴露面。搜索 contextBridge.exposeInMainWorld,看暴露出去的对象是不是过大,有没有直接暴露 ipcRenderer、send、invoke、fs、shell、require。如果暴露的是通用调用器,再去看 channel 白名单是否完整。
第三类是 IPC handler。搜索 ipcMain.on、ipcMain.handle,看主进程是否做了来源校验和参数校验。很多漏洞不是出在 channel 本身,而是出在 handler 里调用了更危险的系统能力。
第四类是远程内容加载。关注 loadURL、webview、iframe、window.open、外链跳转和自定义协议。只要远程内容能进入带本地能力的窗口,就要非常谨慎。
Electron 安全不能只按 Web 的思路看,也不能只按客户端逆向的思路看。它的关键在“桥”上:Web 页面到 preload,preload 到 IPC,IPC 到主进程,每过一层都可能发生权限升级。配置只是基础,真正决定风险的是应用有没有把这些桥收窄、校验和隔离。
如果要尽量让 Electron 应用安全一些的话:
- 默认关闭
nodeIntegration。 - 默认开启
contextIsolation。 - preload 只暴露最小业务 API。
- 不向渲染进程暴露
require、fs、ipcRenderer原始对象。 - IPC handler 必须校验来源和参数。
- 远程内容和本地能力必须隔离。
- 高风险 API 单独审计,比如文件、命令、协议、更新、外链、凭据。
失败的尝试
叽里咕噜说了半天终于到重点了,这次选择的应用是 qwen studio 算是新的 app ,然后功能点也没多少,很诡异的是我第二天写文章时候怎么都打不开这个软件
打不开软件的原因是在测试 qwen:// 的 uri 协议后面拼了一些其他的字符串,导致一直卡在加载环节,但是我在定位到这个问题并测试了一会发现似乎被修复了,这次会返回 405 登陆失败
先 asar 解包

看一下如何开启 devtools ,开发很良心留了一个彩蛋,你在键盘输入 woshi149205 就可以,这是工号吗

测试起来是这样的,两个控制台

这应该算是主界面 + <webview> / BrowserView 标签: 常见的模式。应用的“外壳”(侧边栏、菜单、工具栏)是主 BrowserWindow,而应用中间加载外部网页、用户生成内容或复杂应用逻辑的区域,是一个嵌入的 <webview> 标签或 BrowserView。这两个区域是完全独立的进程。
可以通过这个看一下他们分别加载的界面,就知道后续它内部实际渲染的内容都是在右侧了
window.location.href
继续看一下 asar 包内的内容,在研究内部具体问题前,先留意几个重要的设置是否合理
index.js 中,sandbox 没开,nodeIntegrationInSubFrames: true 问题还是有点大,这样会在每一个 iframe 开辟 Electron Isolated Context,注入 preload
exports.mainWindow = new electron.BrowserWindow({
width: mainWindowState.width,
height: mainWindowState.height,
show: false,
center: true,
minWidth: 400,
titleBarStyle: process.platform === "darwin" ? "hidden" : void 0,
minHeight: 600,
webPreferences: {
preload: path.join(__dirname, "../preload/index.js"),
sandbox: false,
webviewTag: true,
nodeIntegration: false,
contextIsolation: true,
nodeIntegrationInSubFrames: true,
webSecurity: false,
allowRunningInsecureContent: true
}
});MCP 配置可注册任意系统命令并执行
文件: out/main/index.js:55-90(adaptConfig)、:395-404(mcpClientUpdateConfig) 根因: adaptConfig() 仅对 npx/bun/uvx 做路径替换,对任何其他命令字符串原样放行,然后 StdioClientTransport(cross-spawn)直接执行。
// adaptConfig() 仅处理三种命令名称,其余原样透传
if (cmd === "npx" || cmd === "bun") { cmd = getBunPath(); }
if (cmd === "uvx") { cmd = getUvxPath(); }
config.command = cmd; // 任意命令直接赋值,无校验所以我们只需要通过这样的方式就可以调用到 mcp 实现 rce
window.electronAPI.mcp_client_update_config({calc:{command:"C:\\Windows\\System32\\calc.exe",args:[]}}).then(()=>window.electronAPI.mcp_client_tool_list("calc"))先在 top 层上试一下

不过实际利用肯定没有这么简单,先摆上一个之前测试失败的案例,有两个主要问题,第一个是 llm 生成的 html 会放在一个特殊的iframe,开启了 sandbox 和一个同源策略,第二个是子 frame 的 preload 试图 require("@electron-toolkit/preload") 时在 asar 虚拟文件系统中解析失败。这导致 window.electron 和 window.electronAPI 都是 undefined,这会让 llm 生成的 html 没办法按照我们预想的那样展开


成功的利用
这一部分先略去
make electron safe again
这个问题在面试 WXG 的时候问到我,我当时完全不会这一方面,呃开启 sandbox 参数什么的。。。回答的像蛆
现在看来的话,几个设置的默认值
sandbox: true
webSecurity: true
allowRunningInsecureContent: falseIPC 添加调用来源校验
qwen 这个就是主进程 handler 忽略 event.sender,导致远端 webview 和本地壳页面权限一致。只要某个远端页面能调用 IPC,就能碰到 MCP、文件读取、打开外链、DevTools 等能力。
修复:所有敏感 IPC 加 sender 校验。
function isMainWindowSender(event) {
return exports.mainWindow &&
!exports.mainWindow.isDestroyed() &&
event.sender.id === exports.mainWindow.webContents.id
}
function requireMainWindow(event) {
if (!isMainWindowSender(event)) {
throw new Error("Forbidden IPC sender")
}
}
使用:
const mcpClientUpdateConfig = async (event, config) => {
requireMainWindow(event)
...
}对 webview 允许的 IPC 单独做 allowlist,不要共用父页面权限,给 webview 独立 preload,只暴露极少 API等等