2.4 上下文隔离(Context Isolation)与预加载脚本(Preload Script)


2.4 上下文隔离(Context Isolation)与预加载脚本(Preload Script)

本节摘要:上下文隔离让页面脚本与预加载脚本运行在互不可见的 V8 上下文中,预加载脚本通过 contextBridge 以白名单方式向页面暴露安全接口,是现代 Electron 安全架构的核心组件。本节讲清隔离前后的可见性差异、preload 的加载时机与能力边界、exposeInMainWorld 的设计要点,并用"把一个任意命令执行漏洞改造成安全接口"的对照案例说明为什么这套机制是强制配置。

没有隔离的世界什么样

时间倒回 Electron 12 之前。默认配置下,预加载脚本与页面共享同一个 JavaScript 上下文:preload 里定义的全局变量、挂在原型上的扩展,页面代码都能直接看到、直接改。听起来方便,实则是把保险箱焊在了门框外侧。攻击路径说穿了一文不值:

// 旧世界的 preload:把 ipcRenderer 直接塞给页面 window.ipcRenderer = require('electron').ipcRenderer; // 页面里任何脚本(包括被 XSS 注入的)都能: // window.ipcRenderer.send('任意通道', '任意参数')

只要页面上存在任何一个 XSS 点——一条未转义的评论、一个被污染的依赖包、一段加载的远程内容——注入的脚本就继承了 ipcRenderer 的全部话语权。如果主进程里恰好注册了一个"执行更新命令"类的通道,攻击者等于拿到了系统级跳板。历史上多起 Electron 应用的远程代码执行漏洞,起点都是这个模式。

隔离之后:一堵墙加一扇白名单窗口

上下文隔离开启后(Electron 12 起的默认值),V8 里并存两个互不可见的上下文:preload 世界(能看到一部分 Node/Electron API)与页面世界(纯粹浏览器环境)。两个世界之间唯一的合法通道是 contextBridge:

// 现代写法:preload 里只暴露白名单 const { contextBridge, ipcRenderer } = require('electron'); contextBridge.exposeInMainWorld('bridge', { // 页面能看到的只有这个对象的这三个方法 openImage: () => ipcRenderer.invoke('image:open'), saveMetadata: (data) => ipcRenderer.invoke('meta:save', data), onImportProgress: (cb) => { // 事件订阅也要包一层,并处理重复订阅问题 const listener = (_e, payload) => cb(payload); ipcRenderer.on('import:progress', listener); return () => ipcRenderer.removeListener('import:progress', listener); } });

隔离带来的保证是双向的:页面脚本摸不到 ipcRenderer 本体,改不了 preload 的内部状态,能调用的只有白名单上签名明确的函数;反过来,preload 也不污染页面的全局环境。即使页面被注入恶意脚本,它面对的不再是"整条 IPC 公路",而是三个装有护栏的出入口。

图:隔离前后的能力可见性对比

图:隔离前后的能力可见性对比

preload 的加载时机与边界

preload 在每个渲染进程创建时、页面任何脚本运行之前注入执行,这让它天然胜任"渲染进程初始化钩子"的角色:注入版本信息、读取启动参数、装订桥接口,都在页面睁眼看世界之前完成。能力边界同样要记牢:沙箱开启时(2.5 节,也是默认),preload 里可用的 API 缩减到一个精选子集——ipcRenderer、contextBridge、少量网页控制接口,文件系统与大多数 Node 模块不可用。preload 的设计定位是"海关窗口",不是"小型主进程",想动文件就让主进程代办,别在窗口里囤货。

exposeInMainWorld 的接口设计有几条经验法则。暴露的函数名用业务语言(openImage 而不是 ipc-invoke-1),让页面代码可读;参数做校验,别把任意输入原样转发进主进程;事件订阅返回反注册函数,防止组件销毁后监听器堆积;永远不要暴露 ipcRenderer 本体或任何"通用转发"函数——那等于把白名单撕开一个通用豁口:

// 危险:通用转发器让白名单形同虚设 contextBridge.exposeInMainWorld('bridge', { call: (channel, ...args) => ipcRenderer.invoke(channel, ...args) // 禁止! });

案例:一次安全改造

背景:一款 Markdown 笔记应用,旧版 preload 把 ipcRenderer 整体挂到页面,页面直接 send 一个 export:pdf 通道让主进程调系统命令行工具转 PDF。某次安全评审发现,笔记内容里的一个第三方组件有 XSS 漏洞,而 export:pdf 通道把文件名参数未加校验地拼进了命令行——理论上,一篇恶意笔记就能在被打开时执行任意命令。

操作:改造分三步。preload 换成 contextBridge,只暴露 exportPdf 一个方法;主进程的处理器改为接收结构化参数,文件名做白名单字符校验,用参数数组形式调用外部工具(杜绝字符串拼接);页面对接新接口,删除所有对旧通道的直连。

结果:改造后,同样的 XSS 注入点还在,但注入脚本能做的只剩"以合法参数调用导出 PDF"——命令执行路径不复存在。这次改造没有修那个 XSS 漏洞本身(那是另一个工单),却把它的影响半径从"系统级"压到"功能级"。

解读:这就是纵深防御的含义——单点失守不等于全线崩溃。变式:如果该应用还要加载远程内容,改造还需叠加禁用导航、限制窗口打开行为等措施,第四章的安全基线会把清单补全。

本节要点回顾

  • 旧世界病灶:共享上下文里 ipcRenderer 整体暴露,XSS 即全权代理;
  • 新世界结构:双上下文互不可见,contextBridge 白名单是唯一窗口;
  • 时机与边界:preload 先于页面执行,沙箱下 API 收窄,定位是海关不是仓库;
  • 设计法则:业务化命名、参数校验、可反注册订阅、严禁通用转发器;
  • 案例核心:隔离不消灭漏洞,但把漏洞的影响半径压到功能级。

安检门装好了,下一节把房间也换小:沙箱模型如何把渲染进程关进真正的 Chromium 沙箱。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U