前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >自定义协议 | Electron 安全

自定义协议 | Electron 安全

作者头像
意大利的猫
发布2024-05-17 19:57:57
840
发布2024-05-17 19:57:57
举报
文章被收录于专栏:漫流砂漫流砂

0x01 简介

大家好,今天和大家讨论的是自定义协议,在很多应用中,除了支持 http(s)fileftp等开放的通用标准协议外,还会支持一些自定义协议,自定义协议常被用于实现特殊功能,比如深度集成应用程序与特定的网络服务、提升用户体验或实现安全的数据交换。

例如 vscode 就注册了 vscode: 协议,在浏览器中输入 vscode://xxx 就会唤醒 vscode

这就属于在系统层面全局注册了自定义的 vscode:协议

在一些应用程序中,我们发现,调用资源不都是 http(s)file 这种,尤其像是加载插件之类的操作,内部用的也是类似于 vscode: 这种协议,这种就属于应用内注册自定义协议

今天的内容也是围绕着这两种情况进行讨论

公众号开启了留言功能,欢迎大家留言讨论~

这篇文章也提供了 PDF 版本及 Github ,见文末

0x02 程序内部注册自定义协议

1. 效果展示

官方给了一个案例,让我们可以注册一个和 file 协议相同效果的协议

代码语言:javascript
复制
const { app, protocol, net } = require('electron')

app.whenReady().then(() => {
  protocol.handle('atom', (request) =>
    net.fetch('file://' + request.url.slice('atom://'.length)))
})

这里是注册了一个 atom 协议,我们修改为 nopteam 协议,嘿嘿

代码语言:javascript
复制
const { app, protocol, net } = require('electron')

app.whenReady().then(() => {
  protocol.handle('nopteam,', (request) =>
    net.fetch('file://' + request.url.slice('nopteam://'.length)))
})

在渲染页面的 JavaScript 中使用 nopteam:// 协议

在 HTML 标签内使用 nopteam:// 协议

但只限于程序内容,在浏览器中输入 nopteam:///etc/passwd 并不可以打开我们的程序

2. 注册协议到特定 session

如果我们想将自定义的协议注册到特定的 session ,而不是默认的,可以使用以下代码

代码语言:javascript
复制
const { app, BrowserWindow, net, protocol, session } = require('electron')
const path = require('node:path')
const url = require('url')

app.whenReady().then(() => {
  const partition = 'persist:example'
  const ses = session.fromPartition(partition)

  ses.protocol.handle('atom', (request) => {
    const filePath = request.url.slice('atom://'.length)
    return net.fetch(url.pathToFileURL(path.join(__dirname, filePath)).toString())
  })

  const mainWindow = new BrowserWindow({ webPreferences: { partition } })
})

这里涉及两个概念, sessionpartition

Partition: 分区(Partition)是一种机制,用于将不同部分的应用数据隔离开来。每个分区都有其独立的Cookies、本地存储(localStorage)、IndexedDB等数据存储空间。

当你创建一个新的BrowserWindow或者WebContents时,可以通过指定partition参数来决定这个新窗口或页面的数据是否与其他窗口共享,或者是否持久化存储。

  • 当你设置partition:'persist:name'时,Electron 会为该窗口创建一个持久化的分区,即使应用重启,这个分区中的数据(如Cookie)也会被保留。
  • 如果不指定或者使用partition:''(空字符串),则使用一个临时的、匿名的分区,关闭窗口后相关数据会被清除

Session: 会话(Session)在 Electron 中是一个更高级的概念,它代表了一组配置和行为,用于控制网络请求、缓存策略、Cookie管理等。一个Session可以有自己的存储、Cookie和其他设置,并且可以被多个WebContents共享。

  • 创建Session: 你可以通过session.fromPartition()方法创建一个基于特定分区名的Session实例,或者直接使用session.defaultSession来获取应用的默认Session。
  • 控制行为: Session允许你控制例如是否允许使用缓存、是否发送Referer头、代理设置等网络行为,以及管理权限、证书等安全相关的方面。

这样上述代码就比较好理解了

3. protocol 模块的方法

1) registerSchemesAsPrivileged
代码语言:javascript
复制
protocol.registerSchemesAsPrivileged(customSchemes)

注意. 此方法只能在 appready 事件触发前调用,且只能调用一次

此方法用来对我们自定义协议(scheme)进行配置,可以注册为一个标准、安全、允许注册 ServiceWorker、支持获取API、流视频/音频和V8代码缓存的协议

示例代码如下

代码语言:javascript
复制
const { protocol } = require('electron')
protocol.registerSchemesAsPrivileged([
  { scheme: 'foo', privileges: { bypassCSP: true } }
])

CustomScheme 对象的内容结构如下

  • scheme 字符串 - 自定义的计划,可以被按选项注册。
  • privileges Object (可选)
    • standard boolean (可选) 默认为false 是否注册为标准协议
    • secure boolean (可选) - 默认为false 是否被视为安全协议,意味着它可以请求HTTPS资源而不会触发混合内容警告,并且在Web内容中可能不受同源策略的某些限制
    • bypassCSP boolean (可选) - 默认为false 如果设为true,则该协议下的资源可以绕过页面的Content Security Policy (CSP) 策略限制,这在某些特定场景下可能有用,但也可能带来安全风险
    • allowServiceWorkers boolean (可选) - 默认为false 允许在该协议下注册和使用Service Workers
    • supportFetchAPI boolean (可选) - 默认为false 启用后,允许在该协议下通过fetch API进行网络请求,这对于现代Web应用中异步数据获取非常重要
    • corsEnabled boolean (可选) - 默认为false 启用跨源资源共享(CORS),允许该协议下的资源被其他源的Web页面请求,这对于跨域数据交换是必需的
    • stream boolean (可选) - 默认为 false 如果设为true,则支持通过流(Streams)来传输数据,这在处理大文件或连续数据时可以提高效率和响应性
    • codeCache boolean (可选) - 默认为 false 启用支持 v8 代码缓存,只有在 standard 被设置为 true 时有效

标准scheme遵循 RFC 3986 所设定的 URI泛型语法 。例如, httphttps 是标准协议, 而 file 不是

按标准将一个scheme注册, 将保证相对和绝对资源在使用时能够得到正确的解析。否则, 该协议将表现为 file 协议, 而且,这种文件协议将不能解析相对路径

例如, 当您使用自定义协议加载以下内容时,如果你不将其注册为标准scheme, 图片将不会被加载, 因为非标准scheme无法识别相对 路径:

代码语言:javascript
复制
<body>
  <img src='test.png'>
</body>

注册一个scheme作为标准scheme将允许其通过FileSystem 接口访问文件。否则, 渲染器将会因为该scheme,而抛出一个安全性错误。

在非标准 schemes 下,网络存储 Api (localStorage, sessionStorage, webSQL, indexedDB, cookies) 默认是被禁用的。所以一般来说如果你想注册一个自定义协议来替换http协议,你必须将其注册为标准 scheme:

如果 Protocols 需要使用流 (http 和 stream 协议) 应设置 stream: true<video><audio> HTML 元素默认需要协议缓冲其响应内容。stream 标志将这些元素配置为正确的流媒体响应

2) handle

这个方法用来注册协议,并关联协议处理程序

代码语言:javascript
复制
protocol.handle(scheme, handler)
  • scheme 协议名,例如 https 不包含
  • handler 协议处理程序,是一个协议处理函数

当Electron遇到匹配到scheme的URL请求时 handler会被调用。这个函数接收一个request对象作为参数,并且通常需要调用一个回调函数,返回值是一个 Promise<GlobalResponse>

request 对象具体结构参考 https://nodejs.org/api/globals.html#request https://developer.mozilla.org/en-US/docs/Web/API/Request response 对象具体结构参考 https://developer.mozilla.org/en-US/docs/Web/API/Response

官方案例如下

代码语言:javascript
复制
const { app, net, protocol } = require('electron')
const path = require('node:path')
const { pathToFileURL } = require('url')

protocol.registerSchemesAsPrivileged([
  {
    scheme: 'app',
    privileges: {
      standard: true,
      secure: true,
      supportFetchAPI: true
    }
  }
])

app.whenReady().then(() => {
  protocol.handle('app', (req) => {
    const { host, pathname } = new URL(req.url)
    if (host === 'bundle') {
      if (pathname === '/') {
        return new Response('<h1>hello, world</h1>', {
          headers: { 'content-type': 'text/html' }
        })
      }
      // NB, this checks for paths that escape the bundle, e.g.
      // app://bundle/../../secret_file.txt
      const pathToServe = path.resolve(__dirname, pathname)
      const relativePath = path.relative(__dirname, pathToServe)
      const isSafe = relativePath && !relativePath.startsWith('..') && !path.isAbsolute(relativePath)
      if (!isSafe) {
        return new Response('bad', {
          status: 400,
          headers: { 'content-type': 'text/html' }
        })
      }

      return net.fetch(pathToFileURL(pathToServe).toString())
    } else if (host === 'api') {
      return net.fetch('https://api.my-server.com/' + pathname, {
        method: req.method,
        headers: req.headers,
        body: req.body
      })
    }
  })
})
3) unhandle
代码语言:javascript
复制
protocol.unhandle(scheme)

这个就很好理解了,取消注册协议

4) isProtocolHandled
代码语言:javascript
复制
protocol.isProtocolHandled(scheme)

一个 scheme 是否被注册为了一个协议,就是看一个协议有没有被注册过

参考文章 https://www.electronjs.org/zh/docs/latest/api/protocol

0x03 全局注册自定义协议

程序内部协议只能在程序内部使用,如果我们注册一个 nopteam 协议,希望在浏览器里输入 nopteam://index?id=1 时不仅可以唤醒我们的应用,应用还可以获取到链接内容,并且根据实际内容进行对应处理

1. 效果展示

我们希望 id=1 的时候,主窗口渲染 1.htmlid=2 时,主窗口渲染 2.html

1.html

代码语言:javascript
复制
<!DOCTYPE html>
<html>

<head>
  <meta charset="UTF-8">
  <!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
  <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'">
  <meta http-equiv="X-Content-Security-Policy" content="default-src 'self'; script-src 'self'">
  <title>1.html</title>
</head>

<body>
  <h1>I am 1.html !</h1>
</body>

</html>

2.html

代码语言:javascript
复制
<!DOCTYPE html>
<html>

<head>
  <meta charset="UTF-8">
  <!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
  <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'">
  <meta http-equiv="X-Content-Security-Policy" content="default-src 'self'; script-src 'self'">
  <title>2.html</title>
</head>

<body>
  <h1>I am 2.html !</h1>
</body>

</html>

main.js

代码语言:javascript
复制
// Modules to control application life and create native browser window
const { app, BrowserWindow, ipcMain, shell, dialog } = require('electron/main')
const path = require('node:path')
const url = require('url')

let mainWindow

if (process.defaultApp) {
  if (process.argv.length >= 2) {
    app.setAsDefaultProtocolClient('nopteam', process.execPath, [path.resolve(process.argv[1])])
  }
} else {
  app.setAsDefaultProtocolClient('nopteam')
}

const gotTheLock = app.requestSingleInstanceLock()

if (!gotTheLock) {
  app.quit()
} else {
  app.on('second-instance', (event, commandLine, workingDirectory) => {
    // Someone tried to run a second instance, we should focus our window.
    if (mainWindow) {
      if (mainWindow.isMinimized()) mainWindow.restore()
      mainWindow.focus()
    }

    const parsedUrl = new URL(commandLine.pop())
    const page_id = parsedUrl.searchParams.get('id')

    let page_path
    if (page_id === '1') {
      page_path = '1.html'
    } else if (page_id === '2') {
      page_path = '2.html'
    } else {
      app.quit()
    }
    
    console.log(page_path)
    mainWindow.loadFile(path.join(__dirname, page_path))
  })

  // Create mainWindow, load the rest of the app, etc...
  app.whenReady().then(() => {
    createWindow()
  })

  app.on('open-url', (event, url) => {
    dialog.showErrorBox('Welcome Back', `You arrived from: ${url}`)
  })
}

function createWindow () {
  // Create the browser window.
  mainWindow = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      preload: path.join(__dirname, 'preload.js')
    }
  })

  mainWindow.loadFile(path.join(__dirname, 'index.html'))
}

app.on('window-all-closed', function () {
  if (process.platform !== 'darwin') app.quit()
})

运行一次后,就会注册全局协议 nopteam ,之后在浏览器里输入 nopteam://index?id=1

当输入 nopteam://index?id=2

成功解析了我们的自定义 url

注册全局协议,主要使用app 模块的一些方法

2. app.setAsDefaultProtocolClient

将当前可执行文件的设置为协议(也就是 URI scheme) 的默认处理程序。该方法允许你将应用更深入地集成到操作系统中

代码语言:javascript
复制
app.setAsDefaultProtocolClient(protocol[, path, args])
  • protocol 协议名称,字符串类型
  • path 可选项,Electron 执行路径,默认为 process.execPath ,仅在 Windows 平台有用
  • args 可选项,传递给可执行文件的参数,默认是一个空数组,仅在 Windows 平台有用

注意: 在 macOS 上,您只能注册已添加到应用程序的 info.plist 中的协议,这个列表在运行时不能修改。然而,你可以在构建时通过 Electron Forge, Electron Packager, 或通过文本编辑器编辑info.plist文件的方式修改

3. app.removeAsDefaultProtocolClient

此方法检查当前可执行程序是否是协议(也就是URI scheme) 的默认处理程序。如果是,则会将应用移除默认处理器

代码语言:javascript
复制
app.removeAsDefaultProtocolClient(protocol[, path, args])

4. app.isDefaultProtocolClient

当前可执行程序是否是协议(也就是URI scheme) 的默认处理程序

代码语言:javascript
复制
app.isDefaultProtocolClient(protocol[, path, args])

5. app.getApplicationNameForProtocol

此方法返回URL协议(也就是URI scheme) 的默认处理器的应用程序名称

代码语言:javascript
复制
app.getApplicationNameForProtocol(url)
  • url 要检查的协议名称的 URL,不同于家族中的其他方法,该方法接收至少包含 :// (例如:https://)的完整URL

不同平台值可能不完全相同

6. app.getApplicationInfoForProtocol

此方法返回包含应用程序名称,图标和默认协议处理器路径(也就是URI scheme) 的Promise

代码语言:javascript
复制
app.getApplicationInfoForProtocol(url)
  • url string - 要检查的协议名称的 URL。不同于家族中的其他方法,该方法接收至少包含 :// (例如:https://)的完整URL

返回 Promise<Object> - resolve 包含以下内容的 object:

  • icon NativeImage - 处理协议的应用程序的显示图标。
  • path string - 处理协议的应用程序的安装路径。
  • name string - 处理协议的应用程序的显示名称。

参考文章 https://www.electronjs.org/zh/docs/latest/tutorial/launch-app-from-url-in-another-app https://www.electronjs.org/docs/latest/api/app#appsetasdefaultprotocolclientprotocol-path-args

0x04 漏洞案例

这种注册自定义协议具体实现方法不同程序不一致,所以在做安全检查时,也需要根据实际情况,接下来列举几个曾经在注册自定义协议方面出现的问题

需要注意的是,外部引用的安全防护代码可能不会针对自定义协议进行防护,这也是造成很多漏洞的直接原因

CVE-2018-1000006

这个漏洞是个Windows 平台独有的漏洞,在注册全局协议时,用户可以控制 URL,打开特定的 URL 时,URL中的一部分可能会闭合处理程序的语法,导致另一部分成为传递给处理程序的参数,配合 Chromium 的一些特殊参数,最终导致命令执行,下方参考链接中先知社区的文章对其分析得比较好,建议观看

参考文章 https://www.electronjs.org/blog/protocol-handler-fix https://xz.aliyun.com/t/1994?time__1311=n4%2Bxni0QDQdYqDvPBKDsL3ObDcBIKKriTo4D&alichlgref=https%3A%2F%2Fwww.google.com%2F https://blog.doyensec.com/2018/05/24/electron-win-protocol-handler-bug-bypass.html

typora (CVE-2023-2317)

https://xz.aliyun.com/t/12822?time__1311=mqmhq%2BxfxIhGkDlxGo%2Bzd4Dv5TNDjETD&alichlgref=https%3A%2F%2Fwww.google.com%2F

低于1.67版本的Typora存在代码执行漏洞,通过在标签中加载typora://app/typemark/updater/update.html实现在Typora主窗口的上下文中运行任意JavaScript代码

0x05 总结

注册自定义协议通常用来实现特殊功能,比如深度集成应用程序与特定的网络服务、提升用户体验或实现安全的数据交换、插件等

自定义协议关联的处理程序几乎没有特别多的共性,完全由需求决定,因此可能会由于不够健硕的代码而带来一些安全风险,这部分漏洞的挖掘需要对 protocolapp 模块的相关方法进行分析,查找攻击的可能

0x06 PDF 版 & Github

PDF

https://pan.baidu.com/s/1d6gSFG9DPP_oZlpRZdOztw?pwd=am8x

Github

https://github.com/Just-Hack-For-Fun/Electron-Security

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2024-05-11,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 NOP Team 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 0x01 简介
  • 0x02 程序内部注册自定义协议
    • 1. 效果展示
      • 2. 注册协议到特定 session
        • 3. protocol 模块的方法
          • 1) registerSchemesAsPrivileged
          • 2) handle
          • 3) unhandle
          • 4) isProtocolHandled
      • 0x03 全局注册自定义协议
        • 1. 效果展示
          • 2. app.setAsDefaultProtocolClient
            • 3. app.removeAsDefaultProtocolClient
              • 4. app.isDefaultProtocolClient
                • 5. app.getApplicationNameForProtocol
                  • 6. app.getApplicationInfoForProtocol
                  • 0x04 漏洞案例
                  • 0x05 总结
                  • 0x06 PDF 版 & Github
                  相关产品与服务
                  数据保险箱
                  数据保险箱(Cloud Data Coffer Service,CDCS)为您提供更高安全系数的企业核心数据存储服务。您可以通过自定义过期天数的方法删除数据,避免误删带来的损害,还可以将数据跨地域存储,防止一些不可抗因素导致的数据丢失。数据保险箱支持通过控制台、API 等多样化方式快速简单接入,实现海量数据的存储管理。您可以使用数据保险箱对文件数据进行上传、下载,最终实现数据的安全存储和提取。
                  领券
                  问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档