Skip to content

失败的 Electron 应用漏洞挖掘尝试(已成功)

约 2869 字大约 10 分钟

electron

2026-05-19

挖掘之前先简单写一点 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 暴露了过大的能力,比如直接把 ipcRendererfsrequire 挂到 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 应该遵守两个原则:

  1. 只暴露白名单 API。
  2. 暴露业务动作,而不是底层能力。

比如文件读取,

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 应用可以优先看几类点。

第一类是窗口配置。重点搜索 BrowserWindowwebPreferencesnodeIntegrationcontextIsolationpreloadwebviewTag。如果看到 nodeIntegration: truecontextIsolation: false,基本就值得继续跟。

第二类是 preload 暴露面。搜索 contextBridge.exposeInMainWorld,看暴露出去的对象是不是过大,有没有直接暴露 ipcRenderersendinvokefsshellrequire。如果暴露的是通用调用器,再去看 channel 白名单是否完整。

第三类是 IPC handler。搜索 ipcMain.onipcMain.handle,看主进程是否做了来源校验和参数校验。很多漏洞不是出在 channel 本身,而是出在 handler 里调用了更危险的系统能力。

第四类是远程内容加载。关注 loadURLwebviewiframewindow.open、外链跳转和自定义协议。只要远程内容能进入带本地能力的窗口,就要非常谨慎。

Electron 安全不能只按 Web 的思路看,也不能只按客户端逆向的思路看。它的关键在“桥”上:Web 页面到 preload,preload 到 IPC,IPC 到主进程,每过一层都可能发生权限升级。配置只是基础,真正决定风险的是应用有没有把这些桥收窄、校验和隔离。

如果要尽量让 Electron 应用安全一些的话:

  • 默认关闭 nodeIntegration
  • 默认开启 contextIsolation
  • preload 只暴露最小业务 API。
  • 不向渲染进程暴露 requirefsipcRenderer 原始对象。
  • IPC handler 必须校验来源和参数。
  • 远程内容和本地能力必须隔离。
  • 高风险 API 单独审计,比如文件、命令、协议、更新、外链、凭据。

失败的尝试

叽里咕噜说了半天终于到重点了,这次选择的应用是 qwen studio 算是新的 app ,然后功能点也没多少,很诡异的是我第二天写文章时候怎么都打不开这个软件

打不开软件的原因是在测试 qwen:// 的 uri 协议后面拼了一些其他的字符串,导致一直卡在加载环节,但是我在定位到这个问题并测试了一会发现似乎被修复了,这次会返回 405 登陆失败

先 asar 解包

image-20260519101449635

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

image-20260519102614183

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

image-20260519173019566

这应该算是主界面 + <webview> / BrowserView 标签: 常见的模式。应用的“外壳”(侧边栏、菜单、工具栏)是主 BrowserWindow,而应用中间加载外部网页、用户生成内容或复杂应用逻辑的区域,是一个嵌入的 <webview> 标签或 BrowserView。这两个区域是完全独立的进程。

可以通过这个看一下他们分别加载的界面,就知道后续它内部实际渲染的内容都是在右侧了

window.location.href

image-20260519213211681

继续看一下 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 做路径替换,对任何其他命令字符串原样放行,然后 StdioClientTransportcross-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 层上试一下

image-20260519220000792

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

image-20260519220130965

image-20260519220233771

成功的利用

这一部分先略去

make electron safe again

这个问题在面试 WXG 的时候问到我,我当时完全不会这一方面,呃开启 sandbox 参数什么的。。。回答的像蛆

现在看来的话,几个设置的默认值

sandbox: true            
webSecurity: true        
allowRunningInsecureContent: false

IPC 添加调用来源校验

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等等